diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 7819c93d2..0410a0b70 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -30,6 +30,7 @@ import { AppUpdatePrompt } from "@/components/app-update-prompt"; import { OfflineHost } from "@/components/offline/offline-host"; import { DeviceWipeHost } from "@/components/offline/device-wipe-host"; import { NotificationActionsHost } from "@/components/providers/notification-actions-host"; +import { DesktopOsHost } from "@/components/desktop/desktop-os-host"; import { DevicePolicyHost } from "@/components/device/device-policy-host"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt"; @@ -89,6 +90,7 @@ export default async function LocaleLayout({ + {!IS_LITE && } diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index df771c490..6243bc78c 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -1520,9 +1520,10 @@ function LoginPageContent() { )} {refusedWaiting.map((wait) => { const date = new Date(wait.until).toLocaleDateString(String(params?.locale ?? "en"), { dateStyle: "medium" }); + const prefix = wait.kind === "session" ? "session_waiting" : "refused_waiting"; return (
- {wait.unsent === null ? tOfflineWipe("refused_waiting_unknown", { date }) : tOfflineWipe("refused_waiting", { count: wait.unsent, date })} + {wait.unsent === null ? tOfflineWipe(`${prefix}_unknown`, { date }) : tOfflineWipe(prefix, { count: wait.unsent, date })}
); })} diff --git a/app/(main)/admin/_pages/device-registry-panel.tsx b/app/(main)/admin/_pages/device-registry-panel.tsx index db4ef63fa..eba321350 100644 --- a/app/(main)/admin/_pages/device-registry-panel.tsx +++ b/app/(main)/admin/_pages/device-registry-panel.tsx @@ -235,6 +235,7 @@ function wipeReasonLabel(reason: NonNullable): string switch (reason) { case 'user': return 'by the user'; case 'rejected': return 'the server refused the login'; + case 'session': return 'the session limit ran out'; default: return reason; } } diff --git a/app/api/devices/__tests__/route.test.ts b/app/api/devices/__tests__/route.test.ts index 3d26acf90..055485d39 100644 --- a/app/api/devices/__tests__/route.test.ts +++ b/app/api/devices/__tests__/route.test.ts @@ -18,7 +18,7 @@ vi.mock('@/lib/admin/session', async (original) => ({ getClientIP: () => client.ip, })); -import { POST as checkIn, REGISTER_PER_HOUR } from '../route'; +import { POST as checkIn } from '../route'; import { POST as wiped } from '../wiped/route'; import { GET as adminList, POST as adminAct } from '../../admin/devices/route'; import * as registry from '@/lib/devices/registry'; @@ -120,12 +120,12 @@ describe('POST /api/devices', () => { it('holds new rows to a much lower limit per address than check-ins, and a 429 changes nothing', async () => { creds.value = { accountName: 'alice@example.org', serverUrl: 'https://mail.example.org' }; const statuses: number[] = []; - for (let i = 0; i <= REGISTER_PER_HOUR; i += 1) { + for (let i = 0; i <= registry.REGISTER_PER_HOUR; i += 1) { statuses.push((await checkIn(request('/api/devices', { platform: 'x', appVersion: '1' }))).status); } - expect(statuses.slice(0, REGISTER_PER_HOUR).every((status) => status === 200)).toBe(true); + expect(statuses.slice(0, registry.REGISTER_PER_HOUR).every((status) => status === 200)).toBe(true); expect(statuses.at(-1)).toBe(429); - expect((await registry.listDevices()).total).toBe(REGISTER_PER_HOUR); + expect((await registry.listDevices()).total).toBe(registry.REGISTER_PER_HOUR); // A device that's already registered still hears about its wipe. const [{ id }] = (await registry.listDevices()).devices; diff --git a/app/api/devices/route.ts b/app/api/devices/route.ts index 40748e1fe..3d3be4c91 100644 --- a/app/api/devices/route.ts +++ b/app/api/devices/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getClientIP } from '@/lib/admin/session'; -import { checkInDevice, DEVICE_ID_RE, DEVICE_SECRET_RE, registerDevice, TooManyDevicesError } from '@/lib/devices/registry'; +import { checkInDevice, DEVICE_ID_RE, DEVICE_SECRET_RE, REGISTER_PER_HOUR, registerDevice, TooManyDevicesError } from '@/lib/devices/registry'; import { logger } from '@/lib/logger'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { getStore } from '@/lib/shared-store'; @@ -11,12 +11,6 @@ const NO_STORE = { 'Cache-Control': 'no-store' }; const MAX_BODY_BYTES = 2048; // Per address: a whole office can sit behind one, checking in as it starts up. const PER_MINUTE = 300; -/** - * A new row is written to the state dir and stays half a year, for anyone with a - * session. Enough for an office turning this on in one morning: a device turned - * away tries again in a quarter of an hour. - */ -export const REGISTER_PER_HOUR = 60; function cleanText(value: unknown): string { if (typeof value !== 'string') return ''; diff --git a/app/api/errors/__tests__/route.test.ts b/app/api/errors/__tests__/route.test.ts index d53e1dde6..c32f23499 100644 --- a/app/api/errors/__tests__/route.test.ts +++ b/app/api/errors/__tests__/route.test.ts @@ -102,3 +102,22 @@ describe('POST /api/errors', () => { expect((await POST(post('not json'))).status).toBe(400); }); }); + +describe('a desktop app\'s version', () => { + it.each(['0.3.0-beta.1+desktop', '1.20.0+desktop', '1.20.0-rc.2'])('keeps %s', async (version) => { + config.errorReportingDsn = DSN; + const res = await POST(post({ name: 'TypeError', frames: [], version })); + expect(res.status).toBe(204); + await flushErrors(); + const day = (await readErrors())[utcDay()] ?? {}; + expect(Object.values(day).map((entry) => entry.version)).toContain(version); + }); + + it.each(['1.20.0+a+b', '1.20.0-beta/../x', 'not a version'])('falls back to the server\'s for %s', async (version) => { + config.errorReportingDsn = DSN; + await POST(post({ name: 'RangeError', frames: [], version })); + await flushErrors(); + const day = (await readErrors())[utcDay()] ?? {}; + expect(Object.values(day).map((entry) => entry.version)).not.toContain(version); + }); +}); diff --git a/app/api/errors/route.ts b/app/api/errors/route.ts index b31a38952..0bdb02235 100644 --- a/app/api/errors/route.ts +++ b/app/api/errors/route.ts @@ -65,7 +65,7 @@ export async function POST(request: NextRequest) { 'browser', // The version the tab runs (a tab left open across an update runs the // old one, and its frames point into that); the server's when absent. - typeof body.version === 'string' && /^\d+\.\d+\.\d+(?:[-+][\w.-]{1,40})?$/.test(body.version) ? body.version : appVersion(), + typeof body.version === 'string' && /^\d+\.\d+\.\d+(?:-[\w.-]{1,40})?(?:\+[\w.-]{1,40})?$/.test(body.version) ? body.version : appVersion(), ); countBrowserError(); return new NextResponse(null, { status: 204, headers: NO_STORE }); diff --git a/components/admin/org/__tests__/offline-section.test.tsx b/components/admin/org/__tests__/offline-section.test.tsx index dfcbe2415..2b5630630 100644 --- a/components/admin/org/__tests__/offline-section.test.tsx +++ b/components/admin/org/__tests__/offline-section.test.tsx @@ -61,12 +61,18 @@ describe('Offline mode section', () => { it('leaves a refused login\'s data alone by default, and can ask for deletion at once, per scope', async () => { await renderSection(); - const toggle = screen.getByTestId('org-devices-wipeOnRefusedLogin'); + const toggle = screen.getByTestId('org-devices-wipeOnForcedSignOut'); expect(toggle.getAttribute('aria-checked')).toBe('false'); act(() => latest!.setScopeId('field')); - fireEvent.click(screen.getByTestId('org-devices-wipeOnRefusedLogin')); - expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ devices: { wipeOnRefusedLogin: true } }); - expect(latest!.policy?.organisation?.devices.wipeOnRefusedLogin).toBe(false); + fireEvent.click(screen.getByTestId('org-devices-wipeOnForcedSignOut')); + expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ devices: { wipeOnForcedSignOut: true } }); + expect(latest!.policy?.organisation?.devices.wipeOnForcedSignOut).toBe(false); + }); + + it('says what a sign-out does now, for the timers too', async () => { + await renderSection(); + expect(document.body.textContent).not.toContain('Every sign-out, the automatic one after inactivity included, deletes everything'); + expect(document.body.textContent).toContain('A sign-out someone asks for deletes everything'); }); it('writes a scope override only into that scope', async () => { diff --git a/components/admin/org/sections/offline-section.tsx b/components/admin/org/sections/offline-section.tsx index ed51c2708..9a806ccbc 100644 --- a/components/admin/org/sections/offline-section.tsx +++ b/components/admin/org/sections/offline-section.tsx @@ -50,7 +50,7 @@ const describeCap = (value: number, unit: string) => (value > 0 ? `${value} ${un export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) { const forbid = draft.orgValue('devices', 'noOffline'); const noAttachments = draft.orgValue('devices', 'noOfflineAttachments'); - const wipeOnRefused = draft.orgValue('devices', 'wipeOnRefusedLogin'); + const wipeOnRefused = draft.orgValue('devices', 'wipeOnForcedSignOut'); return (
- draft.setOrgValue('devices', 'wipeOnRefusedLogin', v)} testId="org-devices-wipeOnRefusedLogin" /> - draft.clearOrgValue('devices', 'wipeOnRefusedLogin')} /> + draft.setOrgValue('devices', 'wipeOnForcedSignOut', v)} testId="org-devices-wipeOnForcedSignOut" /> + draft.clearOrgValue('devices', 'wipeOnForcedSignOut')} />

@@ -120,8 +120,8 @@ export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) { with calendar, contacts and files, encrypted on the device. That protects against someone copying its files, not against someone using the unlocked browser or app. The limits above can only lower what people pick: where several scopes apply, the lowest value counts, so a scope can't raise what another one lowered. The app holds itself to - them when it fetches the policy; the server can't check what is on a device. Every sign-out, the automatic one - after inactivity included, deletes everything. With several accounts signed in, each account follows its own settings. + them when it fetches the policy; the server can't check what is on a device. A sign-out someone asks for deletes everything. + One that nobody asked for hides it, as the switch above says. With several accounts signed in, each account follows its own settings. Bulwark Lite reads the same policy but has no offline mode.{' '} How offline mode works diff --git a/components/calendar/missed-reminders-dialog.tsx b/components/calendar/missed-reminders-dialog.tsx new file mode 100644 index 000000000..2b4ed8af3 --- /dev/null +++ b/components/calendar/missed-reminders-dialog.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useLocale, useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { getPathPrefix } from "@/lib/browser-navigation"; +import { reminderTimeZone, useMissedReminders } from "@/lib/calendar-reminders/state"; +import { dismissReminders, joinLabel, joinMeeting } from "@/lib/desktop/reminder-notifications"; +import { useSettingsStore } from "@/stores/settings-store"; + +/** What came due while the app was closed, the machine slept or notifications were paused. */ +export function MissedRemindersDialog() { + const t = useTranslations("desktopReminders"); + const tAlert = useTranslations("calendar.notifications"); + const locale = useLocale(); + const timeZone = useSettingsStore((s) => s.timeZone); + const account = useMissedReminders((s) => s.account); + const alerts = useMissedReminders((s) => s.alerts); + const close = useMissedReminders((s) => s.close); + const joinLabels = { join: t("join"), joinHost: (host: string) => t("join_host", { host }) }; + + if (!account || alerts.length === 0) return null; + + const format = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short", timeZone: reminderTimeZone(timeZone) }); + const finish = () => { + dismissReminders(account, alerts.map((alert) => alert.key)); + close(); + }; + + return ( +

+ + + + )} + > +
    + {alerts.map((alert) => ( +
  • +
    +

    {alert.title || tAlert("alert_title")}

    +

    + {format.format(new Date(alert.startMs ?? alert.fireTimeMs))} + {alert.calendarName ? ` · ${alert.calendarName}` : ""} +

    +
    + {alert.joinUrl && ( + + )} +
  • + ))} +
+
+ ); +} diff --git a/components/desktop/desktop-os-host.tsx b/components/desktop/desktop-os-host.tsx new file mode 100644 index 000000000..a6e522136 --- /dev/null +++ b/components/desktop/desktop-os-host.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { useArchiveActionEnabled } from "@/lib/archive-gate"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import { isAppLocked } from "@/lib/desktop/app-lock-state"; +import { contentHiddenFor, startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications"; +import { startOsSummary } from "@/lib/desktop/os-summary"; +import { startPowerWatch } from "@/lib/desktop/power"; +import { startWindowFiles } from "@/lib/desktop/window-files"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; + +/** + * The main window's end of the desktop app's OS integration: new mail as native + * notifications, what the tray and jump list show, waking up after sleep, and files + * dropped or pasted on the window. Renders nothing, and does nothing in a browser. + * Lives inside MainWindowOnly, so pop-outs don't do any of it a second time. + */ +export function DesktopOsHost() { + const t = useTranslations("desktopOs"); + const tNotify = useTranslations("notifications"); + const tPush = useTranslations("pwaNotify"); + const locale = useLocale(); + const archiveEnabled = useArchiveActionEnabled(); + const clientKey = useAccountStore((s) => s.accounts.map((a) => a.id).join("\n")); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const mailRef = useRef(null); + const summaryRef = useRef<{ refresh: () => void } | null>(null); + const localeRef = useRef(locale); + localeRef.current = locale; + + const labels: MailNotificationLabels = { + newMail: t("new_mail"), + newMailCount: (count) => tNotify("new_mail_count", { count }), + noSubject: t("no_subject"), + archive: archiveEnabled ? tPush("action_archive") : null, + markRead: tPush("action_mark_read"), + delete: t("action_delete"), + reply: tPush("action_reply"), + replyPlaceholder: tPush("reply_placeholder"), + }; + const labelsRef = useRef(labels); + labelsRef.current = labels; + + useEffect(() => { + const os = getDesktopBridge()?.os; + if (!os) return; + // Main shows no content anywhere until this page has said it may. + os.setLocked(isAppLocked()); + const mail = startMailNotifications(labelsRef.current); + mailRef.current = mail; + const summary = startOsSummary(() => localeRef.current); + summaryRef.current = summary; + const stopPower = startPowerWatch(); + const stopFiles = startWindowFiles(); + return () => { + mail.stop(); + summary.stop(); + stopPower(); + stopFiles(); + mailRef.current = null; + summaryRef.current = null; + }; + }, []); + + useEffect(() => { + mailRef.current?.setLabels(labels); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [locale, archiveEnabled]); + + // Every login needs a state to work from before its first push. + useEffect(() => { + const mail = mailRef.current; + if (!mail || !isAuthenticated) return; + const auth = useAuthStore.getState(); + const clients = new Set(useAccountStore.getState().accounts.flatMap((account) => auth.getClientForAccount(account.id) ?? [])); + if (auth.client) clients.add(auth.client); + for (const client of clients) mail.seed(client); + summaryRef.current?.refresh(); + }, [isAuthenticated, clientKey]); + + return null; +} diff --git a/components/device/__tests__/device-gates.test.tsx b/components/device/__tests__/device-gates.test.tsx index bb86a6b7b..5557f2eed 100644 --- a/components/device/__tests__/device-gates.test.tsx +++ b/components/device/__tests__/device-gates.test.tsx @@ -129,6 +129,8 @@ describe('No export', () => { await waitFor(() => expect(discard).toHaveBeenCalledWith(true)); expect(controller.signal.aborted).toBe(true); expect(useMailTransferStore.getState().job).toBeNull(); + // The dialog just went: this is all that says why. + expect(toast.info).toHaveBeenCalledWith('export_blocked'); }); }); diff --git a/components/device/device-policy-host.tsx b/components/device/device-policy-host.tsx index d7465be08..acfaa9c7c 100644 --- a/components/device/device-policy-host.tsx +++ b/components/device/device-policy-host.tsx @@ -4,7 +4,7 @@ import { useEffect } from 'react'; import { createPortal } from 'react-dom'; import { useTranslations } from 'next-intl'; import { useDevicePolicy, useStrictestDevices } from '@/hooks/use-device-policy'; -import { DOWNLOAD_BLOCKED_EVENT, type DownloadKind } from '@/lib/download-guard'; +import { DOWNLOAD_BLOCKED_EVENT, downloadAllowed, type DownloadKind } from '@/lib/download-guard'; // Installs the download guard (lib/download-guard.ts) for every page. import '@/lib/device-policy'; import { useMailTransferStore } from '@/stores/mail-transfer-store'; @@ -48,6 +48,8 @@ export function DevicePolicyHost() { if (store.job?.kind !== 'export') return; const job = store.job; store.cancelJob('policy'); + // This closes the dialog too, which would otherwise be gone with no reason given. + downloadAllowed('export', null); // The private file goes at once, once the job has stopped: nothing may download it any more. const discard = () => { if (useMailTransferStore.getState().job?.controller === job.controller) useMailTransferStore.getState().close({ now: true }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index db59d734e..5bb3b1821 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -174,6 +174,7 @@ import { getDesktopBridge } from "@/lib/desktop/bridge"; import { noteOpenDraft } from "@/lib/desktop/window-restore"; import type { RescuedDraft } from "@/lib/popout/channel"; import { toUnicodeEmail } from "@/lib/idn"; +import { setComposerFileTarget } from "@/lib/desktop/window-files"; /** * Derives the text/plain alternative from the composer's HTML body, preserving @@ -2711,6 +2712,12 @@ export function EmailComposer({ const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar'); // The plugin side panels fold when the composer gets too narrow for them. const [composerRootEl, setComposerRootEl] = useState(null); + + // The desktop app: files dropped or pasted anywhere on the window come here, if this one's on screen. + useEffect(() => setComposerFileTarget( + (files) => void addFiles(files), + () => !!composerRootEl?.isConnected && composerRootEl.getClientRects().length > 0, + ), [addFiles, composerRootEl]); // The id plugins know this composer by (registered below, with the handle). const [pluginComposerId, setPluginComposerId] = useState(null); const pluginSidebars = useComposerSidebars(composerRootEl, pluginComposerId); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index f92e2926c..4f9c51111 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -679,6 +679,7 @@ interface DraggableAttachmentChipProps { onPointerEnter: () => void; onDragStart: (e: React.DragEvent) => void; onDragEnd: (e: React.DragEvent) => void; + onCopyAsFile?: () => Promise; }) => React.ReactNode; } @@ -686,6 +687,19 @@ function DraggableAttachmentChip({ attachment, client, accountId, enabled, downl const source = useMemo(() => ({ name: downloadName || attachment.name || 'download', type: attachment.type || 'application/octet-stream', + // A decrypted attachment doesn't go through the app's temporary folder or the clipboard: it + // stays with the browser's drag, which only writes it where the user drops it. + ...(attachment.decryptedAttachment ? {} : { + getBlob: async () => { + if (attachment.blobId && client) { + return client.fetchBlob(attachment.blobId, downloadName || attachment.name || undefined, attachment.type, accountId).catch(() => null); + } + const bytes = attachment.tnefData; + if (!bytes || bytes.byteLength === 0) return null; + const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; + return new Blob([buffer], { type: inertBlobType(attachment.type) }); + }, + }), getBlobUrl: async () => { if (attachment.blobId && client) { try { diff --git a/components/email/reader-attachments.tsx b/components/email/reader-attachments.tsx index d04e16c09..167767905 100644 --- a/components/email/reader-attachments.tsx +++ b/components/email/reader-attachments.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, type DragEvent, type ReactNode } from "react"; import { useTranslations } from "next-intl"; -import { ChevronDown, ChevronUp, Download, FolderInput, Loader2, Paperclip } from "@/components/icons"; +import { ChevronDown, ChevronUp, Copy, Download, FolderInput, Loader2, Paperclip } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { ContextMenu, ContextMenuItem } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; @@ -11,6 +11,7 @@ import { IconButton } from "@/components/ui/icon-button"; import { getAttachmentDisplayName } from "@/lib/attachment-name"; import { cn, formatFileSize } from "@/lib/utils"; import { noMediaSavingProps } from "@/lib/device-frame"; +import { toast } from "@/stores/toast-store"; import { attachmentIcon } from "./attachment-chips"; export interface ReaderAttachmentItem { @@ -29,6 +30,8 @@ export interface AttachmentDragProps { onPointerEnter: () => void; onDragStart: (e: DragEvent) => void; onDragEnd: (e: DragEvent) => void; + /** The desktop app only. */ + onCopyAsFile?: () => Promise; } /** Past this many tiles the bar shows one row less and a "Show N more" tile. */ @@ -173,6 +176,7 @@ export function ReaderAttachments({ )} {pluginMenu?.(attachment)} + {!downloadsOff && drag.onCopyAsFile && } {!downloadsOff && ( ({ )} {pluginMenu?.(attachment)} + {!downloadsOff && drag.onCopyAsFile && } {!downloadsOff && ( onDownload(attachment)}> @@ -359,6 +364,25 @@ export function ReaderAttachments({ * The image itself when there is a thumbnail, else the file type's icon over * its extension, in the type's colour. */ +/** The desktop app's "Copy as file", for pasting the attachment into Explorer or Finder. */ +function CopyAsFileButton({ copy }: { copy: () => Promise }) { + const t = useTranslations("desktopOs"); + return ( + { + void copy().then((ok) => { + if (ok) toast.success(t("copied")); + else toast.error(t("copy_failed")); + }, () => toast.error(t("copy_failed"))); + }} + > + + + ); +} + function AttachmentThumb({ attachment, url, label }: { attachment: ReaderAttachmentItem; url?: string; label: string }) { if (url) { return ; diff --git a/components/layout/__tests__/account-switcher-needs-sign-in.test.tsx b/components/layout/__tests__/account-switcher-needs-sign-in.test.tsx new file mode 100644 index 000000000..ffb61683c --- /dev/null +++ b/components/layout/__tests__/account-switcher-needs-sign-in.test.tsx @@ -0,0 +1,88 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// A refused account beside another that stays signed in: the switcher marks it, +// with the count and the date, and takes the user to its sign-in. + +const push = vi.hoisted(() => vi.fn()); +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push, replace: vi.fn() }), + usePathname: () => '/', +})); + +const { AccountSwitcher } = await import('../account-switcher'); +const { useAccountStore } = await import('@/stores/account-store'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { useSettingsStore } = await import('@/stores/settings-store'); +const { markLoginRefused, setRefusedCount, dropRefusedLogin } = await import('@/lib/devices/wipe-state'); +const { useOfflineStore, markEvicted, clearEvicted } = await import('@/lib/offline/state'); + +const A = 'a@example.com'; +const B = 'b@example.com'; + +function account(id: string) { + return { id, label: id, email: id, username: id, displayName: id.toUpperCase(), serverUrl: 'https://mail.example.com', avatarColor: '#336699', cookieSlot: 0, isConnected: true, isDefault: id === A }; +} + +function open(): void { + render(); + fireEvent.click(screen.getByTestId('account-switcher')); +} + +beforeEach(() => { + push.mockClear(); + localStorage.clear(); + useAccountStore.setState({ accounts: [account(A)] } as never); + useAuthStore.setState({ activeAccountId: A } as never); + useSettingsStore.setState({ interfaceLayout: 'default' } as never); + useOfflineStore.setState({ enabled: [A, B] }); + markLoginRefused(B, { certain: true, seenBy: [A], label: B, username: B }); + markEvicted(B); +}); + +describe('an account that needs signing in again', () => { + it('is marked, with its count and the date its data goes', () => { + setRefusedCount(B, 2); + open(); + const row = screen.getByTestId('account-needs-sign-in'); + expect(row.textContent).toContain(B); + expect(row.textContent).toContain('needs_sign_in'); + expect(row.textContent).toContain('switcher_waiting'); + }); + + it('says only the date when it couldn\'t count, or there\'s nothing unsent', () => { + setRefusedCount(B, 0); + open(); + const row = screen.getByTestId('account-needs-sign-in'); + expect(row.textContent).toContain('switcher_kept'); + expect(row.textContent).not.toContain('switcher_waiting'); + }); + + it('takes the user to its sign-in, with the name filled in', () => { + setRefusedCount(B, 2); + open(); + fireEvent.click(screen.getByTestId('account-needs-sign-in')); + expect(push).toHaveBeenCalledWith(`/login?mode=add-account&username=${encodeURIComponent(B)}`); + }); + + it('shows the count as soon as it\'s known, with the menu open', () => { + open(); + expect(screen.getByTestId('account-needs-sign-in').textContent).toContain('switcher_kept'); + act(() => setRefusedCount(B, 3)); + expect(screen.getByTestId('account-needs-sign-in').textContent).toContain('switcher_waiting'); + }); + + it('isn\'t shown to an account that wasn\'t there', () => { + useAuthStore.setState({ activeAccountId: 'stranger@example.com' } as never); + useAccountStore.setState({ accounts: [account('stranger@example.com')] } as never); + open(); + expect(screen.queryByTestId('account-needs-sign-in')).toBeNull(); + }); + + it('is gone once it signed in again', () => { + clearEvicted(B); + open(); + expect(screen.queryByTestId('account-needs-sign-in')).toBeNull(); + dropRefusedLogin(B); + }); +}); diff --git a/components/layout/account-switcher.tsx b/components/layout/account-switcher.tsx index 505f868e6..9c424eabe 100644 --- a/components/layout/account-switcher.tsx +++ b/components/layout/account-switcher.tsx @@ -17,6 +17,7 @@ import { inboxUnread, totalInboxUnread } from "@/lib/account-inbox-unread"; import { isDocumentRTL } from "@/i18n/direction"; import { cn, formatFileSize } from "@/lib/utils"; import { useRouter } from "@/i18n/navigation"; +import { onRefusedLoginsChanged, siblingWaits, waitDate } from "@/lib/devices/wipe-state"; import { Avatar } from "@/components/ui/avatar"; import { useMenuNavigation } from "@/hooks/use-menu-navigation"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; @@ -116,6 +117,11 @@ export function AccountSwitcher({ const removeAccount = useAuthStore((s) => s.removeAccount); const logoutAll = useAuthStore((s) => s.logoutAll); const { dialogProps: confirmDialogProps, confirm } = useConfirmDialog(); + const tWipe = useTranslations("offlineWipe"); + // An account that signed out by itself beside this one: its data waits. + const [, noteWaitsChanged] = useState(0); + useEffect(() => onRefusedLoginsChanged(() => noteWaitsChanged((count) => count + 1)), []); + const waiting = siblingWaits(activeAccountId); const updatePosition = useCallback(() => { if (!buttonRef.current) return; @@ -513,6 +519,30 @@ export function AccountSwitcher({ ); })} + {waiting.map((wait) => ( + + ))} {/* Mountain View: the account actions as rows closing the white card, diff --git a/components/mail-transfer/__tests__/local-export.test.tsx b/components/mail-transfer/__tests__/local-export.test.tsx new file mode 100644 index 000000000..4d25a4cbe --- /dev/null +++ b/components/mail-transfer/__tests__/local-export.test.tsx @@ -0,0 +1,838 @@ +import 'fake-indexeddb/auto'; +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MailTransferHost } from '@/components/mail-transfer/mail-transfer-host'; +import { localExportAllowed, startLocalExport } from '@/components/mail-transfer/start-local-export'; +import { OfflineSettings } from '@/components/offline/offline-settings'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { getStrictestDevices, devicesForAccount } from '@/lib/device-policy'; +import { setDownloadGuard } from '@/lib/download-guard'; +import type { ExportProgress } from '@/lib/mail-export/engine'; +import { memorySink, type ExportFile, type ExportSink } from '@/lib/mail-export/sinks'; +import { useOfflineStore } from '@/lib/offline/state'; +import { runWipeCleaners } from '@/lib/offline/wipe-registry'; +import { useAccountSubjects } from '@/lib/org/account-subjects'; +import { runBeforeSignOut } from '@/lib/sign-out-cleanup'; +import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { isJobActive, openLocalExport, useMailTransferStore } from '@/stores/mail-transfer-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { toast } from '@/stores/toast-store'; + +const FOLDERS: { id: string; name: string; role?: string; sortOrder: number }[] = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', sortOrder: 0 }, + { id: 'archive', name: 'Archive', sortOrder: 1 }, +]; +const RAW = new TextEncoder().encode('Subject: x\r\n\r\nbody\r\n'); + +/** A kept account where Inbox has 2 of 3 originals and Archive 1 of 1. */ +function fakeStore(overrides: { rawBlob?: (blobId: string) => unknown; status?: { total: number; kept: number }; size?: { bytes: number; keptBytes: number } } = {}) { + const area = vi.fn(async (_account: string, name: string, params?: { mailboxId?: string; blobId?: string }): Promise => { + if (name === 'mail.session') return { primaryAccounts: { 'urn:ietf:params:jmap:mail': 'jmap-a' } }; + if (name === 'mail.rawStatus') return overrides.status ?? (params?.mailboxId === 'inbox' ? { total: 3, kept: 2 } : { total: 1, kept: 1 }); + if (name === 'mail.rawBlob') return overrides.rawBlob ? overrides.rawBlob(params!.blobId!) : { ok: true, blob: new Blob([RAW]) }; + if (name === 'mail.rawSize') return overrides.size ?? { bytes: 10, keptBytes: 10 }; + if (name === 'mail.rawKept') return []; + return null; + }); + const answer = vi.fn(async (_account: string, calls: [string, Record, string][]) => { + const [name, args] = calls[0]; + if (name === 'Mailbox/get') return { methodResponses: [['Mailbox/get', { list: FOLDERS }, '0']] }; + if (name === 'Email/query') return { methodResponses: [['Email/query', { ids: (args.position as number) > 0 ? [] : ['m1'] }, '0']] }; + return { methodResponses: [['Email/get', { list: [{ id: 'm1', blobId: 'raw-m1', size: RAW.length, keywords: {}, receivedAt: '2026-10-01T00:00:00Z', subject: 'x' }] }, '0']] }; + }); + return { supported: () => true, setScope: vi.fn(async () => {}), area, answer }; +} + +function setDevices(devices: Partial) { + usePolicyStore.setState({ + loaded: true, source: 'server', partial: false, subject: { email: 'alice@example.org', groups: [] }, + policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } }, + } as never); +} + +function fakeSink(): ExportSink & { write: ReturnType; abort: ReturnType; close: ReturnType } { + const file: ExportFile = { name: 'a.mbox', size: 1, discard: vi.fn(async () => {}) }; + return { kind: 'picker', write: vi.fn(async () => {}), close: vi.fn(async () => file), abort: vi.fn(async () => {}) }; +} + +/** The one lib/device-policy.ts installs, for tests that put their own in for a moment. */ +const installedGuard: Parameters[0] = (kind, subject) => { + const devices = subject === undefined ? getStrictestDevices() : devicesForAccount(subject as string | null); + return kind === 'export' ? !devices.noExport : !devices.noDownloads; +}; + +const realClient = { getServerUrl: () => 'https://mail.example.org', getAccountId: () => 'jmap-a', getMaxConcurrentRequests: () => 4 }; + +function setOnline(online: boolean) { + useOfflineStore.setState({ browserOffline: !online, downServers: [] }); +} + +beforeEach(() => { + useMailTransferStore.setState({ request: null, visible: false, job: null }); + useAuthStore.setState({ + client: realClient as never, + activeAccountId: 'acc', + getClientForAccount: ((id: string) => (id === 'acc' ? realClient : undefined)) as never, + getAllConnectedClients: (() => new Map([['acc', realClient]])) as never, + } as never); + useAccountStore.setState({ accounts: [{ id: 'acc', email: 'alice@example.org', username: 'alice@example.org', serverUrl: 'https://mail.example.org' }] } as never); + useOfflineStore.setState({ + enabled: ['acc', 'other'], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], syncing: [], lastSyncedAt: {}, choices: { acc: { on: true } }, health: {}, + storageFull: {}, progress: {}, persist: 'granted', browserOffline: false, downServers: [], startedOffline: false, + usage: { acc: { bytes: 5000, parts: [] } }, + }); + setDevices({}); + setStoreBackend(fakeStore() as unknown as StoreBackend); +}); + +afterEach(() => { + cleanup(); + setStoreBackend(null); + delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker; + delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; + vi.unstubAllGlobals(); +}); + +async function openDialog() { + render(); + act(() => openLocalExport('acc')); + await screen.findByTestId('local-export-summary'); +} + +/** Runs one export to its end through the job. */ +async function finishedJob(sink = fakeSink()) { + expect(startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: ['Inbox'] }], sink })).toBe(true); + await act(async () => { await useMailTransferStore.getState().job!.finished; }); + return sink; +} + +describe('before an export starts', () => { + it('says how many originals are here and how many come from the server when online', async () => { + await openDialog(); + expect(screen.getByTestId('local-export-kept').textContent).toBe('kept'); + expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_online'); + expect(screen.queryByTestId('local-export-reason')).toBeNull(); + }); + + it('says offline how many can\'t be exported, and why', async () => { + setOnline(false); + await openDialog(); + expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_offline'); + expect(screen.getByTestId('local-export-reason').textContent).toBe('reason_not_kept'); + }); + + it('gives attachments being off as the reason when they are', async () => { + setOnline(false); + useOfflineStore.setState({ choices: { acc: { on: true, attachments: false } } } as never); + await openDialog(); + expect(screen.getByTestId('local-export-reason').textContent).toBe('reason_attachments_off'); + }); + + it('counts only the folders chosen, and offers none of the rest', async () => { + setOnline(false); + await openDialog(); + const area = (await import('@/lib/store/client/api')).getStore().area as ReturnType; + expect(area).toHaveBeenCalledWith('acc', 'mail.rawStatus', { jmap: 'jmap-a', mailboxId: 'inbox' }); + const boxes = screen.getAllByTestId('local-export-folder') as HTMLInputElement[]; + expect(boxes.map((box) => box.dataset.folder)).toEqual(['Inbox', 'Archive']); + fireEvent.click(boxes[0]); + // Only Archive now: all of its originals are here. + expect(screen.queryByTestId('local-export-missing')).toBeNull(); + const all = screen.getByTestId('local-export-all') as HTMLInputElement; + expect(all.indeterminate).toBe(true); + fireEvent.click(all); + expect(boxes.every((box) => box.checked)).toBe(true); + }); + + it('can\'t start offline with no original of anything, and can as soon as the server is back', async () => { + setOnline(false); + setStoreBackend(fakeStore({ status: { total: 4, kept: 0 } }) as unknown as StoreBackend); + await openDialog(); + expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_offline'); + act(() => setOnline(true)); + expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(false); + expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_online'); + }); + + it('puts the focus on the folders once they are read', async () => { + await openDialog(); + await waitFor(() => expect(document.activeElement).toBe(screen.getByTestId('local-export-all'))); + }); + + it('can\'t start with nothing chosen', async () => { + await openDialog(); + fireEvent.click(screen.getByTestId('local-export-all')); + expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByTestId('local-export-nothing')).toBeTruthy(); + }); + + it('opens the picker from the click, with a name for what was chosen', async () => { + const writable = { write: vi.fn(async () => {}), close: vi.fn(async () => {}), abort: vi.fn(async () => {}) }; + const picker = vi.fn(async () => ({ createWritable: async () => writable })); + (window as { showSaveFilePicker?: unknown }).showSaveFilePicker = picker; + await openDialog(); + fireEvent.click(screen.getAllByTestId('local-export-folder')[1]); + fireEvent.click(screen.getByTestId('local-export-start')); + await waitFor(() => expect(picker).toHaveBeenCalled()); + expect(picker).toHaveBeenCalledWith(expect.objectContaining({ suggestedName: 'Inbox.mbox' })); + await waitFor(() => expect(useMailTransferStore.getState().job?.progress.phase).toBe('done')); + expect(writable.close).toHaveBeenCalled(); + }); +}); + +describe('the device policy "No export"', () => { + it('locks the action in settings, with the reason beside it', () => { + setDevices({ noExport: true }); + render(); + expect((screen.getByTestId('local-export-open') as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText('settings_label').closest('[data-search-label]')!.querySelector('[data-org-locked]')).toBeTruthy(); + }); + + it('leaves the action in settings open when it\'s off, and that opens the dialog', async () => { + render(); + fireEvent.click(screen.getByTestId('local-export-open')); + expect(useMailTransferStore.getState().request).toEqual({ kind: 'local-export', account: 'acc' }); + }); + + it('keeps the dialog from opening', () => { + setDevices({ noExport: true }); + act(() => openLocalExport('acc')); + expect(useMailTransferStore.getState().request).toBeNull(); + }); + + it('refuses to start, gives back the sink and reads nothing', () => { + const store = fakeStore(); + setStoreBackend(store as unknown as StoreBackend); + setDevices({ noExport: true }); + const sink = fakeSink(); + expect(startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink })).toBe(false); + expect(sink.abort).toHaveBeenCalled(); + expect(store.answer).not.toHaveBeenCalled(); + expect(useMailTransferStore.getState().job).toBeNull(); + }); + + it('counts as on while the organisation\'s word isn\'t known, as at a first start offline', () => { + usePolicyStore.setState({ loaded: false, source: 'none' } as never); + expect(localExportAllowed('acc')).toBe(false); + usePolicyStore.setState({ loaded: true, source: 'fallback' } as never); + expect(localExportAllowed('acc')).toBe(false); + usePolicyStore.setState({ loaded: true, source: 'server', partial: true } as never); + expect(localExportAllowed('acc')).toBe(false); + usePolicyStore.setState({ loaded: true, source: 'cache', partial: false } as never); + expect(localExportAllowed('acc')).toBe(true); + }); + + it('stops a running export the moment it\'s switched on, and its file goes', async () => { + let release: () => void = () => {}; + const store = fakeStore(); + store.answer.mockImplementationOnce(() => new Promise((resolve) => { release = () => resolve({ methodResponses: [['Email/query', { ids: [] }, '0']] }); })); + setStoreBackend(store as unknown as StoreBackend); + const sink = fakeSink(); + startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink }); + const job = useMailTransferStore.getState().job!; + expect(isJobActive(job)).toBe(true); + await waitFor(() => expect(store.answer).toHaveBeenCalled()); + act(() => setDevices({ noExport: true })); + expect(job.controller.signal.aborted).toBe(true); + release(); + await act(async () => { await job.finished; }); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('cancelled'); + expect(sink.abort).toHaveBeenCalled(); + expect(sink.close).not.toHaveBeenCalled(); + }); + + it('asks about the account the export is for, not the active one', async () => { + useAuthStore.setState({ getClientForAccount: (() => realClient) as never } as never); + useAccountSubjects.setState({ settled: { other: true } } as never); + const asked: unknown[] = []; + setDownloadGuard((_kind, subject) => { asked.push(subject); return true; }); + try { + const sink = fakeSink(); + expect(startLocalExport({ account: 'other', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink })).toBe(true); + expect(useMailTransferStore.getState().job?.scope).toBe('other'); + await act(async () => { await useMailTransferStore.getState().job!.finished; }); + expect(sink.close).toHaveBeenCalled(); + expect(asked.length).toBeGreaterThan(1); + expect(new Set(asked)).toEqual(new Set(['other'])); + } finally { + setDownloadGuard(installedGuard); + } + }); + + it('stops at the file when it came on without the store noticing, as the save picker has no other gate', async () => { + const sink = fakeSink(); + let allowed = true; + setDownloadGuard(() => allowed); + try { + sink.write.mockImplementation(async () => { allowed = false; }); + await finishedJob(sink); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('failed'); + expect(sink.close).not.toHaveBeenCalled(); + expect(sink.abort).toHaveBeenCalled(); + } finally { + setDownloadGuard(installedGuard); + } + }); + + it('shows the organisation as the reason when a real change of policy stopped it', async () => { + const sink = fakeSink(); + render(); + act(() => openLocalExport('acc')); + // Through the policy store, the way a fetched policy arrives. + sink.write.mockImplementation(async () => { act(() => setDevices({ noExport: true })); }); + await finishedJob(sink); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('cancelled'); + expect(screen.getByTestId('local-export-stop-reason').textContent).toBe('export_blocked'); + expect(sink.close).not.toHaveBeenCalled(); + }); + + it('says why when the account stopped keeping mail here during the run', async () => { + const sink = fakeSink(); + render(); + act(() => openLocalExport('acc')); + sink.write.mockImplementation(async () => { act(() => useOfflineStore.setState({ enabled: ['other'] })); }); + await finishedJob(sink); + expect(screen.getByTestId('local-export-stop-reason').textContent).toBe('stopped_offline_off'); + }); + + it('gives no reason beyond the title when the person cancelled it', async () => { + const sink = fakeSink(); + render(); + act(() => openLocalExport('acc')); + sink.write.mockImplementation(async () => { act(() => useMailTransferStore.getState().cancelJob()); }); + await finishedJob(sink); + expect(screen.queryByTestId('local-export-stop-reason')).toBeNull(); + }); + + describe('with a scope the subject decides', () => { + /** Everyone may export; one domain or group may not. */ + function scoped(match: Record) { + const base = usePolicyStore.getState().rawPolicy; + usePolicyStore.setState({ + rawPolicy: { ...base, scopes: [{ id: 'locked', label: 'Locked', match, policy: { organisation: { devices: { noExport: true } } } }] }, + loaded: true, source: 'server', partial: false, subject: null, subjectPending: false, + } as never); + } + + afterEach(() => { + usePolicyStore.setState({ rawPolicy: { ...usePolicyStore.getState().rawPolicy, scopes: [] }, subjectPending: false } as never); + usePolicyStore.getState().setPolicySubject(null); + }); + + it.each([['domain', { domains: ['example.org'] }], ['group', { groups: ['team@example.org'] }]])('%s: says no until the subject is known, whatever Everyone may do', (_kind, match) => { + scoped(match as Record); + // What the policy alone would say: nobody has been matched yet. + expect(devicesForAccount('acc').noExport).toBe(false); + expect(localExportAllowed('acc')).toBe(false); + usePolicyStore.setState({ subjectPending: true } as never); + expect(localExportAllowed('acc')).toBe(false); + usePolicyStore.setState({ subjectPending: false } as never); + usePolicyStore.getState().setPolicySubject({ email: 'alice@example.org', groups: ['team@example.org'] }); + expect(devicesForAccount('acc').noExport).toBe(true); + expect(localExportAllowed('acc')).toBe(false); + }); + + it('opens once the subject is known and the scope isn\'t for it', () => { + scoped({ domains: ['elsewhere.example'] }); + expect(localExportAllowed('acc')).toBe(false); + usePolicyStore.getState().setPolicySubject({ email: 'alice@example.org', groups: [] }); + expect(localExportAllowed('acc')).toBe(true); + }); + + it('waits for another account\'s own subject too', () => { + usePolicyStore.getState().setPolicySubject({ email: 'alice@example.org', groups: [] }); + useAuthStore.setState({ getClientForAccount: (() => realClient) as never } as never); + useAccountSubjects.setState({ settled: {} } as never); + expect(localExportAllowed('other')).toBe(false); + useAccountSubjects.setState({ settled: { other: true } } as never); + expect(localExportAllowed('other')).toBe(true); + }); + }); + + describe('at the click on Export', () => { + function withPicker() { + const picker = vi.fn(async () => ({ createWritable: async () => ({ write: async () => {}, close: async () => {}, abort: async () => {} }) })); + (window as { showSaveFilePicker?: unknown }).showSaveFilePicker = picker; + return picker; + } + + it('opens the picker in the same tick as the click, so the gesture counts', async () => { + const picker = withPicker(); + await openDialog(); + fireEvent.click(screen.getByTestId('local-export-start')); + expect(picker).toHaveBeenCalledTimes(1); + await waitFor(() => expect(useMailTransferStore.getState().job?.progress.phase).toBe('done')); + }); + + it('opens no picker under the policy, and says why', async () => { + const picker = withPicker(); + await openDialog(); + setDevices({ noExport: true }); + fireEvent.click(screen.getByTestId('local-export-start')); + expect(picker).not.toHaveBeenCalled(); + expect((await screen.findByTestId('local-export-refused')).textContent).toBe('export_blocked'); + expect(useMailTransferStore.getState().job).toBeNull(); + }); + + it('opens no picker while the policy isn\'t known, and says that', async () => { + const picker = withPicker(); + await openDialog(); + usePolicyStore.setState({ subjectPending: true } as never); + fireEvent.click(screen.getByTestId('local-export-start')); + expect(picker).not.toHaveBeenCalled(); + expect((await screen.findByTestId('local-export-refused')).textContent).toBe('policy_unknown'); + usePolicyStore.setState({ subjectPending: false } as never); + }); + }); +}); + +describe('what the browser can hold', () => { + const SAFARI = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15'; + const MB = 1024 * 1024; + + /** A browser with no save picker but a private file system: the spool path. */ + function spoolBrowser(agent: string, free: number) { + vi.stubGlobal('navigator', { + ...navigator, userAgent: agent, + storage: { getDirectory: async () => ({}), estimate: async () => ({ quota: free + 100, usage: 100 }) }, + locks: { request: async (name: string, _o: unknown, cb: (lock: unknown) => unknown) => cb({ name }) }, + }); + vi.stubGlobal('FileSystemFileHandle', class { createWritable() {} }); + } + + it('on WebKit stops it before it starts when the choice is over the cap, and says what to do', async () => { + spoolBrowser(SAFARI, 100 * 1024 * MB); + setStoreBackend(fakeStore({ status: { total: 2, kept: 2 }, size: { bytes: 600 * MB, keptBytes: 600 * MB } }) as unknown as StoreBackend); + await openDialog(); + expect(screen.getByTestId('local-export-over-limit').textContent).toBe('over_limit'); + expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(true); + // Fewer folders fit. + fireEvent.click(screen.getAllByTestId('local-export-folder')[0]); + expect(screen.queryByTestId('local-export-over-limit')).toBeNull(); + expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(false); + }); + + it('puts no such cap on Firefox', async () => { + spoolBrowser('Mozilla/5.0 (Windows NT 10.0; rv:148.0) Gecko/20100101 Firefox/148.0', 100 * 1024 * MB); + setStoreBackend(fakeStore({ status: { total: 2, kept: 2 }, size: { bytes: 600 * MB, keptBytes: 600 * MB } }) as unknown as StoreBackend); + await openDialog(); + expect(screen.queryByTestId('local-export-over-limit')).toBeNull(); + expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(false); + }); + + it('says so when the browser has no room for the spool', async () => { + spoolBrowser('Mozilla/5.0 (Windows NT 10.0; rv:148.0) Gecko/20100101 Firefox/148.0', 5 * MB); + setStoreBackend(fakeStore({ status: { total: 2, kept: 2 }, size: { bytes: 3 * MB, keptBytes: 3 * MB } }) as unknown as StoreBackend); + await openDialog(); + expect(screen.getByTestId('local-export-no-room').textContent).toBe('no_room'); + expect((screen.getByTestId('local-export-start') as HTMLButtonElement).disabled).toBe(true); + }); + + it('counts only what will be written: offline, what is kept', async () => { + spoolBrowser('Mozilla/5.0 (Windows NT 10.0; rv:148.0) Gecko/20100101 Firefox/148.0', 5 * MB); + setOnline(false); + setStoreBackend(fakeStore({ status: { total: 2, kept: 1 }, size: { bytes: 300 * MB, keptBytes: 1 * MB } }) as unknown as StoreBackend); + await openDialog(); + expect(screen.queryByTestId('local-export-no-room')).toBeNull(); + act(() => setOnline(true)); + expect(screen.getByTestId('local-export-no-room')).toBeTruthy(); + }); + + it('doesn\'t ask about room when the save picker writes elsewhere', async () => { + spoolBrowser('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36', 5 * MB); + (window as { showSaveFilePicker?: unknown }).showSaveFilePicker = vi.fn(); + setStoreBackend(fakeStore({ status: { total: 2, kept: 2 }, size: { bytes: 300 * MB, keptBytes: 300 * MB } }) as unknown as StoreBackend); + await openDialog(); + expect(screen.queryByTestId('local-export-no-room')).toBeNull(); + expect(screen.queryByTestId('local-export-over-limit')).toBeNull(); + }); + + it.each([ + ['ExportTooLargeError', 'stopped_limit'], + ['QuotaExceededError', 'stopped_no_room'], + ['TypeError', 'failed_generic'], + ])('says %s in the user\'s language, never the browser\'s text', async (name, key) => { + const sink = fakeSink(); + sink.write.mockImplementation(async () => { throw Object.assign(new Error('Some English from a browser'), { name, limit: 1024 }); }); + render(); + act(() => openLocalExport('acc')); + await finishedJob(sink); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('failed'); + expect(screen.getByText(key)).toBeTruthy(); + expect(screen.queryByText(/Some English/)).toBeNull(); + }); + + it('says a message failed in the user\'s language too', async () => { + setStoreBackend(fakeStore({ rawBlob: () => { throw new Error('Some English from a server'); } }) as unknown as StoreBackend); + render(); + act(() => openLocalExport('acc')); + await finishedJob(); + expect(screen.getByText(/message_failed/)).toBeTruthy(); + expect(screen.queryByText(/Some English/)).toBeNull(); + }); +}); + +describe('what an export couldn\'t write', () => { + const notKept = () => setStoreBackend(fakeStore({ rawBlob: () => ({ ok: false, reason: 'not_kept' }) }) as unknown as StoreBackend); + + it('counts it offline with the reason and no way to retry until online', async () => { + setOnline(false); + notKept(); + render(); + act(() => openLocalExport('acc')); + await finishedJob(); + expect((useMailTransferStore.getState().job?.progress as ExportProgress).notKept).toEqual({ not_kept: 1 }); + expect(screen.getByTestId('local-export-not-kept-missing').textContent).toBe('not_kept_not_kept'); + // Nothing was written: no file, and nothing to download again. + expect(screen.queryByTestId('mail-export-file')).toBeNull(); + expect(screen.queryByTestId('mail-export-download')).toBeNull(); + expect(screen.queryByTestId('local-export-not-kept-attachments')).toBeNull(); + expect(screen.getByTestId('local-export-retry-hint').textContent).toBe('retry_offline'); + expect((screen.getByTestId('local-export-retry') as HTMLButtonElement).disabled).toBe(true); + }); + + it('offers the retry once the connection is back, which goes back to the question with fresh counts', async () => { + setOnline(false); + notKept(); + render(); + act(() => openLocalExport('acc')); + await finishedJob(); + act(() => setOnline(true)); + expect(screen.getByTestId('local-export-retry-hint').textContent).toBe('retry_online'); + const area = (await import('@/lib/store/client/api')).getStore().area as ReturnType; + area.mockClear(); + fireEvent.click(screen.getByTestId('local-export-retry')); + await screen.findByTestId('local-export-summary'); + expect(useMailTransferStore.getState().job).toBeNull(); + expect(area).toHaveBeenCalledWith('acc', 'mail.rawStatus', expect.anything()); + expect(screen.getByTestId('local-export-missing').textContent).toBe('missing_online'); + }); + + it('says the export stopped, not that a file is ready, when it ends hidden with nothing written', async () => { + setOnline(false); + notKept(); + const warning = vi.spyOn(toast, 'warning'); + const success = vi.spyOn(toast, 'success'); + render(); + await finishedJob(); + expect(warning).toHaveBeenCalledWith('toast_stopped', expect.anything()); + expect(success).not.toHaveBeenCalled(); + warning.mockRestore(); + success.mockRestore(); + }); + + it('says when the picked file couldn\'t be removed after a run that wrote nothing', async () => { + setOnline(false); + notKept(); + const sink = fakeSink(); + sink.abort.mockImplementation(async () => ({ leftover: 'a.mbox' })); + render(); + act(() => openLocalExport('acc')); + await finishedJob(sink); + expect(sink.close).not.toHaveBeenCalled(); + expect(screen.getByTestId('local-export-leftover').textContent).toBe('leftover_file'); + }); + + it('says nothing about a file that was removed', async () => { + setOnline(false); + notKept(); + const sink = fakeSink(); + sink.abort.mockImplementation(async () => ({})); + render(); + act(() => openLocalExport('acc')); + await finishedJob(sink); + expect(sink.abort).toHaveBeenCalled(); + expect(screen.queryByTestId('local-export-leftover')).toBeNull(); + }); + + it('keeps to the chosen folders on the retry, also when a new folder has turned up', async () => { + setOnline(false); + notKept(); + render(); + act(() => openLocalExport('acc')); + await screen.findByTestId('local-export-summary'); + fireEvent.click(screen.getAllByTestId('local-export-folder')[1]); + await finishedJob(); + act(() => setOnline(true)); + FOLDERS.push({ id: 'new', name: 'New', sortOrder: 2 }); + try { + fireEvent.click(screen.getByTestId('local-export-retry')); + await waitFor(() => expect(screen.getAllByTestId('local-export-folder')).toHaveLength(3)); + const boxes = screen.getAllByTestId('local-export-folder') as HTMLInputElement[]; + expect(boxes.map((box) => [box.dataset.folder, box.checked])).toEqual([['Inbox', true], ['Archive', false], ['New', false]]); + } finally { + FOLDERS.pop(); + } + }); + + it('takes a new folder in when all were chosen', async () => { + setOnline(false); + notKept(); + render(); + act(() => openLocalExport('acc')); + await screen.findByTestId('local-export-summary'); + await finishedJob(); + act(() => setOnline(true)); + FOLDERS.push({ id: 'new', name: 'New', sortOrder: 2 }); + try { + fireEvent.click(screen.getByTestId('local-export-retry')); + await waitFor(() => expect(screen.getAllByTestId('local-export-folder')).toHaveLength(3)); + expect((screen.getAllByTestId('local-export-folder') as HTMLInputElement[]).every((box) => box.checked)).toBe(true); + } finally { + FOLDERS.pop(); + } + }); + + it('makes no empty file when the folders turned out empty by the time it ran, and says so', async () => { + const store = fakeStore(); + store.answer.mockImplementation(async (_account: string, calls: [string, Record, string][]) => ( + calls[0][0] === 'Mailbox/get' + ? { methodResponses: [['Mailbox/get', { list: FOLDERS }, '0']] } + : { methodResponses: [['Email/query', { ids: [] }, '0']] } + ) as never); + setStoreBackend(store as unknown as StoreBackend); + const sink = fakeSink(); + render(); + act(() => openLocalExport('acc')); + await finishedJob(sink); + expect(sink.close).not.toHaveBeenCalled(); + expect(sink.abort).toHaveBeenCalled(); + expect(screen.getByTestId('local-export-wrote-nothing').textContent).toBe('nothing_kept'); + }); + + it('offers no retry when everything was written', async () => { + render(); + act(() => openLocalExport('acc')); + await finishedJob(); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('done'); + expect(screen.queryByTestId('local-export-retry')).toBeNull(); + }); +}); + +describe('while it runs', () => { + it('shows the progress, and Cancel stops it and gives up its file', async () => { + const sink = fakeSink(); + let finish: () => void = () => {}; + const store = fakeStore(); + store.answer.mockImplementationOnce(() => new Promise((resolve) => { finish = () => resolve({ methodResponses: [['Email/query', { ids: [] }, '0']] }); })); + setStoreBackend(store as unknown as StoreBackend); + render(); + act(() => openLocalExport('acc')); + act(() => { startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink }); }); + expect(screen.getByRole('progressbar')).toBeTruthy(); + expect(screen.getByTestId('mail-export-status').getAttribute('role')).toBe('status'); + await waitFor(() => expect(store.answer).toHaveBeenCalled()); + const job = useMailTransferStore.getState().job!; + fireEvent.click(screen.getByTestId('local-export-cancel')); + expect(job.controller.signal.aborted).toBe(true); + finish(); + await act(async () => { await job.finished; }); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('cancelled'); + expect(sink.abort).toHaveBeenCalled(); + expect(sink.close).not.toHaveBeenCalled(); + expect(screen.getByText('cancelled_title')).toBeTruthy(); + }); +}); + +describe('stopped while the file is being finished', () => { + /** A sink whose close doesn't end until told, the way opening a large spool takes a while. */ + function slowClose() { + const sink = fakeSink(); + const file: ExportFile = { name: 'a.mbox', size: 1, discard: vi.fn(async () => {}) }; + let finish: () => void = () => {}; + let signal: AbortSignal | undefined; + sink.close.mockImplementation((given?: AbortSignal) => new Promise((resolve) => { + signal = given; + finish = () => resolve(file); + })); + return { sink, file, finish: () => finish(), signal: () => signal }; + } + + async function closing(sink: ExportSink) { + expect(startLocalExport({ account: 'acc', jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink })).toBe(true); + await waitFor(() => expect((sink.close as ReturnType)).toHaveBeenCalled()); + return useMailTransferStore.getState().job!; + } + + it('passes the job\'s signal to the sink', async () => { + const slow = slowClose(); + const job = await closing(slow.sink); + expect(slow.signal()).toBe(job.controller.signal); + slow.finish(); + await act(async () => { await job.finished; }); + }); + + it('a cancel: the file goes and the run counts as cancelled, also if the sink finished anyway', async () => { + const slow = slowClose(); + const job = await closing(slow.sink); + act(() => useMailTransferStore.getState().cancelJob()); + slow.finish(); + await act(async () => { await job.finished; }); + const progress = useMailTransferStore.getState().job?.progress as ExportProgress; + expect(progress.phase).toBe('cancelled'); + expect(progress.file).toBeUndefined(); + expect(slow.file.discard).toHaveBeenCalledWith(true); + }); + + it('a wipe that outlasts its wait: the job and its file still go when it ends', async () => { + render(); + const slow = slowClose(); + const job = await closing(slow.sink); + await act(async () => { await runWipeCleaners('acc'); }); + expect(job.controller.signal.aborted).toBe(true); + // Still running after the 1.5 s the wipe gives it. + expect(useMailTransferStore.getState().job).not.toBeNull(); + slow.finish(); + await act(async () => { await job.finished; }); + await waitFor(() => expect(useMailTransferStore.getState().job).toBeNull()); + expect(slow.file.discard).toHaveBeenCalledWith(true); + }); + + it('a sign-out that outlasts its wait: the same', async () => { + render(); + const slow = slowClose(); + const job = await closing(slow.sink); + await act(async () => { await runBeforeSignOut('acc'); }); + slow.finish(); + await act(async () => { await job.finished; }); + await waitFor(() => expect(useMailTransferStore.getState().job).toBeNull()); + expect(slow.file.discard).toHaveBeenCalledWith(true); + }); + + it('a wipe after the dialog closed drops the finished file at once', async () => { + const revoke = vi.fn(); + vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: () => 'blob:x', revokeObjectURL: revoke })); + render(); + await finishedJob(memorySink('a.mbox', 'application/mbox') as ReturnType); + act(() => useMailTransferStore.getState().close()); + expect(useMailTransferStore.getState().job).toBeNull(); + expect(revoke).not.toHaveBeenCalled(); + await act(async () => { await runWipeCleaners('acc'); }); + expect(revoke).toHaveBeenCalledWith('blob:x'); + }); +}); + +describe('an export is cleaned up', () => { + /** A job that doesn't end until it's aborted. */ + function runningJob(account = 'acc') { + const sink = fakeSink(); + const store = fakeStore(); + // Like a store read: under way until the job is stopped. + store.answer.mockImplementation(() => new Promise((_resolve, reject) => { + const signal = useMailTransferStore.getState().job!.controller.signal; + const stop = () => reject(new DOMException('stopped', 'AbortError')); + if (signal.aborted) stop(); + else signal.addEventListener('abort', stop); + })); + setStoreBackend(store as unknown as StoreBackend); + expect(startLocalExport({ account, jmap: 'jmap-a', format: 'mbox', folders: [{ mailboxId: 'inbox', path: [] }], sink })).toBe(true); + return { sink, job: useMailTransferStore.getState().job! }; + } + + function stubSweep() { + const removed: string[] = []; + const dir = { + keys: async function* () { yield 'old-spool'; }, + removeEntry: async (name: string) => { removed.push(name); }, + }; + const root = { + getDirectoryHandle: async () => dir, + removeEntry: vi.fn(async () => {}), + }; + vi.stubGlobal('navigator', { ...navigator, storage: { getDirectory: async () => root }, locks: { request: async (name: string, _o: unknown, cb: (lock: unknown) => unknown) => cb({ name }) } }); + return { removed, root }; + } + + it('on a wipe of its account: stops the job and forgets it', async () => { + render(); + const { job } = runningJob(); + await act(async () => { await runWipeCleaners('acc'); }); + expect(job.controller.signal.aborted).toBe(true); + expect(useMailTransferStore.getState().job).toBeNull(); + }); + + it('a wipe of another account leaves the job alone', async () => { + render(); + const { job } = runningJob(); + await act(async () => { await runWipeCleaners('someone-else'); }); + expect(job.controller.signal.aborted).toBe(false); + expect(useMailTransferStore.getState().job).toBe(job); + }); + + it('on a wipe of everything: also every spool, whoever holds it', async () => { + render(); + const { job } = runningJob(); + const { root } = stubSweep(); + await act(async () => { await runWipeCleaners(null); }); + expect(job.controller.signal.aborted).toBe(true); + expect(root.removeEntry).toHaveBeenCalledWith('bulwark-exports', { recursive: true }); + }); + + it('on a wipe of one account: the spools nobody holds go too', async () => { + render(); + const { removed, root } = stubSweep(); + await act(async () => { await runWipeCleaners('acc'); }); + expect(removed).toEqual(['old-spool']); + expect(root.removeEntry).not.toHaveBeenCalled(); + }); + + it('on sign-out of its account: stops the job and forgets it', async () => { + render(); + const { job } = runningJob(); + await act(async () => { await runBeforeSignOut('acc'); }); + expect(job.controller.signal.aborted).toBe(true); + expect(useMailTransferStore.getState().job).toBeNull(); + }); + + it('frees a finished export\'s file at once on a wipe', async () => { + render(); + const sink = fakeSink(); + await finishedJob(sink); + const file = (useMailTransferStore.getState().job!.progress as ExportProgress).file!; + await act(async () => { await runWipeCleaners('acc'); }); + expect(file.discard).toHaveBeenCalledWith(true); + }); + + it('when the account stops keeping mail here: stops the job', () => { + const { job } = runningJob(); + act(() => useOfflineStore.setState({ enabled: ['other'] })); + expect(job.controller.signal.aborted).toBe(true); + }); + + it('at start-up: sweeps what a crash left', async () => { + const { removed } = stubSweep(); + render(); + await waitFor(() => expect(removed).toEqual(['old-spool'])); + }); +}); + +describe('the taskbar in the desktop app', () => { + it('shows a running export and takes it away at the end', async () => { + const setProgress = vi.fn(); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', os: { setProgress } }; + render(); + await finishedJob(); + const calls = setProgress.mock.calls; + expect(calls[0]).toEqual([0, 'normal']); + expect(calls[calls.length - 1][0]).toBeNull(); + }); + + it('says when it\'s paused', async () => { + const setProgress = vi.fn(); + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', os: { setProgress } }; + render(); + const store = useMailTransferStore.getState(); + act(() => store.startJob({ kind: 'export', controller: new AbortController(), progress: { phase: 'paused', exported: 1, failed: 0, failures: [], total: 4, fraction: 0.25 } })); + expect(setProgress).toHaveBeenLastCalledWith(0.25, 'paused'); + }); + + it('asks nothing of a shell without it, or of a browser', async () => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32' }; + render(); + await finishedJob(); + expect(useMailTransferStore.getState().job?.progress.phase).toBe('done'); + }); +}); diff --git a/components/mail-transfer/local-export-dialog.tsx b/components/mail-transfer/local-export-dialog.tsx new file mode 100644 index 000000000..c72d05329 --- /dev/null +++ b/components/mail-transfer/local-export-dialog.tsx @@ -0,0 +1,340 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { Dialog } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { downloadAllowed } from "@/lib/download-guard"; +import { folderTree, safeName, type ExportFolder, type ExportFormat, type ExportProgress } from "@/lib/mail-export/engine"; +import { MEMORY_EXPORT_LIMIT, openExportSink, spoolLimit } from "@/lib/mail-export/sinks"; +import { scopeForAccount } from "@/lib/offline/offline-mode"; +import { useOfflineStore } from "@/lib/offline/state"; +import { getStore } from "@/lib/store/client/api"; +import { formatFileSize } from "@/lib/utils"; +import { useAccountStore } from "@/stores/account-store"; +import { isJobActive, useMailTransferStore, type LocalExportRequest } from "@/stores/mail-transfer-store"; +import { ExportProgressBody, saveMode } from "./mail-export-dialog"; +import { localExportAllowed, localExportDecided, localExportReachable, localExportReader, primaryJmapAccount, startLocalExport } from "./start-local-export"; +import { useFocusOnStage } from "./transfer-progress"; + +interface FolderRow { + folder: ExportFolder; + total: number; + kept: number; + bytes: number; + keptBytes: number; +} + +/** `free`: what the browser says is left for this site; null where it doesn't say. */ +type Loaded = { jmap: string; rows: FolderRow[]; free: number | null }; + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +/** What an account keeps, folder by folder, for the question before an export starts. */ +async function readFolders(account: string): Promise { + const jmap = await primaryJmapAccount(account); + if (!jmap) throw new Error("The account has no mail account in the store"); + const folders = folderTree(await localExportReader(account, jmap).getMailboxes()); + const rows: FolderRow[] = []; + for (const folder of folders) { + const params = { jmap, mailboxId: folder.mailboxId }; + const status = await getStore().area<{ total: number; kept: number }>(account, "mail.rawStatus", params); + const size = await getStore().area<{ bytes: number; keptBytes: number }>(account, "mail.rawSize", params); + rows.push({ folder, ...status, ...size }); + } + return { jmap, rows, free: await freeRoom() }; +} + +async function freeRoom(): Promise { + try { + const estimate = await navigator.storage?.estimate?.(); + if (typeof estimate?.quota !== "number") return null; + return Math.max(0, estimate.quota - (estimate.usage ?? 0)); + } catch { + return null; + } +} + +/** Settings > Backup & reset > Offline > Export kept mail: from the local store, also with no server. */ +export function LocalExportDialog({ request }: { request: LocalExportRequest }) { + const t = useTranslations("offlineExport"); + const tExport = useTranslations("mail_export"); + const tPolicy = useTranslations("device_policy"); + const locale = useLocale(); + const id = useId(); + const { account } = request; + const visible = useMailTransferStore((s) => s.visible); + const job = useMailTransferStore((s) => s.job); + const close = useMailTransferStore((s) => s.close); + const hide = useMailTransferStore((s) => s.hide); + const cancelJob = useMailTransferStore((s) => s.cancelJob); + const address = useAccountStore((s) => { + const entry = s.accounts.find((candidate) => candidate.id === account); + return entry?.email || entry?.username || account; + }); + // Re-renders when the connection changes; what it means is asked below. + useOfflineStore((s) => s.browserOffline); + useOfflineStore((s) => s.downServers); + const reachable = localExportReachable(account); + const [loaded, setLoaded] = useState(null); + const [loadFailed, setLoadFailed] = useState(false); + const [excluded, setExcluded] = useState>(new Set()); + const [format, setFormat] = useState("mbox"); + const [starting, setStarting] = useState(false); + const [refused, setRefused] = useState(null); + const mode = saveMode(); + const progress = job?.kind === "export" ? job.progress : null; + const active = isJobActive(job); + const stageButton = useRef(null); + const allFolders = useRef(null); + const knownFolders = useRef>(new Set()); + useFocusOnStage(stageButton, progress ? (active ? "running" : "ended") : "setup"); + const inSetup = !progress; + + // Read again each time the setup shows, so a try online sees what's kept by then. + useEffect(() => { + if (!inSetup) return; + let live = true; + setLoadFailed(false); + readFolders(account).then( + (result) => { + if (!live) return; + // A folder that turned up since isn't one the person chose: after a try online + // only "All folders" takes it in. + const ids = result.rows.map((row) => row.folder.mailboxId); + const known = knownFolders.current; + knownFolders.current = new Set(ids); + setExcluded((was) => (was.size === 0 ? was : new Set([...was, ...ids.filter((folderId) => !known.has(folderId))]))); + setLoaded(result); + }, + () => { if (live) setLoadFailed(true); }, + ); + return () => { live = false; }; + }, [account, inSetup]); + + // The dialog opens before the folders are read, with only its footer to focus. + const foldersShown = inSetup && loaded !== null; + useEffect(() => { + if (foldersShown) allFolders.current?.focus(); + }, [foldersShown]); + + /** Why a run stopped, in the user's language, whatever the error said. */ + function stopMessage(run: ExportProgress): string { + if (run.errorCode === "ExportForbiddenError" || !localExportAllowed(account)) return tPolicy("export_blocked"); + if (run.errorCode === "ExportTooLargeError") return t("stopped_limit", { limit: formatFileSize(run.errorLimit ?? 0) }); + if (run.errorCode === "QuotaExceededError") return t("stopped_no_room"); + return t("failed_generic"); + } + + if (!visible) return null; + + if (progress) { + const notKept = progress.notKept; + const notKeptCount = (notKept?.attachments_off ?? 0) + (notKept?.not_kept ?? 0); + const reason: unknown = job?.controller.signal.reason; + const leftover = progress.leftover; + const shown: ExportProgress = { + ...progress, + // Nothing from a server or a browser reaches the user in English. + failures: progress.failures.map((failure) => ({ ...failure, reason: t("message_failed") })), + ...(progress.phase === "failed" ? { error: stopMessage(progress) } : {}), + }; + return ( + (active ? hide() : close())} + size="full-on-phone" + preventOutsideClose + title={progress.phase === "done" ? tExport("done_title") : progress.phase === "cancelled" ? tExport("cancelled_title") : progress.phase === "failed" ? tExport("failed_title") : tExport("running_title")} + testId="local-export-dialog" + footer={active ? ( + <> + + + + ) : ( + <> + {progress.phase === "done" && progress.file?.download && ( + + )} + {notKeptCount > 0 && ( + + )} + + + )} + > + + {progress.phase === "cancelled" && reason === "policy" &&

{tPolicy("export_blocked")}

} + {progress.phase === "cancelled" && reason === "offline-off" &&

{t("stopped_offline_off")}

} + {progress.phase === "done" && progress.exported === 0 && progress.failed === 0 &&

{t("nothing_kept")}

} + {leftover &&

{t("leftover_file", { name: leftover })}

} + {notKeptCount > 0 && ( +
+ {!!notKept?.attachments_off &&

{t("not_kept_attachments_off", { count: notKept.attachments_off })}

} + {!!notKept?.not_kept &&

{t("not_kept_not_kept", { count: notKept.not_kept })}

} +

{reachable ? t("retry_online") : t("retry_offline")}

+
+ )} +
+ ); + } + + const rows = loaded?.rows ?? []; + const chosen = rows.filter((row) => !excluded.has(row.folder.mailboxId)); + const total = chosen.reduce((sum, row) => sum + row.total, 0); + const kept = chosen.reduce((sum, row) => sum + row.kept, 0); + const missing = total - kept; + const single = chosen.length === 1; + const attachmentsOff = !scopeForAccount(account).scope.attachments; + // Offline with no original of anything: the save picker would ask where to put nothing. + const nothingToWrite = total === 0 || (kept === 0 && !reachable); + // What the run will need, against what this browser can give it. Asked up front: a + // tab killed with no message after hours is the outcome to avoid. + const needed = chosen.reduce((sum, row) => sum + (reachable ? row.bytes : row.keptBytes), 0); + const limit = mode === "memory" ? MEMORY_EXPORT_LIMIT : mode === "download" ? spoolLimit() : Infinity; + const overLimit = needed > limit; + const noRoom = mode === "download" && loaded?.free != null && needed > loaded.free; + + const onStart = async () => { + if (!loaded || starting || chosen.length === 0) return; + // Before the picker, with nothing awaited, so the click still counts for it. + if (!localExportAllowed(account)) { + setRefused(localExportDecided(account) ? tPolicy("export_blocked") : t("policy_unknown")); + downloadAllowed("export", account); + return; + } + setRefused(null); + const zip = !single || format === "eml"; + const base = single + ? safeName(chosen[0].folder.path[chosen[0].folder.path.length - 1] ?? "", "mail") + : `${safeName(address, "mail")}-${today()}`; + setStarting(true); + try { + // The save picker must open straight from the click. In the desktop app it's the OS's own Save dialog. + const sink = await openExportSink(`${base}.${zip ? "zip" : "mbox"}`, zip ? "application/zip" : "application/mbox", zip ? ".zip" : ".mbox"); + if (!sink) return; + startLocalExport({ account, jmap: loaded.jmap, format, folders: chosen.map((row) => row.folder), sink }); + } finally { + setStarting(false); + } + }; + + const toggleFolder = (mailboxId: string, on: boolean) => { + const next = new Set(excluded); + if (on) next.delete(mailboxId); + else next.add(mailboxId); + setExcluded(next); + }; + + const formats: { value: ExportFormat; label: string; hint: string }[] = [ + { value: "mbox", label: single ? tExport("format_mbox_folder") : tExport("format_mbox_account"), hint: tExport("hint_mbox") }, + { value: "eml", label: single ? tExport("format_eml_folder") : tExport("format_eml_account"), hint: tExport("hint_eml") }, + ]; + + return ( + close()} + size="full-on-phone" + title={t("title")} + description={t("description")} + testId="local-export-dialog" + footer={( + <> + + + + )} + > +
+ {loadFailed ? ( +

{t("load_failed")}

+ ) : !loaded ? ( +

{t("loading")}

+ ) : ( + <> +
+ {t("folders_label")} + +
    + {rows.map((row) => ( +
  • + +
  • + ))} +
+
+
+ {tExport("format_label")} +
+ {formats.map((f) => ( + + ))} +
+
+
+ {total === 0 ? ( +

{t("nothing_kept")}

+ ) : ( +

{t("kept", { kept, total })}

+ )} + {missing > 0 && ( +

+ {reachable ? t("missing_online", { count: missing }) : t("missing_offline", { count: missing })} +

+ )} + {missing > 0 && !reachable && ( +

{attachmentsOff ? t("reason_attachments_off") : t("reason_not_kept")}

+ )} + {overLimit &&

{t("over_limit", { limit: formatFileSize(limit), size: formatFileSize(needed) })}

} + {noRoom && !overLimit &&

{t("no_room", { size: formatFileSize(needed), free: formatFileSize(loaded?.free ?? 0) })}

} + {refused &&

{refused}

} +
+

{tExport(`save_${mode}`)}

+ + )} +
+
+ ); +} diff --git a/components/mail-transfer/mail-export-dialog.tsx b/components/mail-transfer/mail-export-dialog.tsx index c3e7b6b99..9c09d14b7 100644 --- a/components/mail-transfer/mail-export-dialog.tsx +++ b/components/mail-transfer/mail-export-dialog.tsx @@ -9,7 +9,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { isJobActive, useMailTransferStore, type ExportRequest } from "@/stores/mail-transfer-store"; import type { ExportFormat, ExportProgress } from "@/lib/mail-export/engine"; import { safeName } from "@/lib/mail-export/engine"; -import { canPickSaveFile, canWritePrivateFile, memorySink, pickSaveFile, privateFileSink, sweepPrivateExports, type ExportSink } from "@/lib/mail-export/sinks"; +import { canPickSaveFile, canWritePrivateFile, openExportSink } from "@/lib/mail-export/sinks"; import { formatFileSize } from "@/lib/utils"; import { formatClock, TransferBar, useFocusOnStage } from "./transfer-progress"; import { startExport } from "./start-export"; @@ -19,7 +19,7 @@ function today(): string { } /** Where the file will go in this browser, said before the export starts. */ -function saveMode(): "picker" | "download" | "memory" { +export function saveMode(): "picker" | "download" | "memory" { if (canPickSaveFile()) return "picker"; return canWritePrivateFile() ? "download" : "memory"; } @@ -57,21 +57,10 @@ export function MailExportDialog({ request }: { request: ExportRequest }) { const name = `${base}.${zip ? "zip" : "mbox"}`; const mime = zip ? "application/zip" : "application/mbox"; setStarting(true); - let sink: ExportSink | null = null; try { // The save picker must open straight from the click. - if (mode === "picker") { - try { - sink = await pickSaveFile(name, mime, zip ? ".zip" : ".mbox"); - } catch (error) { - if ((error as { name?: string } | null)?.name === "AbortError") return; - sink = null; - } - } - if (!sink) { - await sweepPrivateExports(); - sink = (await privateFileSink(name, mime)) ?? memorySink(name, mime); - } + const sink = await openExportSink(name, mime, zip ? ".zip" : ".mbox"); + if (!sink) return; startExport({ format, accountId: mailbox?.accountId || client.getAccountId(), @@ -165,7 +154,7 @@ export function MailExportDialog({ request }: { request: ExportRequest }) { type T = ReturnType; -function ExportProgressBody({ progress: p, t, locale, statusId }: { progress: ExportProgress; t: T; locale: string; statusId: string }) { +export function ExportProgressBody({ progress: p, t, locale, statusId }: { progress: ExportProgress; t: T; locale: string; statusId: string }) { const running = p.phase === "running" || p.phase === "paused"; // The live region says only what changes the user's plans (paused, done); // the count beside it would otherwise be read out several times a second. diff --git a/components/mail-transfer/mail-transfer-host.tsx b/components/mail-transfer/mail-transfer-host.tsx index 981fb2bdf..5f9296947 100644 --- a/components/mail-transfer/mail-transfer-host.tsx +++ b/components/mail-transfer/mail-transfer-host.tsx @@ -4,18 +4,47 @@ import { useEffect, useRef } from "react"; import { useLocale, useTranslations } from "next-intl"; import { Loader2, Pause } from "@/components/icons"; import { isJobActive, useMailTransferStore } from "@/stores/mail-transfer-store"; +import { showTaskbarProgress } from "@/lib/desktop/taskbar-progress"; +import { onDeviceWipe } from "@/lib/offline/wipe-registry"; import { onBeforeSignOut } from "@/lib/sign-out-cleanup"; import { clearCheckpointsFor } from "@/lib/mail-import/checkpoints"; -import { sweepPrivateExports } from "@/lib/mail-export/sinks"; +import { dropExportFiles, sweepPrivateExports } from "@/lib/mail-export/sinks"; import { useAuthStore } from "@/stores/auth-store"; import { toast } from "@/stores/toast-store"; import { MailImportDialog } from "./mail-import-dialog"; import { MailExportDialog } from "./mail-export-dialog"; +import { LocalExportDialog } from "./local-export-dialog"; import { formatClock } from "./transfer-progress"; /** How long sign-out waits for a cancelled job to stop (its budget is 2 s). */ const SIGN_OUT_WAIT_MS = 1500; +/** + * Stops the job of an account that goes, and frees what its export kept in this + * browser. `all`: whoever's job it is. Another account's job goes on. + */ +async function stopJobOf(accountId: string): Promise { + // Finished exports waiting out their grace period, with the dialog long closed. + dropExportFiles(accountId); + const store = useMailTransferStore.getState(); + const job = store.job; + if (!job || !(accountId === "all" || !job.scope || job.scope === accountId)) return; + const forget = () => { + const now = useMailTransferStore.getState(); + if (now.job?.controller === job.controller) now.close({ now: true }); + }; + if (isJobActive(job)) { + store.cancelJob("signout"); + await Promise.race([job.finished, new Promise((resolve) => setTimeout(resolve, SIGN_OUT_WAIT_MS))]); + // Still going after the wait: close() leaves a running job be, so it goes when it ends. + if (isJobActive(useMailTransferStore.getState().job)) { + void job.finished?.then(forget, forget); + return; + } + } + forget(); +} + /** * Draws the mail import and export dialogs, and while it is hidden with a job running, * a chip in the corner that shows the progress and brings it back. Mounted @@ -31,18 +60,16 @@ export function MailTransferHost() { // Another account signing out leaves the job alone. The job's checkpoint // goes after the job has stopped, so a late write can't bring it back. useEffect(() => onBeforeSignOut(async (accountId) => { - const store = useMailTransferStore.getState(); - const job = store.job; - if (job && (accountId === "all" || !job.scope || job.scope === accountId)) { - if (isJobActive(job)) { - store.cancelJob("signout"); - await Promise.race([job.finished, new Promise((resolve) => setTimeout(resolve, SIGN_OUT_WAIT_MS))]); - } - useMailTransferStore.getState().close({ now: true }); - } + await stopJobOf(accountId); if (accountId !== "all") await clearCheckpointsFor(accountId); }), []); + // A wipe takes the same, and every spool no tab holds. `null` is everything on this device. + useEffect(() => onDeviceWipe(async (account) => { + await stopJobOf(account ?? "all"); + await sweepPrivateExports({ force: account === null }); + }), []); + // Export files a closed tab or a crash left in the browser's private // storage go when the app starts and when someone signs in. const activeAccountId = useAuthStore((s) => s.activeAccountId); @@ -50,6 +77,20 @@ export function MailTransferHost() { if (!isJobActive(useMailTransferStore.getState().job)) void sweepPrivateExports(); }, [activeAccountId]); + // The desktop app shows a running export on its taskbar button. + const exportPhase = job?.kind === "export" ? job.progress.phase : null; + const exportFraction = job?.kind === "export" ? job.progress.fraction : 0; + const onTaskbar = useRef(false); + useEffect(() => { + if (exportPhase === "running" || exportPhase === "paused") { + onTaskbar.current = true; + showTaskbarProgress(exportFraction, exportPhase === "paused" ? "paused" : "normal"); + } else if (onTaskbar.current) { + onTaskbar.current = false; + showTaskbarProgress(null); + } + }, [exportPhase, exportFraction]); + // A job that ends while its dialog is hidden says so, with a way back to its summary. const tImport = useTranslations("mail_import"); const tExport = useTranslations("mail_export"); @@ -64,8 +105,8 @@ export function MailTransferHost() { if (job.kind === "import") { if (phase === "done") toast.success(tImport("toast_done", { imported: job.progress.imported, failed: job.progress.failed }), { action }); else toast.warning(tImport("toast_stopped"), { action }); - } else if (phase === "done") { - toast.success(tExport("toast_done", { name: job.progress.file?.name ?? "" }), { action }); + } else if (phase === "done" && job.progress.file) { + toast.success(tExport("toast_done", { name: job.progress.file.name }), { action }); } else { toast.warning(tExport("toast_stopped"), { action }); } @@ -77,6 +118,7 @@ export function MailTransferHost() { <> {request?.kind === "import" && } {request?.kind === "export" && } + {request?.kind === "local-export" && } {!visible && isJobActive(job) && } ); diff --git a/components/mail-transfer/start-local-export.ts b/components/mail-transfer/start-local-export.ts new file mode 100644 index 000000000..ca40ea492 --- /dev/null +++ b/components/mail-transfer/start-local-export.ts @@ -0,0 +1,116 @@ +import { downloadAllowed, downloadAllowedQuietly } from "@/lib/download-guard"; +import { localExportClient } from "@/lib/mail-export/local-store-client"; +import { runExport, type ExportFolder, type ExportFormat } from "@/lib/mail-export/engine"; +import { guardedSink, type ExportSink } from "@/lib/mail-export/sinks"; +import { scopeDecided } from "@/lib/offline/offline-mode"; +import { isOfflineNow, useOfflineStore } from "@/lib/offline/state"; +import { getStore } from "@/lib/store/client/api"; +import type { StoreSession } from "@/lib/store/engine/transport"; +import { useAuthStore } from "@/stores/auth-store"; +import { useMailTransferStore } from "@/stores/mail-transfer-store"; +import { usePolicyStore } from "@/stores/policy-store"; +import { useAccountSubjects } from "@/lib/org/account-subjects"; + +const MAIL_CAPABILITY = "urn:ietf:params:jmap:mail"; + +/** + * Whether the organisation has said what applies to this account, down to its + * groups. A start offline may not have heard it, and a group or domain scope may + * forbid what Everyone may do: not known means no. + */ +export function localExportDecided(account: string): boolean { + return scopeDecided(account); +} + +/** Whether the organisation lets this account export. */ +export function localExportAllowed(account: string): boolean { + return localExportDecided(account) && downloadAllowedQuietly("export", account); +} + +function clientOf(account: string) { + const auth = useAuthStore.getState(); + return auth.getClientForAccount?.(account) ?? (account === auth.activeAccountId ? auth.client : undefined) ?? null; +} + +/** The real client while its server can be reached. */ +function reachableClient(account: string) { + const client = clientOf(account); + if (!client || isOfflineNow(client.getServerUrl())) return null; + return client; +} + +/** Whether a message with no original here could be fetched right now. */ +export function localExportReachable(account: string): boolean { + return reachableClient(account) !== null; +} + +/** The account's own mail account in the store. Shared accounts aren't offered yet. */ +export async function primaryJmapAccount(account: string): Promise { + const session = await getStore().area(account, "mail.session"); + return session?.primaryAccounts?.[MAIL_CAPABILITY] ?? clientOf(account)?.getAccountId() ?? null; +} + +export function localExportReader(account: string, jmap: string) { + return localExportClient({ account, jmap, store: getStore(), online: () => reachableClient(account) }); +} + +export interface StartLocalExportInput { + account: string; + jmap: string; + format: ExportFormat; + folders: ExportFolder[]; + sink: ExportSink; +} + +/** Runs an export from the local store as the dialog's job. False, and nothing read, when the policy says no. */ +export function startLocalExport(input: StartLocalExportInput): boolean { + const { account, jmap, format, folders } = input; + if (!localExportAllowed(account) || !downloadAllowed("export", account)) { + void input.sink.abort().catch(() => {}); + return false; + } + const controller = new AbortController(); + // Stops at once, also for an account that isn't the active one, and when no write is due. + const unwatchPolicy = usePolicyStore.subscribe(() => { + if (!localExportAllowed(account)) controller.abort("policy"); + }); + // What another account's groups turn out to be can say no as well. + const unwatchSubjects = useAccountSubjects.subscribe(() => { + if (!localExportAllowed(account)) controller.abort("policy"); + }); + const unwatchOffline = useOfflineStore.subscribe((state) => { + if (!state.enabled.includes(account)) controller.abort("offline-off"); + }); + let ended: () => void = () => {}; + const finished = new Promise((resolve) => { ended = resolve; }); + useMailTransferStore.getState().startJob({ + kind: "export", + controller, + scope: account, + finished, + progress: { phase: "running", exported: 0, failed: 0, failures: [], total: 0, fraction: 0 }, + }); + void (async () => { + try { + const result = await runExport({ + client: localExportReader(account, jmap), + accountId: jmap, + folders, + format, + // The save picker passes the guard in saveBlobAs, so every write asks. + sink: guardedSink(input.sink, () => localExportAllowed(account)), + signal: controller.signal, + requestsPerMinute: Infinity, + noEmptyFile: true, + onProgress: (progress) => useMailTransferStore.getState().updateProgress(progress), + }); + useMailTransferStore.getState().updateProgress(result); + } finally { + unwatchPolicy(); + unwatchSubjects(); + unwatchOffline(); + ended(); + } + })(); + return true; +} diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index b4bd25746..c6d045964 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -148,6 +148,7 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { canDownload } from "@/lib/device-policy"; import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session"; import { setNotificationReplyOpener } from "@/lib/pwa-notify/page-actions"; +import { setMailLinkOpener } from "@/lib/desktop/mail-links"; import { requestTabReply } from "@/lib/pwa-notify/tab-reply"; import { consumePendingReplyText } from "@/lib/pwa-notify/worker-state"; import { pendingHandoffs, subscribeHandoff, takeHandoff, type Handoff } from "@/lib/launch-handoff"; @@ -2955,6 +2956,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }); }, []); + // The desktop app's tray, jump list and toasts, like any other permalink. A live + // composer's text goes through its own "Save or discard?" first. + useEffect(() => { + return setMailLinkOpener((link, options) => { + const open = () => void applyMailDeepLinkRef.current(link, options); + const requestClose = composerRequestCloseRef.current; + if (link.kind === 'message' && link.reply && composerOpenRef.current && requestClose) requestClose(open); + else open(); + }); + }, []); + // A plugin opening a message (api.ui.openEmail), as a click on a search // result entry naming one does: handleEmailSelect, set once it is defined. // Not inside the Pro shell, which keeps this tab mounted while hidden and diff --git a/components/offline/__tests__/device-wipe-host.test.tsx b/components/offline/__tests__/device-wipe-host.test.tsx new file mode 100644 index 000000000..f2cf3373e --- /dev/null +++ b/components/offline/__tests__/device-wipe-host.test.tsx @@ -0,0 +1,62 @@ +import { act, render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// The one toast when an account beside this one signs out by itself. + +const warning = vi.hoisted(() => vi.fn()); +vi.mock('@/stores/toast-store', () => ({ toast: { warning } })); +vi.mock('@/lib/offline/device-wipe', () => ({ startDeviceWipe: () => () => {} })); + +const { DeviceWipeHost } = await import('../device-wipe-host'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { markLoginRefused, setRefusedCount } = await import('@/lib/devices/wipe-state'); +const { useOfflineStore, markEvicted } = await import('@/lib/offline/state'); + +const A = 'a@example.com'; +const B = 'b@example.com'; + +beforeEach(() => { + warning.mockClear(); + localStorage.clear(); + useOfflineStore.setState({ enabled: [A, B] }); + useAuthStore.setState({ activeAccountId: A, isAuthenticated: true } as never); +}); + +function refuseB(): void { + markLoginRefused(B, { certain: true, seenBy: [A], label: B, username: B }); + markEvicted(B); +} + +describe('the toast', () => { + it('comes once, when the count is in, and names the account, the count and the date', () => { + render(); + act(() => refuseB()); + expect(warning).not.toHaveBeenCalled(); + act(() => setRefusedCount(B, 2)); + expect(warning).toHaveBeenCalledTimes(1); + expect(warning.mock.calls[0][0]).toBe('sibling_waiting'); + act(() => setRefusedCount(B, 2)); + expect(warning).toHaveBeenCalledTimes(1); + }); + + it('says the other thing with nothing unsent, or no number', () => { + render(); + act(() => { refuseB(); setRefusedCount(B, null); }); + expect(warning.mock.calls[0][0]).toBe('sibling_kept'); + }); + + it('isn\'t for someone signed in later', () => { + useAuthStore.setState({ activeAccountId: 'stranger@example.com' } as never); + refuseB(); + setRefusedCount(B, 2); + render(); + expect(warning).not.toHaveBeenCalled(); + }); + + it('shows at the next page for the one it was meant for, if it got no chance before', () => { + refuseB(); + setRefusedCount(B, 2); + render(); + expect(warning).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/offline/account-storage.tsx b/components/offline/account-storage.tsx index d58d07a1d..80666f60c 100644 --- a/components/offline/account-storage.tsx +++ b/components/offline/account-storage.tsx @@ -13,7 +13,9 @@ import { attachmentCapOptions, maxAttachmentMb, windowOptions } from '@/lib/offl import { isOfflineNow, useOfflineStore } from '@/lib/offline/state'; import { minutesLeft, progressWorthShowing, type SyncProgress } from '@/lib/offline/sync-progress'; import { devicesForAccount } from '@/lib/device-policy'; +import { localExportAllowed } from '@/components/mail-transfer/start-local-export'; import { useAccountStore } from '@/stores/account-store'; +import { openLocalExport } from '@/stores/mail-transfer-store'; import { KeptFiles } from './kept-files'; const MB = 1024 * 1024; @@ -65,6 +67,7 @@ function useUnsent(account: string) { export function AccountStorage({ account, primary, heading }: { account: string; primary: boolean; heading: string | null }) { const t = useTranslations('offline.settings'); const tStorage = useTranslations('offlineStorage'); + const tExport = useTranslations('offlineExport'); const format = useFormatter(); const enabled = useOfflineStore((s) => s.enabled.includes(account)); const usage = useOfflineStore((s) => s.usage[account]); @@ -220,6 +223,9 @@ export function AccountStorage({ account, primary, heading }: { account: string; )} + + + diff --git a/components/offline/device-wipe-host.tsx b/components/offline/device-wipe-host.tsx index 3816ca38d..405e94a82 100644 --- a/components/offline/device-wipe-host.tsx +++ b/components/offline/device-wipe-host.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react'; import { useTranslations } from 'next-intl'; -import { consumeWipeNote, type WipeNote } from '@/lib/devices/wipe-state'; +import { consumeWipeNote, onRefusedLoginsChanged, takeSiblingToasts, waitDate, type WipeNote } from '@/lib/devices/wipe-state'; import { startDeviceWipe } from '@/lib/offline/device-wipe'; import { useAuthStore } from '@/stores/auth-store'; import { toast } from '@/stores/toast-store'; @@ -33,5 +33,17 @@ export function DeviceWipeHost() { if (note) toast.warning(wipeNoteText(note, tRef.current), { duration: 0 }); }, [signedIn]); + // An account beside this one signed out by itself: once, when it's known what waits. + useEffect(() => { + const look = () => { + for (const wait of takeSiblingToasts(useAuthStore.getState().activeAccountId)) { + const values = { name: wait.label, date: waitDate(wait.until) }; + toast.warning(wait.unsent ? tRef.current('sibling_waiting', { ...values, count: wait.unsent }) : tRef.current('sibling_kept', values), { duration: 0 }); + } + }; + look(); + return onRefusedLoginsChanged(look); + }, []); + return null; } diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx index d3d8f4942..f3940efaa 100644 --- a/components/protocol/protocol-launch-handler-provider.tsx +++ b/components/protocol/protocol-launch-handler-provider.tsx @@ -7,6 +7,7 @@ import { usePathname, useRouter } from "@/i18n/navigation"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useConfig } from "@/hooks/use-config"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { startOsCommands } from "@/lib/desktop/os-commands"; import { flushComposers } from "@/lib/compose-flush-registry"; import { isPopoutPath } from "@/lib/popout/window-role"; import { isStandaloneDisplayMode } from "@/lib/standalone-display"; @@ -110,6 +111,7 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle const router = useRouter(); const pathname = usePathname(); const tLaunch = useTranslations("pwaLaunch"); + const tDesktop = useTranslations("desktopOs"); const sentToTarget = useRef(new Set()); const { dialogProps, confirm } = useConfirmDialog(); const [offer, setOffer] = useState(null); @@ -201,6 +203,22 @@ export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandle return subscribeHandoff("any", goToTarget); }, [pathname, leaveFor]); + // The desktop app: what the OS asks for arrives over the bridge, not launchQueue. + const osEnv = useRef({ leaveFor, push: router.push as (path: string) => void, pathname }); + osEnv.current = { leaveFor, push: router.push as (path: string) => void, pathname }; + useEffect(() => { + if (isPopoutPath(window.location.pathname)) return; + return startOsCommands({ + leaveFor: (path) => osEnv.current.leaveFor(path), + push: (path) => { + if (osEnv.current.pathname !== path) osEnv.current.push(path); + }, + notifyFailed: () => toast.error(tLaunch("open_failed")), + notifyReplyLost: () => toast.error(tDesktop("reply_lost")), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + useEffect(() => { if (pathname.startsWith("/protocol/") || isPopoutPath(pathname)) return; diff --git a/components/providers/calendar-alert-provider.tsx b/components/providers/calendar-alert-provider.tsx index cb0b72b6a..561586746 100644 --- a/components/providers/calendar-alert-provider.tsx +++ b/components/providers/calendar-alert-provider.tsx @@ -1,12 +1,15 @@ "use client"; +import { MissedRemindersDialog } from '@/components/calendar/missed-reminders-dialog'; import { useCalendarAlerts } from '@/hooks/use-calendar-alerts'; +import { useStoreReminders } from '@/hooks/use-store-reminders'; import { ToastContainer } from '@/components/ui/toast'; import { LiveRegions } from '@/components/ui/live-regions'; import { useToastStore } from '@/stores/toast-store'; export function CalendarAlertProvider({ children }: { children: React.ReactNode }) { useCalendarAlerts(); + useStoreReminders(); const toasts = useToastStore((s) => s.toasts); const removeToast = useToastStore((s) => s.removeToast); @@ -15,6 +18,7 @@ export function CalendarAlertProvider({ children }: { children: React.ReactNode <> {children} + ); diff --git a/components/session/__tests__/session-timeout-guard.test.tsx b/components/session/__tests__/session-timeout-guard.test.tsx index cad5348f1..887ccbf2a 100644 --- a/components/session/__tests__/session-timeout-guard.test.tsx +++ b/components/session/__tests__/session-timeout-guard.test.tsx @@ -184,7 +184,7 @@ describe('session timeout guard', () => { await advance(500); expect(order).toEqual(['draft saved', 'signed out']); - expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false }); + expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1 }); }); @@ -196,7 +196,7 @@ describe('session timeout guard', () => { await advance(DRAFT_FLUSH_TIMEOUT_MS - 1); expect(logoutAll).not.toHaveBeenCalled(); await advance(1); - expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false }); + expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); }); it('uses the strictest of the signed-in accounts', async () => { @@ -258,7 +258,7 @@ describe('session timeout guard', () => { expect(screen.queryByText('max_title')).toBeNull(); await advance(60 * SECOND); await advance(0); - expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false }); + expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 1 }); }); @@ -276,7 +276,7 @@ describe('session timeout guard', () => { fireEvent.pointerDown(window); await advance(0); - expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false }); + expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1 }); }); @@ -319,7 +319,7 @@ describe('session timeout guard', () => { // Later in the body than the overlay, so it paints on top at a higher z-index too. expect(overlay.compareDocumentPosition(warning) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); await advance(30 * SECOND); - expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false }); + expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); } finally { overlay.remove(); hasFocus.mockRestore(); diff --git a/components/session/session-timeout-guard.tsx b/components/session/session-timeout-guard.tsx index 08b0dd983..c208a810a 100644 --- a/components/session/session-timeout-guard.tsx +++ b/components/session/session-timeout-guard.tsx @@ -92,7 +92,8 @@ const IDLE_STATE: SessionTimerState = { phase: "active", kind: null, deadline: n * (policy.json `organisation.session`). Activity in any Bulwark tab keeps * every tab signed in; a warning counts down first; at zero the open and * minimised composers save their drafts and every account signs out, with - * the reason on the login page. + * the reason on the login page. What the accounts keep on the device hides until + * the next sign-in, unless the organisation asks for deletion at once. */ export function SessionTimeoutGuard() { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); @@ -149,7 +150,8 @@ export function SessionTimeoutGuard() { rememberSessionEnd(endReason(kind, current, false)); const flushed = await flushComposers(DRAFT_FLUSH_TIMEOUT_MS); if (flushed.failed > 0 || flushed.timedOut) rememberSessionEnd(endReason(kind, current, true)); - await useAuthStore.getState().logoutAll({ endProviderSession: false }); + // Nobody asked: the mail hides until the next sign-in, and unsent messages with it. + await useAuthStore.getState().logoutAll({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); }, []); // The clock, while there is a limit to count. diff --git a/components/settings/__tests__/desktop-telemetry-settings.test.tsx b/components/settings/__tests__/desktop-telemetry-settings.test.tsx new file mode 100644 index 000000000..94fabcccb --- /dev/null +++ b/components/settings/__tests__/desktop-telemetry-settings.test.tsx @@ -0,0 +1,189 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { DesktopCrashFile, DesktopCrashSendResult, DesktopPrivacyState } from '@/lib/desktop/types'; +import { DesktopTelemetrySettings } from '../desktop-telemetry-settings'; + +// The desktop app's two switches. A browser sees nothing. The shell decides and asks; this +// page only shows what it says, and sends a crash file only when the button is pressed. + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() })); +vi.mock('@/stores/toast-store', () => ({ toast })); +vi.mock('next-intl', async (importOriginal) => ({ + ...(await importOriginal()), + useTranslations: () => (key: string) => key, + useFormatter: () => ({ dateTime: (date: Date) => date.toISOString() }), +})); +vi.mock('../settings-section', async (importOriginal) => ({ + ...(await importOriginal()), + SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +const OFF: DesktopPrivacyState = { + telemetry: { on: false, forcedOff: false }, + crashReports: { on: false, forcedOff: false, uploadUrl: '' }, +}; +const CRASH_ON: DesktopPrivacyState = { ...OFF, crashReports: { on: true, forcedOff: false, uploadUrl: '' } }; +const FILE: DesktopCrashFile = { id: 'a1', createdAt: Date.parse('2026-11-03T10:00:00Z'), bytes: 2048 }; + +function bridge(state: DesktopPrivacyState, crashes: DesktopCrashFile[] = [], mode: 'standalone' | 'instance' = 'standalone') { + const privacy = { + get: vi.fn(async () => state), + setTelemetry: vi.fn(async (on: boolean) => ({ ...state, telemetry: { ...state.telemetry, on } })), + setCrashReports: vi.fn(async (on: boolean) => ({ ...state, crashReports: { ...state.crashReports, on } })), + setCrashUploadUrl: vi.fn(async (_url: string) => ({ ok: true, state })), + listCrashes: vi.fn(async () => crashes), + sendCrash: vi.fn(async (_id: string): Promise => ({ ok: true })), + deleteCrashes: vi.fn(async () => {}), + onChange: vi.fn(() => () => {}), + }; + (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode, privacy }; + return privacy; +} + +afterEach(() => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + toast.success.mockClear(); + toast.error.mockClear(); +}); + +const checked = (id: string) => screen.getByTestId(id).getAttribute('aria-checked'); + +describe('usage info and crash reports', () => { + it('shows nothing in a browser', async () => { + const { container } = render(); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(container.textContent).toBe(''); + }); + + it('shows nothing under a shell without the bridge member', async () => { + (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32' }; + const { container } = render(); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(container.textContent).toBe(''); + }); + + it('starts with both off, and says what a crash file can hold', async () => { + bridge(OFF); + render(); + await screen.findByTestId('desktop-telemetry-toggle'); + expect(checked('desktop-telemetry-toggle')).toBe('false'); + expect(checked('desktop-crash-toggle')).toBe('false'); + expect(screen.getByText('crash_memory')).toBeTruthy(); + }); + + it('asks the shell and shows its answer', async () => { + const privacy = bridge(OFF); + render(); + fireEvent.click(await screen.findByTestId('desktop-telemetry-toggle')); + await waitFor(() => expect(privacy.setTelemetry).toHaveBeenCalledWith(true)); + await waitFor(() => expect(checked('desktop-telemetry-toggle')).toBe('true')); + expect(privacy.setCrashReports).not.toHaveBeenCalled(); + }); + + it('shows a switch still off when the user said no in the shell\'s dialog', async () => { + const privacy = bridge(OFF); + privacy.setCrashReports.mockResolvedValue(OFF); + render(); + fireEvent.click(await screen.findByTestId('desktop-crash-toggle')); + await waitFor(() => expect(privacy.setCrashReports).toHaveBeenCalledWith(true)); + expect(checked('desktop-crash-toggle')).toBe('false'); + }); + + it('locks both when a policy forces them off, and says who did', async () => { + const privacy = bridge({ + telemetry: { on: false, forcedOff: true }, + crashReports: { on: false, forcedOff: true, uploadUrl: '' }, + }); + render(); + const toggle = await screen.findByTestId('desktop-telemetry-toggle'); + expect(toggle.hasAttribute('disabled')).toBe(true); + expect(screen.getByTestId('desktop-crash-toggle').hasAttribute('disabled')).toBe(true); + expect(screen.getAllByText('forced_off')).toHaveLength(2); + fireEvent.click(toggle); + expect(privacy.setTelemetry).not.toHaveBeenCalled(); + }); + + it('adds the note about the instance\'s own admin only on an instance', async () => { + bridge(OFF, [], 'instance'); + render(); + await screen.findByTestId('desktop-telemetry-toggle'); + expect(screen.getByText(/instance_note/)).toBeTruthy(); + }); +}); + +describe('crash files', () => { + it('sends only when the button is pressed', async () => { + const privacy = bridge(CRASH_ON, [FILE]); + render(); + const send = await screen.findByTestId('desktop-crash-send'); + expect(privacy.sendCrash).not.toHaveBeenCalled(); + fireEvent.click(send); + await waitFor(() => expect(privacy.sendCrash).toHaveBeenCalledWith('a1')); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('sent')); + }); + + it('says so when no address is set', async () => { + const privacy = bridge(CRASH_ON, [FILE]); + privacy.sendCrash.mockResolvedValue({ ok: false, reason: 'no-url' }); + render(); + fireEvent.click(await screen.findByTestId('desktop-crash-send')); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('send_no_url')); + }); + + it('says nothing when the user said no in the dialog', async () => { + const privacy = bridge(CRASH_ON, [FILE]); + privacy.sendCrash.mockResolvedValue({ ok: false, reason: 'cancelled' }); + render(); + fireEvent.click(await screen.findByTestId('desktop-crash-send')); + await waitFor(() => expect(privacy.sendCrash).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(toast.error).not.toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('asks for the address only with crash reports on, and shows a bad one\'s answer', async () => { + const privacy = bridge(CRASH_ON); + privacy.setCrashUploadUrl.mockResolvedValue({ ok: false, state: CRASH_ON }); + render(); + const input = await screen.findByTestId('desktop-crash-url'); + fireEvent.change(input, { target: { value: 'ftp://nope' } }); + fireEvent.click(screen.getByRole('button', { name: 'url_save' })); + await waitFor(() => expect(privacy.setCrashUploadUrl).toHaveBeenCalledWith('ftp://nope')); + await screen.findByText('url_invalid'); + }); + + it('hides the address with crash reports off', async () => { + bridge(OFF); + render(); + await screen.findByTestId('desktop-telemetry-toggle'); + expect(screen.queryByTestId('desktop-crash-url')).toBeNull(); + }); + + it('deletes through the shell', async () => { + const privacy = bridge(CRASH_ON, [FILE]); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'delete_all' })); + await waitFor(() => expect(privacy.deleteCrashes).toHaveBeenCalledTimes(1)); + }); +}); + +describe('the text', () => { + const en = JSON.parse(readFileSync(path.join(process.cwd(), 'locales/en/common.json'), 'utf8')).desktopTelemetry as Record; + + it('names everything a heartbeat holds, in plain words', () => { + for (const word of [/a random ID/, /app version/, /Node version/, /operating system/, /how long the app has been running/, /how many plugins and themes/]) { + expect(en.telemetry_description).toMatch(word); + } + expect(en.telemetry_description).not.toMatch(/uptime/i); + }); + + it('says crash reports carry a random ID too', () => { + expect(en.crash_description).toMatch(/a random ID/); + }); + + it('says a crash file can still hold the text of mail after scrubbing', () => { + expect(en.crash_memory).toMatch(/can still contain the text of your mail/); + }); +}); diff --git a/components/settings/desktop-os-settings.tsx b/components/settings/desktop-os-settings.tsx new file mode 100644 index 000000000..1703487e6 --- /dev/null +++ b/components/settings/desktop-os-settings.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import type { DesktopOsDefaultAppStatus, DesktopOsSettings as Settings, DesktopOsStatus } from "@/lib/desktop/types"; +import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section"; + +/** On Windows and Linux this is what a press of the keys shows; macOS maps it to Cmd. */ +const SHOW_SHORTCUT = "CommandOrControl+Shift+B"; + +function shortcutLabel(platform: string | undefined): string { + return platform === "darwin" ? "⇧⌘B" : "Ctrl+Shift+B"; +} + +/** + * The desktop app's own settings, on the General page: only where there's an app to + * set. A browser never sees it, and it renders nothing until the bridge has answered. + * Quick compose adds its shortcut here later. + */ +export function DesktopOsSettings() { + const t = useTranslations("desktopOs"); + const [status, setStatus] = useState(null); + const [defaultApp, setDefaultApp] = useState(null); + const bridge = getDesktopBridge(); + const os = bridge?.os; + + useEffect(() => { + if (!os) return; + let stale = false; + void os.getStatus().then((next) => { + if (!stale) setStatus(next); + }); + void os.defaultAppStatus().then((next) => { + if (!stale) setDefaultApp(next); + }); + const stop = os.onStatusChange(setStatus); + return () => { + stale = true; + stop(); + }; + }, [os]); + + if (!os || !status) return null; + const { settings } = status; + const change = (patch: Partial) => { + void os.setSettings(patch).then(setStatus); + }; + + return ( + + + change({ closeToTray })} /> + + + change({ startAtLogin })} /> + + {settings.startAtLogin && ( + + change({ startMinimized })} /> + + )} + + change({ attentionOnNewMail })} /> + + + change({ quietHoursEnabled })} /> + + {settings.quietHoursEnabled && ( + +
+ change({ quietHoursStart: event.target.value })} + className="rounded-md border border-border bg-background px-2 py-1 text-sm" + /> + {t("quiet_hours_to")} + change({ quietHoursEnd: event.target.value })} + className="rounded-md border border-border bg-background px-2 py-1 text-sm" + /> +
+
+ )} + + change({ showShortcut: on ? SHOW_SHORTCUT : null })} + /> + + + + +
+ ); +} diff --git a/components/settings/desktop-telemetry-settings.tsx b/components/settings/desktop-telemetry-settings.tsx new file mode 100644 index 000000000..cc2dda5f3 --- /dev/null +++ b/components/settings/desktop-telemetry-settings.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useFormatter, useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import type { DesktopCrashFile, DesktopCrashSendResult, DesktopPrivacyState } from "@/lib/desktop/types"; +import { toast } from "@/stores/toast-store"; +import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section"; + +const SEND_ERROR: Record["reason"], string | null> = { + "no-consent": "send_no_consent", + "no-url": "send_no_url", + "not-found": "send_failed", + failed: "send_failed", + // The user said no in the dialog: nothing to tell them. + cancelled: null, +}; + +/** + * Usage info and crash reports in the desktop app: each with its own switch, both off + * until the user says yes, and only where there's an app. A browser never sees this, and + * it renders nothing until the bridge has answered. The shell does the deciding, the + * asking and the sending. + */ +export function DesktopTelemetrySettings() { + const t = useTranslations("desktopTelemetry"); + const format = useFormatter(); + const bridge = getDesktopBridge(); + const privacy = bridge?.privacy; + const [state, setState] = useState(null); + const [crashes, setCrashes] = useState([]); + const [url, setUrl] = useState(""); + const [urlInvalid, setUrlInvalid] = useState(false); + const [sending, setSending] = useState(null); + + const refreshCrashes = useCallback(() => { + void privacy?.listCrashes().then(setCrashes, () => {}); + }, [privacy]); + + useEffect(() => { + if (!privacy) return; + let stale = false; + const apply = (next: DesktopPrivacyState) => { + if (stale) return; + setState(next); + setUrl(next.crashReports.uploadUrl); + }; + void privacy.get().then(apply, () => {}); + refreshCrashes(); + const stop = privacy.onChange((next) => { + apply(next); + refreshCrashes(); + }); + return () => { + stale = true; + stop(); + }; + }, [privacy, refreshCrashes]); + + if (!privacy || !state) return null; + const { telemetry, crashReports } = state; + + const saveUrl = async () => { + const result = await privacy.setCrashUploadUrl(url.trim()); + setUrlInvalid(!result.ok); + if (result.ok) setState(result.state); + }; + + const send = async (id: string) => { + setSending(id); + try { + const result = await privacy.sendCrash(id); + if (result.ok) toast.success(t("sent")); + else if (SEND_ERROR[result.reason]) toast.error(t(SEND_ERROR[result.reason]!)); + } finally { + setSending(null); + } + }; + + const showCrashFiles = crashReports.on || crashes.length > 0; + + return ( + + + void privacy.setTelemetry(on).then(setState)} + /> + + + void privacy.setCrashReports(on).then((next) => { + setState(next); + refreshCrashes(); + })} + /> + + {!crashReports.forcedOff && ( +

+ {t("crash_memory")} + {bridge?.mode === "instance" && ` ${t("instance_note")}`} +

+ )} + {crashReports.on && ( + +
+ { + setUrl(event.target.value); + setUrlInvalid(false); + }} + className="w-64" + /> + +
+
+ )} + {showCrashFiles && ( + + {crashes.length > 0 && ( + + )} + + )} + {showCrashFiles && crashes.length === 0 && ( +

{t("files_empty")}

+ )} + {crashes.map((crash) => ( + + + + ))} +
+ ); +} diff --git a/components/settings/general-settings.tsx b/components/settings/general-settings.tsx index f0bf21780..d9e72d32e 100644 --- a/components/settings/general-settings.tsx +++ b/components/settings/general-settings.tsx @@ -6,7 +6,9 @@ import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { LanguageSettings } from './language-settings'; import { AppLockSettings } from './app-lock-settings'; import { DesktopKeystoreSettings } from './desktop-keystore-settings'; +import { DesktopTelemetrySettings } from './desktop-telemetry-settings'; import { ProtocolHandlerSettings } from './protocol-handler-settings'; +import { DesktopOsSettings } from './desktop-os-settings'; import { useTour } from '@/components/tour/tour-provider'; import { Button } from '@/components/ui/button'; import { PlayCircle } from '@/components/icons'; @@ -29,8 +31,11 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea + + {!IS_LITE && } + diff --git a/components/ui/__tests__/toast-extra-actions.test.tsx b/components/ui/__tests__/toast-extra-actions.test.tsx new file mode 100644 index 000000000..10354d4f3 --- /dev/null +++ b/components/ui/__tests__/toast-extra-actions.test.tsx @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { ToastItem } from '../toast'; + +describe('a toast with more than three actions', () => { + it('shows them in order, and each closes it', () => { + vi.useFakeTimers(); + const clicked = vi.fn(); + const onDismiss = vi.fn(); + const action = (label: string) => ({ label, onClick: () => clicked(label) }); + render( + , + ); + expect(screen.getAllByRole('button').map((button) => button.textContent).filter(Boolean)).toEqual(['five', 'ten', 'fifteen', 'join', 'open']); + fireEvent.click(screen.getByText('join')); + expect(clicked).toHaveBeenCalledWith('join'); + expect(onDismiss).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); +}); diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index 28bb16c0a..a3d96a2be 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -36,6 +36,8 @@ export interface Toast { * that has one lays its actions out on a row of their own. */ tertiaryAction?: ToastAction; + /** More actions at the end of that row. Needs a tertiaryAction. */ + extraActions?: ToastAction[]; /** * Work in progress, e.g. an upload: 0..1, or null while the amount is * unknown. The bottom line then shows progress instead of a countdown, @@ -291,6 +293,7 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr {actionButton(toast.tertiaryAction)} {toast.secondaryAction && actionButton(toast.secondaryAction)} {toast.action && actionButton(toast.action)} + {toast.extraActions?.map((action) => {actionButton(action)})} )} diff --git a/hooks/__tests__/use-calendar-alerts-hooks.test.tsx b/hooks/__tests__/use-calendar-alerts-hooks.test.tsx index 331953d64..0f3ca9527 100644 --- a/hooks/__tests__/use-calendar-alerts-hooks.test.tsx +++ b/hooks/__tests__/use-calendar-alerts-hooks.test.tsx @@ -76,6 +76,54 @@ afterEach(() => { vi.restoreAllMocks(); }); +describe('the 24-hour fetch', () => { + it('fires the occurrence of a series the server answered with its master', async () => { + const now = Date.now(); + const occurrenceStart = new Date(now + 5 * 60_000); + const stamp = (date: Date) => { + const pad = (value: number) => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:00`; + }; + // The same weekday and time, years ago: only the expansion finds today's. + const first = new Date(occurrenceStart.getTime() - 52 * 7 * 24 * 3_600_000); + const master = { + id: 'series', + uid: 'uid-series', + title: 'Weekly', + start: stamp(first), + duration: 'PT30M', + calendarIds: { c1: true }, + useDefaultAlerts: false, + recurrenceRules: [{ '@type': 'RecurrenceRule', frequency: 'weekly' }], + alerts: { + a1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT6M', relativeTo: 'start' } }, + }, + } as unknown as CalendarEvent; + useCalendarStore.setState({ events: [], calendars: [], supportsCalendar: true }); + useAuthStore.setState({ isAuthenticated: true, client: { queryCalendarEvents: vi.fn(async () => [master]) } as never }); + + renderHook(() => useCalendarAlerts()); + + await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toEqual(['Weekly'])); + }); +}); + +describe('two tabs', () => { + it('rings in one, also when an id changed between the two', async () => { + const synthetic = { ...event, id: 'synthetic-2' } as CalendarEvent; + renderHook(() => useCalendarAlerts()); + await waitFor(() => expect(useToastStore.getState().toasts).toHaveLength(1)); + + // The other tab has the same reminder under the id the server gives it now. + useCalendarStore.setState({ events: [synthetic] }); + renderHook(() => useCalendarAlerts()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 700)); + }); + expect(useToastStore.getState().toasts).toHaveLength(1); + }); +}); + describe('reminder hooks', () => { it('reports a fired reminder once, and its dismissal when the user closes it', async () => { const fired = vi.fn(); diff --git a/hooks/__tests__/use-email-drag-routing.test.tsx b/hooks/__tests__/use-email-drag-routing.test.tsx index e53285031..e3757d946 100644 --- a/hooks/__tests__/use-email-drag-routing.test.tsx +++ b/hooks/__tests__/use-email-drag-routing.test.tsx @@ -731,4 +731,32 @@ describe('message row drag hover wiring', () => { expect(retry.payload.get('DownloadURL')).toMatch(/^message\/rfc822:Message.eml:blob:/); expect(await text(payloadBlob(retry.payload))).toBe(bytes('shared-b', authB)); }); + + it("in the desktop app an Alt drag is the OS's own with the real bytes, and a plain one stays the page's", async () => { + const email = mail({ subject: 'Hello world' }); + const nativeDrag = vi.fn(async (_files: { name: string; data: Uint8Array }[]) => true); + window.bulwarkDesktop = { version: 1, platform: 'win32', os: { startDrag: nativeDrag } } as never; + const { result } = renderHook(() => useEmailDrag({ email, sourceMailboxId: 'inbox' })); + await warm(result); + try { + startDrag.mockClear(); + // Plain: still a move to a folder, still DownloadURL for Explorer. + expect(drag(result.current.dragHandlers).has('DownloadURL')).toBe(true); + expect(startDrag).toHaveBeenCalled(); + expect(nativeDrag).not.toHaveBeenCalled(); + + startDrag.mockClear(); + const preventDefault = vi.fn(); + const dataTransfer = { setData: vi.fn(), setDragImage: vi.fn(), effectAllowed: '' }; + act(() => result.current.dragHandlers.onDragStart({ altKey: true, preventDefault, dataTransfer } as unknown as DragEvent)); + expect(preventDefault).toHaveBeenCalled(); + expect(startDrag).not.toHaveBeenCalled(); + await waitFor(() => expect(nativeDrag).toHaveBeenCalled(), { container: document.createElement('div') }); + const [files] = nativeDrag.mock.calls[0]; + expect(files[0].name).toBe('Hello world.eml'); + expect(new TextDecoder().decode(files[0].data)).toBe(bytes('primary-a', authA)); + } finally { + window.bulwarkDesktop = undefined; + } + }); }); diff --git a/hooks/__tests__/use-new-mail-alerts.test.tsx b/hooks/__tests__/use-new-mail-alerts.test.tsx index c98b51dca..40c1fcee7 100644 --- a/hooks/__tests__/use-new-mail-alerts.test.tsx +++ b/hooks/__tests__/use-new-mail-alerts.test.tsx @@ -39,6 +39,14 @@ describe('new mail alerts', () => { vi.useRealTimers(); }); + it('in the desktop app leaves the sound to the notification path, so it plays once', () => { + window.bulwarkDesktop = { version: 1, platform: 'win32', os: {} } as never; + renderHook(() => useNewMailAlerts()); + arrive('a'); + expect(playNotificationSound).not.toHaveBeenCalled(); + window.bulwarkDesktop = undefined; + }); + it('tells screen readers once how many messages came, never what they say', () => { renderHook(() => useNewMailAlerts()); arrive('a'); diff --git a/hooks/__tests__/use-store-reminders-review.test.tsx b/hooks/__tests__/use-store-reminders-review.test.tsx new file mode 100644 index 000000000..dfdafce43 --- /dev/null +++ b/hooks/__tests__/use-store-reminders-review.test.tsx @@ -0,0 +1,442 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react'; +import { MissedRemindersDialog } from '@/components/calendar/missed-reminders-dialog'; +import { ToastContainer } from '@/components/ui/toast'; +import { useCalendarAlerts } from '../use-calendar-alerts'; +import { useStoreReminders } from '../use-store-reminders'; +import { claimKey, claimNow } from '@/lib/calendar-reminders/claims'; +import { useMissedReminders, useReminderSource } from '@/lib/calendar-reminders/state'; +import { setAppLockCheck } from '@/lib/desktop/app-lock-state'; +import type { BulwarkDesktopBridge, DesktopOsNotification, DesktopOsNotifyResult } from '@/lib/desktop/types'; +import { clearAllHooks, uiHooks } from '@/lib/plugin-hooks'; +import { useOfflineStore } from '@/lib/offline/state'; +import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar'; +import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store'; +import { setStoreBackend } from '@/lib/store/client/api'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useCalendarNotificationStore } from '@/stores/calendar-notification-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useEmailStore } from '@/stores/email-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useToastStore } from '@/stores/toast-store'; + +const sounds = vi.hoisted(() => ({ play: vi.fn() })); +vi.mock('@/lib/notification-sound', () => ({ playNotificationSound: sounds.play })); +vi.mock('@/lib/title-alert', () => ({ startTitleAlert: vi.fn() })); +// Real crypto takes real time, and fake time would run ahead of it. +vi.mock('@/lib/pwa-notify/login-fingerprint', () => ({ loginFingerprint: async () => 'fingerprint' })); +vi.mock('@/lib/offline/offline-mode', () => ({ offlineActive: () => true })); +const policy = vi.hoisted(() => ({ pushWithoutContent: false })); +vi.mock('@/lib/device-policy', async (original) => ({ + ...await original(), + devicesForAccount: () => ({ pushWithoutContent: policy.pushWithoutContent }), +})); + +const MIN = 60_000; +const ACCOUNT = 'acc'; +const START = Date.UTC(2026, 10, 10, 9); +const FIRE = START - 15 * MIN; +const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } }; +const FOREIGN_LINK = { virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://calls.example.org/room' } } }; + +function event(partial: Record = {}) { + return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial }; +} + +let store: InProcessStore; +let server: FakeCalendarJmap; +let notifyResult: DesktopOsNotifyResult; +const notify = vi.fn(async (_notification: DesktopOsNotification) => notifyResult); +const openExternal = vi.fn(); + +function desktop() { + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus: vi.fn() }, shell: { openExternal }, os: { notify } } as unknown as BulwarkDesktopBridge; +} + +function setVisibility(state: 'visible' | 'hidden') { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state }); +} + +function Toasts() { + const toasts = useToastStore((s) => s.toasts); + const removeToast = useToastStore((s) => s.removeToast); + return ; +} + +async function advance(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +async function syncedWith(...events: Record[]) { + for (const partial of events) server.addEvent(partial); + await store.sync(ACCOUNT); +} + +const toastTitles = () => useToastStore.getState().toasts.map((toast) => toast.title); + +/** The login fingerprint is real crypto, in real time. Fake time must not run ahead of it. */ +async function untilToast() { + for (let tries = 0; tries < 500 && toastTitles().length === 0; tries += 1) { + await act(async () => { + await new Promise((resolve) => setImmediate(resolve)); + }); + } +} + +/** Two alerts due with the app closed. Leaves the hook unmounted and the clock moved on. */ +async function missedTwo(...extra: Record[]) { + await syncedWith(event(extra[0] ?? {}), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + const before = renderHook(() => useStoreReminders()); + await advance(0); + before.unmount(); + vi.setSystemTime(FIRE + 3 * 60 * MIN); +} + +beforeEach(() => { + vi.useFakeTimers({ now: FIRE - 30 * MIN, toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); + vi.clearAllMocks(); + clearAllHooks(); + localStorage.clear(); + setAppLockCheck(null); + setVisibility('visible'); + policy.pushWithoutContent = false; + notifyResult = { shown: true, playSound: false }; + useToastStore.setState({ toasts: [] }); + useReminderSource.setState({ driven: {} }); + useMissedReminders.getState().close(); + useCalendarNotificationStore.getState().clearAll(); + useOfflineStore.setState({ enabled: [ACCOUNT] }); + useAuthStore.setState({ isAuthenticated: true, client: null, activeAccountId: ACCOUNT }); + useCalendarStore.setState({ events: [], calendars: [] }); + usePolicyStore.setState({ loaded: true, policy: { ...usePolicyStore.getState().policy, features: { ...usePolicyStore.getState().policy.features, calendarEnabled: true } } }); + useAccountStore.setState({ accounts: [{ id: ACCOUNT, cookieSlot: 1, serverUrl: 'https://one.example', username: 'a@example.org' }] as never }); + useEmailStore.setState({ isPushConnected: true }); + useSettingsStore.setState({ + calendarNotificationsEnabled: true, calendarNotificationSound: true, enableCalendarTasks: true, + doNotDisturb: false, visualAlerts: false, timeZone: 'Europe/Berlin', + }); + server = new FakeCalendarJmap('1.0'); + store = new InProcessStore(server, CALENDAR_SESSION); + setStoreBackend(store); +}); + +afterEach(() => { + setStoreBackend(null); + window.bulwarkDesktop = undefined; + setAppLockCheck(null); + vi.unstubAllGlobals(); + vi.useRealTimers(); + Reflect.deleteProperty(navigator, 'serviceWorker'); +}); + +function grantNotifications() { + const showNotification = vi.fn(async (_title: string, _options: NotificationOptions) => {}); + vi.stubGlobal('Notification', Object.assign(vi.fn(), { permission: 'granted' })); + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { getRegistration: async () => ({ showNotification, getNotifications: async () => [] }) }, + }); + return showNotification; +} + +describe('Join', () => { + beforeEach(desktop); + + it('says the host of a link to a service nobody knows', async () => { + await syncedWith(event(FOREIGN_LINK)); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + const join = notify.mock.calls[0][0].actions!.find((action) => action.id === 'join')!; + expect(join.label).toBe('join_host'); + }); + + it('asks the link check, and a stopped link opens nothing and leaves the reminder standing', async () => { + await syncedWith(event(FOREIGN_LINK)); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + uiHooks.onBeforeExternalLink.register('t', () => false); + render(); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + await untilToast(); + const dismiss = vi.spyOn(store, 'area'); + fireEvent.click(screen.getByText('join_host')); + await advance(100); + expect(openExternal).not.toHaveBeenCalled(); + expect(dismiss.mock.calls.filter(([, name]) => name === 'reminders.dismiss')).toEqual([]); + }); + + it('goes out through the app when the link check lets it', async () => { + await syncedWith(event(FOREIGN_LINK)); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + render(); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + await untilToast(); + const area = vi.spyOn(store, 'area'); + fireEvent.click(screen.getByText('join_host')); + await advance(100); + expect(openExternal).toHaveBeenCalledWith('https://calls.example.org/room'); + expect(area.mock.calls.filter(([, name]) => name === 'reminders.dismiss')).toHaveLength(1); + }); + + it('names each Join in the summary for a screen reader', async () => { + await missedTwo(FOREIGN_LINK); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + render(<>); + renderHook(() => useStoreReminders()); + await advance(20_000); + await untilToast(); + fireEvent.click(screen.getByText('missed_show')); + await advance(0); + expect(screen.getByText('join_host').getAttribute('aria-label')).toBe('join_named'); + }); +}); + +describe('with pushWithoutContent', () => { + it('keeps the title off a browser system notification, on the store path', async () => { + policy.pushWithoutContent = true; + const shown = grantNotifications(); + setVisibility('hidden'); + await syncedWith(event()); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + 3000); + expect(shown).toHaveBeenCalledTimes(1); + expect(shown.mock.calls[0][0]).toBe('generic_title'); + expect(shown.mock.calls[0][1].body).toBe(''); + expect(toastTitles()).toEqual(['Standup']); + }); + + it('keeps the titles off the summary as well', async () => { + policy.pushWithoutContent = true; + await missedTwo(); + const shown = grantNotifications(); + setVisibility('hidden'); + renderHook(() => useStoreReminders()); + await advance(20_000 + 3000); + expect(shown).toHaveBeenCalledTimes(1); + expect(JSON.stringify(shown.mock.calls[0])).not.toContain('Standup'); + }); + + it('keeps it off on the path without a store', async () => { + policy.pushWithoutContent = true; + useOfflineStore.setState({ enabled: [] }); + const shown = grantNotifications(); + setVisibility('hidden'); + useCalendarStore.setState({ + events: [{ id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', utcStart: new Date(START).toISOString(), duration: 'PT30M', calendarIds: { c: true }, useDefaultAlerts: false, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } } }] as never, + }); + renderHook(() => useCalendarAlerts()); + await advance(FIRE - Date.now() + 3000); + expect(shown).toHaveBeenCalledTimes(1); + expect(shown.mock.calls[0][0]).toBe('alert_title'); + expect(shown.mock.calls[0][1].body).toBe(''); + }); +}); + +describe('when the OS refuses or fails', () => { + beforeEach(desktop); + + it('a rejected notify keeps a reminder for the summary', async () => { + await syncedWith(event()); + notify.mockRejectedValueOnce(new Error('main is gone')); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + await advance(5 * MIN); + expect(notify.mock.calls.at(-1)![0].id).toMatch(/^reminder-missed:/); + }); + + it('a failed toast keeps a reminder for the summary', async () => { + await syncedWith(event()); + notifyResult = { shown: false, reason: 'failed', playSound: false }; + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + notifyResult = { shown: true, playSound: false }; + await advance(5 * MIN); + expect(notify.mock.calls.at(-1)![0].id).toMatch(/^reminder-missed:/); + expect(notify.mock.calls.at(-1)![0].body).toBe('Standup'); + }); + + it('a rejected notify puts a summary straight back', async () => { + await missedTwo(); + notify.mockRejectedValueOnce(new Error('main is gone')); + renderHook(() => useStoreReminders()); + await advance(20_000); + expect(notify).toHaveBeenCalledTimes(1); + await advance(5 * MIN); + expect(notify).toHaveBeenCalledTimes(2); + }); +}); + +describe('a summary in the app', () => { + beforeEach(desktop); + + it('comes back after a reload that lost the toast', async () => { + await missedTwo(); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + const page = renderHook(() => useStoreReminders()); + await advance(20_000); + await untilToast(); + expect(toastTitles()).toEqual(['missed_title']); + + // A reload: the toast and the page are gone, nobody dismissed anything. + page.unmount(); + useToastStore.setState({ toasts: [] }); + renderHook(() => useStoreReminders()); + await advance(10 * MIN); + expect(toastTitles()).toEqual(['missed_title']); + }); + + it('stays one toast for as long as it is on screen', async () => { + await missedTwo(); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + renderHook(() => useStoreReminders()); + await advance(20_000); + await untilToast(); + await advance(30 * MIN); + expect(toastTitles()).toEqual(['missed_title']); + }); + + it('does not come back after it was closed', async () => { + await missedTwo(); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + render(); + const page = renderHook(() => useStoreReminders()); + await advance(20_000); + await untilToast(); + act(() => useToastStore.getState().toasts[0].onDismiss!()); + useToastStore.setState({ toasts: [] }); + await advance(30 * MIN); + page.unmount(); + renderHook(() => useStoreReminders()); + await advance(30 * MIN); + expect(toastTitles()).toEqual([]); + }); + + it('leaves out what the path without a store already rang', async () => { + await missedTwo(); + claimNow(claimKey(ACCOUNT, 'uid-1', 'a', FIRE), FIRE); + renderHook(() => useStoreReminders()); + await advance(20_000); + expect(notify.mock.calls[0][0].body).toBe('Review'); + }); + + it('says nothing at all when that was all of it', async () => { + await missedTwo(); + claimNow(claimKey(ACCOUNT, 'uid-1', 'a', FIRE), FIRE); + claimNow(claimKey(ACCOUNT, 'uid-2', 'a', FIRE), FIRE); + renderHook(() => useStoreReminders()); + await advance(20_000 + 10 * MIN); + expect(notify).not.toHaveBeenCalled(); + expect(toastTitles()).toEqual([]); + }); +}); + +describe('the organisation has the calendar off', () => { + beforeEach(() => { + usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, features: { ...usePolicyStore.getState().policy.features, calendarEnabled: false } } }); + }); + + it('the store path stays quiet, and nothing counts as missed later', async () => { + desktop(); + await syncedWith(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + MIN); + expect(notify).not.toHaveBeenCalled(); + await advance(20 * MIN); + expect(notify).not.toHaveBeenCalled(); + expect(toastTitles()).toEqual([]); + }); + + it('the path without a store stays quiet', async () => { + useOfflineStore.setState({ enabled: [] }); + useCalendarStore.setState({ + events: [{ id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', utcStart: new Date(START).toISOString(), duration: 'PT30M', calendarIds: { c: true }, useDefaultAlerts: false, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } } }] as never, + }); + renderHook(() => useCalendarAlerts()); + await advance(FIRE - Date.now() + MIN); + expect(toastTitles()).toEqual([]); + }); +}); + +describe('the path without a store on the desktop', () => { + const plain = () => useCalendarStore.setState({ + events: [{ id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', utcStart: new Date(START).toISOString(), duration: 'PT30M', calendarIds: { c: true }, useDefaultAlerts: false, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } } }] as never, + }); + + beforeEach(() => { + desktop(); + useOfflineStore.setState({ enabled: [] }); + plain(); + }); + + it('stands down while locked: nothing fires, nothing is used up', async () => { + let locked = true; + setAppLockCheck(() => locked); + renderHook(() => useCalendarAlerts()); + await advance(FIRE - Date.now() + MIN); + expect(notify).not.toHaveBeenCalled(); + expect(toastTitles()).toEqual([]); + + locked = false; + await advance(MIN); + expect(toastTitles()).toEqual(['Standup']); + }); + + it('sends an id that names nothing when the title is hidden', async () => { + policy.pushWithoutContent = true; + renderHook(() => useCalendarAlerts()); + await advance(FIRE - Date.now()); + expect(JSON.stringify(notify.mock.calls[0][0])).not.toContain('e1'); + expect(JSON.stringify(notify.mock.calls[0][0])).not.toContain('Standup'); + }); + + it('plays no sound in quiet hours or a pause', async () => { + notifyResult = { shown: false, reason: 'quiet-hours', playSound: false }; + renderHook(() => useCalendarAlerts()); + await advance(FIRE - Date.now()); + expect(notify.mock.calls[0][0].sound).toBe(true); + expect(sounds.play).not.toHaveBeenCalled(); + }); + + it('plays the sound main asks for', async () => { + notifyResult = { shown: true, playSound: true }; + renderHook(() => useCalendarAlerts()); + await advance(FIRE - Date.now()); + expect(sounds.play).toHaveBeenCalledTimes(1); + }); +}); + +describe('the click on a browser notification', () => { + it('carries the address in the user language', async () => { + const shown = grantNotifications(); + setVisibility('hidden'); + await syncedWith(event()); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + 3000); + expect((shown.mock.calls[0][1].data as { url: string }).url).toMatch(/\/calendar$/); + }); +}); + +describe('without a push channel', () => { + it('syncs every few minutes, so an event made elsewhere still rings', async () => { + useEmailStore.setState({ isPushConnected: false }); + await syncedWith(); + const sync = vi.spyOn(store, 'sync'); + renderHook(() => useStoreReminders()); + await advance(11 * MIN); + expect(sync.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it('leaves it to the push channel when there is one', async () => { + useEmailStore.setState({ isPushConnected: true }); + await syncedWith(); + const sync = vi.spyOn(store, 'sync'); + renderHook(() => useStoreReminders()); + await advance(11 * MIN); + expect(sync).not.toHaveBeenCalled(); + }); +}); diff --git a/hooks/__tests__/use-store-reminders.test.tsx b/hooks/__tests__/use-store-reminders.test.tsx new file mode 100644 index 000000000..229b8ab80 --- /dev/null +++ b/hooks/__tests__/use-store-reminders.test.tsx @@ -0,0 +1,383 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react'; +import { MissedRemindersDialog } from '@/components/calendar/missed-reminders-dialog'; +import { ToastContainer } from '@/components/ui/toast'; +import { useCalendarAlerts } from '../use-calendar-alerts'; +import { useStoreReminders } from '../use-store-reminders'; +import { useMissedReminders, useReminderSource } from '@/lib/calendar-reminders/state'; +import { setAppLockCheck } from '@/lib/desktop/app-lock-state'; +import type { BulwarkDesktopBridge, DesktopOsNotification, DesktopOsNotifyResult } from '@/lib/desktop/types'; +import { calendarHooks, clearAllHooks } from '@/lib/plugin-hooks'; +import { useOfflineStore } from '@/lib/offline/state'; +import { emitStoreEvent } from '@/lib/offline/store-events'; +import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar'; +import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store'; +import { setStoreBackend } from '@/lib/store/client/api'; +import { useAuthStore } from '@/stores/auth-store'; +import { useCalendarNotificationStore } from '@/stores/calendar-notification-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useToastStore } from '@/stores/toast-store'; + +const sounds = vi.hoisted(() => ({ play: vi.fn() })); +vi.mock('@/lib/notification-sound', () => ({ playNotificationSound: sounds.play })); +vi.mock('@/lib/title-alert', () => ({ startTitleAlert: vi.fn() })); +// Real crypto takes real time, and fake time would run ahead of it. +vi.mock('@/lib/pwa-notify/login-fingerprint', () => ({ loginFingerprint: async () => 'fingerprint' })); +vi.mock('@/lib/offline/offline-mode', () => ({ offlineActive: () => true })); +const popout = vi.hoisted(() => ({ value: false })); +vi.mock('@/lib/popout/window-role', async (original) => ({ + ...await original(), + isPopoutWindow: () => popout.value, +})); + +const MIN = 60_000; +const ACCOUNT = 'acc'; +const START = Date.UTC(2026, 10, 10, 9); +const FIRE = START - 15 * MIN; +const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } }; +const LINK = { virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } } }; + +function event(partial: Record = {}) { + return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial }; +} + +let store: InProcessStore; +let server: FakeCalendarJmap; +let notifyResult: DesktopOsNotifyResult; +const notify = vi.fn(async (_notification: DesktopOsNotification) => notifyResult); +const openExternal = vi.fn(); + +function desktop() { + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus: vi.fn() }, shell: { openExternal }, os: { notify } } as unknown as BulwarkDesktopBridge; +} + +function setVisibility(state: 'visible' | 'hidden') { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state }); +} + +function Toasts() { + const toasts = useToastStore((s) => s.toasts); + const removeToast = useToastStore((s) => s.removeToast); + return ; +} + +async function advance(ms: number) { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms); + }); +} + +async function syncedWith(...events: Record[]) { + for (const partial of events) server.addEvent(partial); + await store.sync(ACCOUNT); +} + +const toastTitles = () => useToastStore.getState().toasts.map((toast) => toast.title); + +beforeEach(() => { + vi.useFakeTimers({ now: FIRE - 30 * MIN, toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); + vi.clearAllMocks(); + popout.value = false; + clearAllHooks(); + localStorage.clear(); + setAppLockCheck(null); + setVisibility('visible'); + notifyResult = { shown: true, playSound: false }; + useToastStore.setState({ toasts: [] }); + useReminderSource.setState({ driven: {} }); + useMissedReminders.getState().close(); + useCalendarNotificationStore.getState().clearAll(); + useOfflineStore.setState({ enabled: [ACCOUNT] }); + useAuthStore.setState({ isAuthenticated: true, client: null, activeAccountId: ACCOUNT }); + useCalendarStore.setState({ events: [], calendars: [] }); + useSettingsStore.setState({ + calendarNotificationsEnabled: true, calendarNotificationSound: true, enableCalendarTasks: true, + doNotDisturb: false, visualAlerts: false, timeZone: 'Europe/Berlin', + }); + server = new FakeCalendarJmap('1.0'); + store = new InProcessStore(server, CALENDAR_SESSION); + setStoreBackend(store); +}); + +afterEach(() => { + setStoreBackend(null); + window.bulwarkDesktop = undefined; + setAppLockCheck(null); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('in the desktop app', () => { + beforeEach(desktop); + + it('fires a native notification with the window hidden, at the time, with the network gone', async () => { + await syncedWith(event(LINK)); + await store.setOnline(false); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() - 1000); + expect(notify).not.toHaveBeenCalled(); + await advance(1000); + expect(notify).toHaveBeenCalledTimes(1); + const sent = notify.mock.calls[0][0]; + expect(sent.title).toBe('Standup'); + expect(sent.actions!.map((action) => action.id)).toEqual(['join', 'open', 'snooze-5', 'snooze-10', 'snooze-15']); + expect(toastTitles()).toEqual([]); + }); + + it('shows no Join for an event without a link', async () => { + await syncedWith(event()); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(notify.mock.calls[0][0].actions!.map((action) => action.id)).not.toContain('join'); + }); + + it('plays the sound main asks for, and asks for none in Do not disturb', async () => { + await syncedWith(event()); + notifyResult = { shown: true, playSound: true }; + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(notify.mock.calls[0][0].sound).toBe(true); + expect(sounds.play).toHaveBeenCalledTimes(1); + + useSettingsStore.setState({ doNotDisturb: true }); + await syncedWith(event({ id: 'e2', uid: 'uid-2', start: '2026-11-10T10:01:00' })); + emitStoreEvent({ type: 'changed', account: ACCOUNT, collections: ['CalendarEvent'] }); + await advance(MIN); + expect(notify.mock.calls[1][0].sound).toBe(false); + }); + + it('shows it in the app when the window is in front, with the same choices', async () => { + await syncedWith(event(LINK)); + notifyResult = { shown: false, reason: 'focused', playSound: true }; + render(); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(toastTitles()).toEqual(['Standup']); + expect(sounds.play).toHaveBeenCalledTimes(1); + expect(screen.getAllByRole('button').map((button) => button.textContent).filter(Boolean)).toEqual(['snooze', 'snooze', 'snooze', 'join', 'open']); + fireEvent.click(screen.getByText('join')); + await advance(0); + expect(openExternal).toHaveBeenCalledWith('https://meet.jit.si/standup'); + }); + + it('snoozing from the in-app toast brings it back, and closing it does not', async () => { + await syncedWith(event()); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + render(); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + fireEvent.click(screen.getAllByText('snooze')[0]); + await advance(5 * MIN); + expect(notify).toHaveBeenCalledTimes(2); + + await advance(1000); + const again = useToastStore.getState().toasts.at(-1)!; + act(() => again.onDismiss!()); + await advance(60 * MIN); + expect(notify).toHaveBeenCalledTimes(2); + }); + + it('holds what fires while paused or in quiet hours for the summary afterwards', async () => { + await syncedWith(event()); + notifyResult = { shown: false, reason: 'quiet-hours', playSound: false }; + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(notify).toHaveBeenCalledTimes(1); + expect(toastTitles()).toEqual([]); + + // Still quiet: the summary is tried and put back. + await advance(5 * MIN); + const tried = notify.mock.calls.length; + expect(notify.mock.calls.at(-1)![0].id).toMatch(/^reminder-missed:/); + + notifyResult = { shown: true, playSound: false }; + await advance(5 * MIN); + expect(notify.mock.calls.length).toBe(tried + 1); + expect(notify.mock.calls.at(-1)![0].body).toBe('Standup'); + + await advance(30 * MIN); + expect(notify.mock.calls.length).toBe(tried + 1); + }); + + it('says nothing of the event while locked, and tells afterwards', async () => { + await syncedWith(event()); + let locked = true; + setAppLockCheck(() => locked); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(notify).toHaveBeenCalledTimes(1); + expect(JSON.stringify(notify.mock.calls[0][0])).not.toContain('Standup'); + expect(notify.mock.calls[0][0].actions).toBeUndefined(); + expect(toastTitles()).toEqual([]); + + await advance(10 * MIN); + expect(notify).toHaveBeenCalledTimes(1); + + locked = false; + await advance(5 * MIN); + expect(notify).toHaveBeenCalledTimes(2); + expect(notify.mock.calls[1][0].body).toBe('Standup'); + }); + + it('summarises what was missed once, with a native notification, across a restart', async () => { + await syncedWith(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + const before = renderHook(() => useStoreReminders()); + await advance(0); + before.unmount(); + vi.setSystemTime(FIRE + 3 * 60 * MIN); + + const after = renderHook(() => useStoreReminders()); + await advance(20_000); + expect(notify).toHaveBeenCalledTimes(1); + expect(notify.mock.calls[0][0]).toMatchObject({ title: 'missed_title', body: 'Standup, Review' }); + after.unmount(); + + renderHook(() => useStoreReminders()); + await advance(60 * MIN); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it('summarises in the app when the window is in front, and the dialog lists them', async () => { + await syncedWith(event(LINK), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + notifyResult = { shown: false, reason: 'focused', playSound: false }; + const before = renderHook(() => useStoreReminders()); + await advance(0); + before.unmount(); + vi.setSystemTime(FIRE + 3 * 60 * MIN); + render(<>); + renderHook(() => useStoreReminders()); + await advance(20_000); + expect(toastTitles()).toEqual(['missed_title']); + fireEvent.click(screen.getByText('missed_show')); + await advance(0); + const list = screen.getByTestId('missed-reminders'); + expect(list.textContent).toContain('Standup'); + expect(list.textContent).toContain('Review'); + expect(screen.getAllByText('join')).toHaveLength(1); + fireEvent.click(screen.getByText('done')); + await advance(0); + expect(screen.queryByTestId('missed-reminders')).toBeNull(); + }); + + it('tells plugins, and lets one take the reminder over', async () => { + await syncedWith(event()); + const fired = vi.fn(); + calendarHooks.onCalendarAlert.register('t', fired); + calendarHooks.onBeforeCalendarAlert.register('t', () => false); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(fired).toHaveBeenCalledWith(expect.objectContaining({ id: 'a', eventId: 'e1', eventTitle: 'Standup' })); + expect(notify).not.toHaveBeenCalled(); + }); + + function calendarStoreHas(uid: string, title: string) { + useCalendarStore.setState({ + events: [{ id: 'e1', uid, title, start: '2026-11-10T10:00:00', utcStart: new Date(START).toISOString(), duration: 'PT30M', calendarIds: { c: true }, useDefaultAlerts: false, alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } } }] as never, + }); + } + + it('makes the old path stand down once the store has this account', async () => { + await syncedWith(event()); + // Not the same event as far as the claim can tell: only standing down keeps it quiet. + calendarStoreHas('another-uid', 'Old path'); + renderHook(() => { + useCalendarAlerts(); + useStoreReminders(); + }); + await advance(FIRE - Date.now() + 2 * MIN); + expect(notify).toHaveBeenCalledTimes(1); + expect(toastTitles()).toEqual([]); + }); + + it('lets the two paths tell each other what fired, for the moment the store takes over', async () => { + await syncedWith(event()); + calendarStoreHas('uid-1', 'Standup'); + const { claimKey, claimNow } = await import('@/lib/calendar-reminders/claims'); + // The old path got there just before the store was ready. + expect(claimNow(claimKey(ACCOUNT, 'uid-1', 'a', FIRE), FIRE)).toBe(true); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + 2 * MIN); + expect(notify).not.toHaveBeenCalled(); + }); +}); + +describe('in a browser', () => { + function grantNotifications(showNotification = vi.fn(async () => {})) { + vi.stubGlobal('Notification', Object.assign(vi.fn(), { permission: 'granted' })); + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { getRegistration: async () => ({ showNotification, getNotifications: async () => [] }) }, + }); + return showNotification; + } + + afterEach(() => { + Reflect.deleteProperty(navigator, 'serviceWorker'); + }); + + it('keeps the in-app toast, without desktop buttons', async () => { + await syncedWith(event(LINK)); + render(); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + const toast = useToastStore.getState().toasts[0]; + expect(toast.title).toBe('Standup'); + expect(toast.action).toBeUndefined(); + expect(toast.extraActions).toBeUndefined(); + expect(sounds.play).toHaveBeenCalledTimes(1); + }); + + it('adds a system notification only where permission is granted and the tab is out of view', async () => { + await syncedWith(event()); + const shown = grantNotifications(); + setVisibility('hidden'); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + 3000); + expect(toastTitles()).toEqual(['Standup']); + expect(shown).toHaveBeenCalledTimes(1); + expect(shown).toHaveBeenCalledWith('Standup', expect.objectContaining({ data: expect.objectContaining({ kind: 'calendar-reminder' }), silent: false })); + }); + + it('asks for no permission and shows none when it was not granted', async () => { + await syncedWith(event()); + const request = vi.fn(); + vi.stubGlobal('Notification', Object.assign(vi.fn(), { permission: 'default', requestPermission: request })); + setVisibility('hidden'); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + 3000); + expect(toastTitles()).toEqual(['Standup']); + expect(request).not.toHaveBeenCalled(); + }); + + it('fires in one of two tabs', async () => { + await syncedWith(event()); + renderHook(() => useStoreReminders()); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + MIN); + expect(toastTitles()).toEqual(['Standup']); + }); + + it('closes the toast in this window when another acknowledges the reminder', async () => { + await syncedWith(event()); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now()); + expect(toastTitles()).toEqual(['Standup']); + const key = `${Object.keys(CALENDAR_SESSION.accounts)[0]}:uid-1:a:${FIRE}`; + const other = new BroadcastChannel('bulwark-calendar-reminders'); + other.postMessage({ kind: 'acknowledged', keys: [key] }); + other.close(); + vi.useRealTimers(); + await vi.waitFor(() => expect(toastTitles()).toEqual([])); + }); + + it('gives nothing to a pop-out', async () => { + popout.value = true; + await syncedWith(event()); + const area = vi.spyOn(store, 'area'); + renderHook(() => useStoreReminders()); + await advance(FIRE - Date.now() + MIN); + expect(area).not.toHaveBeenCalled(); + expect(toastTitles()).toEqual([]); + }); +}); diff --git a/hooks/use-app-badge.ts b/hooks/use-app-badge.ts index 927a869b5..d8a33b320 100644 --- a/hooks/use-app-badge.ts +++ b/hooks/use-app-badge.ts @@ -2,6 +2,8 @@ import { useEffect } from 'react'; import { debug } from '@/lib/debug'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { isPopoutWindow } from '@/lib/popout/window-role'; /** * Badges the *installed* PWA's icon — the shortcut Chrome/Edge pin next to @@ -35,6 +37,13 @@ export function useAppBadge(count: number, enabled = true): void { const effectiveCount = enabled ? count : 0; + // The desktop app's own icon. A pop-out has its own account and no say in it. + const desktopOs = getDesktopBridge()?.os; + if (desktopOs) { + if (!isPopoutWindow()) desktopOs.setBadge(effectiveCount); + return; + } + void (async () => { try { if (effectiveCount > 0) { diff --git a/hooks/use-attachment-drag.ts b/hooks/use-attachment-drag.ts index 83cdce627..6136c4453 100644 --- a/hooks/use-attachment-drag.ts +++ b/hooks/use-attachment-drag.ts @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, DragEvent } from "react"; +import { canDragNativeFiles, copyFilesToClipboard, startNativeFileDrag } from "@/lib/desktop/file-drag"; // Chromium ships the `DownloadURL` DataTransfer entry, which the OS reads on // drop to materialize a real file. Firefox and Safari ignore it, so we only @@ -33,6 +34,8 @@ export interface AttachmentDragSource { name: string; type: string; getBlobUrl: () => Promise; + /** The bytes themselves, for the desktop app's native drag. */ + getBlob?: () => Promise; } export interface UseAttachmentDragResult { @@ -40,6 +43,8 @@ export interface UseAttachmentDragResult { onPointerEnter: () => void; onDragStart: (e: DragEvent) => void; onDragEnd: (e: DragEvent) => void; + /** The desktop app: put the file on the clipboard, for pasting into Explorer or Finder. */ + onCopyAsFile?: () => Promise; } const NOOP_HANDLERS: UseAttachmentDragResult = { @@ -56,6 +61,8 @@ export function useAttachmentDrag( const urlRef = useRef(null); const ownedRef = useRef(false); const inFlightRef = useRef | null>(null); + const blobRef = useRef(null); + const nativeDrag = canDragNativeFiles() && !!source.getBlob; useEffect(() => { return () => { @@ -70,6 +77,19 @@ export function useAttachmentDrag( const prefetch = useCallback(() => { if (!enabled) return; + if (nativeDrag) { + if (blobRef.current || inFlightRef.current) return; + inFlightRef.current = source.getBlob!() + .then((blob) => { + blobRef.current = blob; + return null; + }) + .catch(() => null) + .finally(() => { + inFlightRef.current = null; + }); + return; + } if (urlRef.current || inFlightRef.current) return; inFlightRef.current = source .getBlobUrl() @@ -87,7 +107,7 @@ export function useAttachmentDrag( .finally(() => { inFlightRef.current = null; }); - }, [enabled, source]); + }, [enabled, source, nativeDrag]); const handleDragStart = useCallback( (e: DragEvent) => { @@ -95,6 +115,15 @@ export function useAttachmentDrag( const name = source.name || "download"; const type = source.type || "application/octet-stream"; + // In the desktop app the OS's own drag, with the real file on disk first. The bytes + // aren't here yet: fetch them for the next try, as below. + if (nativeDrag) { + e.preventDefault(); + if (blobRef.current) void startNativeFileDrag([{ name, blob: blobRef.current }]); + else prefetch(); + return; + } + if (!url) { // Blob isn't materialized yet. Kick off the fetch so the next attempt // works, but cancel this drag so the user doesn't get a silent failure @@ -111,7 +140,7 @@ export function useAttachmentDrag( e.dataTransfer.setData("DownloadURL", `${type}:${name}:${url}`); e.dataTransfer.effectAllowed = "copyMove"; }, - [source.name, source.type, prefetch], + [source.name, source.type, prefetch, nativeDrag], ); const handleDragEnd = useCallback(() => { @@ -125,6 +154,11 @@ export function useAttachmentDrag( } }, []); + const copyAsFile = useCallback(async () => { + const blob = blobRef.current ?? await source.getBlob?.(); + return blob ? copyFilesToClipboard([{ name: source.name || "download", blob }]) : false; + }, [source]); + if (!enabled) return NOOP_HANDLERS; return { @@ -132,5 +166,6 @@ export function useAttachmentDrag( onPointerEnter: prefetch, onDragStart: handleDragStart, onDragEnd: handleDragEnd, + ...(nativeDrag ? { onCopyAsFile: copyAsFile } : {}), }; } diff --git a/hooks/use-calendar-alerts.ts b/hooks/use-calendar-alerts.ts index 87fbd0a66..b4757dc59 100644 --- a/hooks/use-calendar-alerts.ts +++ b/hooks/use-calendar-alerts.ts @@ -2,20 +2,29 @@ import { useEffect, useRef, useCallback } from 'react'; import { useTranslations, useLocale } from 'next-intl'; -import { parseISO } from 'date-fns'; +import { format, parseISO } from 'date-fns'; import { isPopoutWindow } from '@/lib/popout/window-role'; import { useAuthStore } from '@/stores/auth-store'; import { useCalendarStore } from '@/stores/calendar-store'; +import { usePolicyStore } from '@/stores/policy-store'; import { useSettingsStore } from '@/stores/settings-store'; import { useTaskStore } from '@/stores/task-store'; import { useCalendarNotificationStore } from '@/stores/calendar-notification-store'; import { useToastStore } from '@/stores/toast-store'; import { getPendingAlerts, getPendingTaskAlerts, getNextAlertFireTime, buildAlertKey } from '@/lib/calendar-alerts'; +import { claimAlert, claimKey } from '@/lib/calendar-reminders/claims'; +import { useReminderSource } from '@/lib/calendar-reminders/state'; +import { showSystemReminder } from '@/lib/calendar-reminders/system-notification'; +import { notifyWithoutStore } from '@/lib/desktop/reminder-notifications'; +import { expandRecurringEvents } from '@/lib/recurrence-expansion'; import { playNotificationSound } from '@/lib/notification-sound'; import { lastingToastDuration } from '@/lib/toast-duration'; import { startTitleAlert } from '@/lib/title-alert'; import { getPathPrefix } from '@/lib/browser-navigation'; import { goInApp } from '@/lib/plugin-sandbox/page-navigation'; +import { isAppLocked } from '@/lib/desktop/app-lock-state'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { contentHiddenFor } from '@/lib/desktop/mail-notifications'; import type { CalendarEvent } from '@/lib/jmap/types'; import type { CalendarAlertView } from '@/lib/plugin-types'; import { calendarHooks } from '@/lib/plugin-hooks'; @@ -24,6 +33,8 @@ import { changesAnyType, subscribePushStateChanges } from '@/lib/push-events'; const CHECK_INTERVAL_MS = 60 * 1000; const PROACTIVE_FETCH_HOURS = 24; const PROACTIVE_THROTTLE_MS = CHECK_INTERVAL_MS * 5; +// Wall clocks: an occurrence's can be a day off the instant it's at. +const EXPAND_MARGIN_MS = 27 * 60 * 60 * 1000; // Longest single wait for the next reminder; the periodic check covers the rest. const MAX_WAKE_DELAY_MS = 6 * 60 * 60 * 1000; /** How long a reminder stays at the shortest "Notification duration". */ @@ -43,7 +54,7 @@ export function reminderToastDuration(): number { * Whether a reminder plays its sound. Do not disturb keeps reminders on * screen (they were asked for) but silent. */ -function reminderSoundOn(soundSetting: boolean): boolean { +export function reminderSoundOn(soundSetting: boolean): boolean { return soundSetting && !useSettingsStore.getState().doNotDisturb; } @@ -52,7 +63,7 @@ function reminderSoundOn(soundSetting: boolean): boolean { * is in the background. Reminders were asked for, so Do not disturb keeps * this too. */ -function reminderTitleAlert(text: string): void { +export function reminderTitleAlert(text: string): void { if (useSettingsStore.getState().visualAlerts) startTitleAlert(text); } @@ -62,7 +73,7 @@ function reminderTitleAlert(text: string): void { * itself does. onCalendarAlert fires either way. With no plugin asked, the * reminder is shown at once. */ -function remind(view: CalendarAlertView, show: () => void): void { +export function remind(view: CalendarAlertView, show: () => void): void { void calendarHooks.onCalendarAlert.emit(view); if (calendarHooks.onBeforeCalendarAlert.size === 0) { show(); @@ -74,11 +85,14 @@ function remind(view: CalendarAlertView, show: () => void): void { } export function useCalendarAlerts() { - const { isAuthenticated: signedIn, client } = useAuthStore(); + const { isAuthenticated: signedIn, client, activeAccountId } = useAuthStore(); + // Reminders of an account with a local store come from there: useStoreReminders. + const fromStore = useReminderSource((s) => !!activeAccountId && !!s.driven[activeAccountId]); + const calendarAllowed = usePolicyStore((s) => s.isFeatureEnabled('calendarEnabled')); // The main window rings; a pop-out would ring the same alert again. - const isAuthenticated = signedIn && !isPopoutWindow(); + const isAuthenticated = signedIn && !isPopoutWindow() && !fromStore && calendarAllowed; const { events, calendars, supportsCalendar } = useCalendarStore(); - const { calendarNotificationsEnabled, calendarNotificationSound, enableCalendarTasks, notificationSoundChoice } = useSettingsStore(); + const { calendarNotificationsEnabled, calendarNotificationSound, enableCalendarTasks } = useSettingsStore(); const { tasks: storeTasks } = useTaskStore(); const { acknowledgedAlerts, acknowledgeAlert, cleanupStaleAlerts } = useCalendarNotificationStore(); const addToast = useToastStore((s) => s.addToast); @@ -92,12 +106,35 @@ export function useCalendarAlerts() { const wakeTimerRef = useRef | null>(null); const checkAlertsRef = useRef<() => void>(() => {}); + // Beside the toast: the desktop app's native notification, a browser's system one. + const tellOutsideTheWindow = useCallback((key: string, fireTimeMs: number, title: string, body: string, wantsSound: boolean) => { + const sound = reminderSoundOn(wantsSound); + const { notificationSoundChoice: choice } = useSettingsStore.getState(); + if (getDesktopBridge()?.os) { + // Main knows about pauses and quiet hours, and says whether a sound is in order. + void notifyWithoutStore({ key, fireTimeMs, title, body, sound }, { generic: t('alert_title') }, activeAccountId).then((result) => { + if (result?.playSound) playNotificationSound(choice); + }); + return; + } + if (sound) playNotificationSound(choice); + void showSystemReminder({ + key, + title, + body, + silent: !sound, + hidden: contentHiddenFor(activeAccountId) ? { title: t('alert_title') } : undefined, + }, `${getPathPrefix(locale)}/${locale}/calendar`); + }, [t, locale, activeAccountId]); + const checkAlerts = useCallback(() => { if (wakeTimerRef.current) { clearTimeout(wakeTimerRef.current); wakeTimerRef.current = null; } if (!calendarNotificationsEnabled || !isAuthenticated) return; + // Nothing may show now, so nothing is used up: the periodic check comes back to it. + if (isAppLocked()) return; try { const now = Date.now(); @@ -128,27 +165,29 @@ export function useCalendarAlerts() { ? `${timeLabel} · ${alert.calendarName}` : timeLabel; - remind({ - id: alert.alertId, - eventId: alert.eventId, - eventTitle: alert.event.title || '', - triggerTime: new Date(alert.fireTimeMs).toISOString(), - }, () => { - if (reminderSoundOn(calendarNotificationSound)) { - playNotificationSound(notificationSoundChoice); - } - reminderTitleAlert(t('title_alert', { title: alert.event.title || t('alert_title') })); - addToast({ - type: 'info', - title: alert.event.title || t('alert_title'), - message, - duration: reminderToastDuration(), - onClick: () => { - goInApp('/calendar', `${getPathPrefix(locale)}/${locale}/calendar`); - }, - onDismiss: () => { - void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId }); - }, + const uid = alert.event.uid || alert.event.originalId || alert.eventId; + claimAlert(claimKey(activeAccountId, uid, alert.alertId, alert.fireTimeMs), alert.fireTimeMs, () => { + remind({ + id: alert.alertId, + eventId: alert.eventId, + eventTitle: alert.event.title || '', + triggerTime: new Date(alert.fireTimeMs).toISOString(), + }, () => { + const title = alert.event.title || t('alert_title'); + reminderTitleAlert(t('title_alert', { title })); + addToast({ + type: 'info', + title, + message, + duration: reminderToastDuration(), + onClick: () => { + goInApp('/calendar', `${getPathPrefix(locale)}/${locale}/calendar`); + }, + onDismiss: () => { + void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: alert.alertId, eventId: alert.eventId }); + }, + }); + tellOutsideTheWindow(key, alert.fireTimeMs, title, message, calendarNotificationSound); }); }); } @@ -167,27 +206,29 @@ export function useCalendarAlerts() { ? `${t('task_due')} · ${taskAlert.calendarName}` : t('task_due'); - remind({ - id: taskAlert.alertId, - taskId: taskAlert.taskId, - taskTitle: taskAlert.task.title || '', - triggerTime: new Date(taskAlert.fireTimeMs).toISOString(), - }, () => { - if (reminderSoundOn(calendarNotificationSound)) { - playNotificationSound(notificationSoundChoice); - } - reminderTitleAlert(t('title_alert', { title: taskAlert.task.title || t('alert_title') })); - addToast({ - type: 'info', - title: taskAlert.task.title || t('alert_title'), - message: taskMsg, - duration: reminderToastDuration(), - onClick: () => { - goInApp('/calendar', `${getPathPrefix(locale)}/${locale}/calendar`); - }, - onDismiss: () => { - void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: taskAlert.alertId, taskId: taskAlert.taskId }); - }, + const uid = taskAlert.task.uid || taskAlert.taskId; + claimAlert(claimKey(activeAccountId, uid, taskAlert.alertId, taskAlert.fireTimeMs), taskAlert.fireTimeMs, () => { + remind({ + id: taskAlert.alertId, + taskId: taskAlert.taskId, + taskTitle: taskAlert.task.title || '', + triggerTime: new Date(taskAlert.fireTimeMs).toISOString(), + }, () => { + const title = taskAlert.task.title || t('alert_title'); + reminderTitleAlert(t('title_alert', { title })); + addToast({ + type: 'info', + title, + message: taskMsg, + duration: reminderToastDuration(), + onClick: () => { + goInApp('/calendar', `${getPathPrefix(locale)}/${locale}/calendar`); + }, + onDismiss: () => { + void calendarHooks.onCalendarAlertAcknowledge.emit({ alertId: taskAlert.alertId, taskId: taskAlert.taskId }); + }, + }); + tellOutsideTheWindow(key, taskAlert.fireTimeMs, title, taskMsg, calendarNotificationSound); }); }); } @@ -196,9 +237,9 @@ export function useCalendarAlerts() { // Silently ignore alert evaluation errors } }, [ - calendarNotificationsEnabled, calendarNotificationSound, notificationSoundChoice, - isAuthenticated, events, calendars, acknowledgedAlerts, - acknowledgeAlert, addToast, t, locale, enableCalendarTasks, storeTasks, + calendarNotificationsEnabled, calendarNotificationSound, + isAuthenticated, activeAccountId, events, calendars, acknowledgedAlerts, + acknowledgeAlert, addToast, t, locale, enableCalendarTasks, storeTasks, tellOutsideTheWindow, ]); checkAlertsRef.current = checkAlerts; @@ -212,7 +253,10 @@ export function useCalendarAlerts() { const start = new Date(now - 10 * 60 * 1000).toISOString(); const end = new Date(now + PROACTIVE_FETCH_HOURS * 60 * 60 * 1000).toISOString(); const fetched = await client.queryCalendarEvents({ after: start, before: end }); - proactiveEventsRef.current = fetched; + // Without synthetic ids the server answers with a series' master, whose start can + // be years ago: only its occurrences have a fire time. Passes real occurrences through. + const stamp = (ms: number) => format(new Date(ms), "yyyy-MM-dd'T'HH:mm:ss"); + proactiveEventsRef.current = expandRecurringEvents(fetched, stamp(now - EXPAND_MARGIN_MS), stamp(now + PROACTIVE_FETCH_HOURS * 60 * 60 * 1000 + EXPAND_MARGIN_MS)); lastProactiveFetchRef.current = Date.now(); checkAlertsRef.current(); } catch { diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts index 25450a031..6debff52c 100644 --- a/hooks/use-email-drag.ts +++ b/hooks/use-email-drag.ts @@ -9,6 +9,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useUIStore } from "@/stores/ui-store"; import { isDragOutSupported } from "@/hooks/use-attachment-drag"; +import { canDragNativeFiles, forgetDragBlob, keepDragBlob, keptDragBlob, startNativeFileDrag } from "@/lib/desktop/file-drag"; import { canExport } from "@/lib/device-policy"; import { appUrl, buildMailPath } from "@/lib/deep-links"; import { @@ -103,7 +104,10 @@ function selectionKey(emails: RoutedEmail[], bundleOptions: EmailFilenameOptions function revokeLater(url: string): void { // Chromium may read the URL asynchronously after the drop completes. - setTimeout(() => URL.revokeObjectURL(url), 60_000); + setTimeout(() => { + URL.revokeObjectURL(url); + forgetDragBlob(url); + }, 60_000); } async function buildEmailZip(emails: RoutedEmail[]): Promise { @@ -126,7 +130,7 @@ async function buildEmailZip(emails: RoutedEmail[]): Promise { }), ); const zipBlob = await zip.generateAsync({ type: "blob", mimeType: "application/zip" }); - return URL.createObjectURL(zipBlob); + return keepDragBlob(URL.createObjectURL(zipBlob), zipBlob); } function prefetchEmailBundle( @@ -224,8 +228,11 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD if (previous?.url) revokeLater(previous.url); const entry: BundleEntry = { key: route.key, name: route.name, url: null, promise: null }; singleRef.current = entry; - entry.promise = route.client - .fetchBlobAsObjectUrl(target.blobId, route.name, "message/rfc822", route.accountId) + // The desktop app's native drag needs the bytes as well as the address. + const fetched = canDragNativeFiles() + ? route.client.fetchBlob(target.blobId, route.name, "message/rfc822", route.accountId).then((blob) => keepDragBlob(URL.createObjectURL(blob), blob)) + : route.client.fetchBlobAsObjectUrl(target.blobId, route.name, "message/rfc822", route.accountId); + entry.promise = fetched .then((url) => { if (url) { if (singleRef.current === entry && routeEmail(target, filenameOptions).key === entry.key) entry.url = url; @@ -295,6 +302,14 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const url = singleRef.current?.key === route.key ? singleRef.current.url : null; if (url) { const name = emailExportFilename(emailsToDrag[0], filenameOptions); + // Desktop app, Alt held: the OS's own drag with the real file. A plain drag has + // to stay the page's, because it also moves the message to a folder. + const blob = e.altKey ? keptDragBlob(url) : undefined; + if (blob) { + e.preventDefault(); + void startNativeFileDrag([{ name, blob }]); + return; + } // `DownloadURL` format: ::. Chromium expects // the filename raw - URL-encoding it ends up literally on disk // (e.g. `%20` instead of a space). The sanitiser already removed @@ -313,6 +328,12 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD const routed = emailsToDrag.map((em) => routeEmail(em, filenameOptions)); const ready = getReadyBundle(routed, bundleOptions); if (ready) { + const blob = e.altKey ? keptDragBlob(ready.url) : undefined; + if (blob) { + e.preventDefault(); + void startNativeFileDrag([{ name: ready.name, blob }]); + return; + } e.dataTransfer.setData( "DownloadURL", `application/zip:${ready.name}:${ready.url}`, diff --git a/hooks/use-new-mail-alerts.ts b/hooks/use-new-mail-alerts.ts index 0b118372c..b2fd6801f 100644 --- a/hooks/use-new-mail-alerts.ts +++ b/hooks/use-new-mail-alerts.ts @@ -8,6 +8,7 @@ import { playNotificationSound } from "@/lib/notification-sound"; import { announce } from "@/lib/announce"; import { startTitleAlert } from "@/lib/title-alert"; import { debug } from "@/lib/debug"; +import { desktopPlaysMailSound } from "@/lib/desktop/mail-notifications"; /** * Mail that arrives within this time is told about once ("3 new messages"), @@ -51,7 +52,8 @@ export function useNewMailAlerts(): void { const s = useSettingsStore.getState(); if (s.doNotDisturb) return; if (batchTimer === null) { - if (s.emailNotificationsEnabled && s.emailNotificationSound) { + // The desktop app sounds it with the notification, in Do Not Disturb not at all. + if (s.emailNotificationsEnabled && s.emailNotificationSound && !desktopPlaysMailSound()) { playNotificationSound(s.notificationSoundChoice); } batchTimer = setTimeout(flush, NEW_MAIL_BATCH_MS); diff --git a/hooks/use-policy-refresh.ts b/hooks/use-policy-refresh.ts index 3ae942946..f7115e422 100644 --- a/hooks/use-policy-refresh.ts +++ b/hooks/use-policy-refresh.ts @@ -1,6 +1,7 @@ 'use client'; import { useEffect } from 'react'; +import { isDesktopApp } from '@/lib/desktop/bridge'; import { usePolicyStore } from '@/stores/policy-store'; /** How often an open, visible tab fetches the policy again. */ @@ -13,7 +14,9 @@ let mounted = 0; let interval: ReturnType | null = null; function refresh(force = false): void { - if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return; + // A browser tab out of view waits until it's back. The desktop app in the tray is + // out of view for days and still has to hear about a wipe or a changed policy. + if (typeof document !== 'undefined' && document.visibilityState === 'hidden' && !isDesktopApp()) return; const now = Date.now(); if (!force && now - lastRefresh < FOCUS_THROTTLE_MS) return; const state = usePolicyStore.getState(); @@ -27,7 +30,7 @@ const onVisible = () => refresh(); /** * Fetches the policy again when the tab comes back into view and every 15 - * minutes while it is visible, so an announcement (or any other change the + * minutes while it is visible (in the desktop app, also while hidden), so an announcement (or any other change the * admin saves) reaches tabs that stay open. One timer for the page however * many components use it. */ diff --git a/hooks/use-store-reminders.ts b/hooks/use-store-reminders.ts new file mode 100644 index 000000000..3c5d0c437 --- /dev/null +++ b/hooks/use-store-reminders.ts @@ -0,0 +1,326 @@ +"use client"; + +import { useEffect, useRef } from 'react'; +import { useLocale, useTranslations } from 'next-intl'; +import { remind, reminderSoundOn, reminderTitleAlert, reminderToastDuration } from '@/hooks/use-calendar-alerts'; +import { getPathPrefix } from '@/lib/browser-navigation'; +import { onAcknowledged } from '@/lib/calendar-reminders/channel'; +import { claimKey, firedWithoutStore, noteStoreFired } from '@/lib/calendar-reminders/claims'; +import { createReminderRunner } from '@/lib/calendar-reminders/runner'; +import { reminderTimeZone, setReminderPoke, useMissedReminders, useReminderSource } from '@/lib/calendar-reminders/state'; +import { closeSystemReminders, showSystemReminder } from '@/lib/calendar-reminders/system-notification'; +import { isAppLocked } from '@/lib/desktop/app-lock-state'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { contentHiddenFor } from '@/lib/desktop/mail-notifications'; +import { + buildMissedNotification, + buildReminderNotification, + dismissReminders, + joinLabel, + joinMeeting, + missedBody, + SNOOZE_MINUTES, + snoozeReminder, + type ReminderLabels, +} from '@/lib/desktop/reminder-notifications'; +import { addWakeHandler } from '@/lib/desktop/system-events'; +import { playNotificationSound } from '@/lib/notification-sound'; +import { offlineActive } from '@/lib/offline/offline-mode'; +import { visibleAccounts, useOfflineStore } from '@/lib/offline/state'; +import { addStoreEventListener } from '@/lib/offline/store-events'; +import { calendarHooks } from '@/lib/plugin-hooks'; +import type { CalendarAlertView } from '@/lib/plugin-types'; +import { isPopoutWindow } from '@/lib/popout/window-role'; +import { getStore } from '@/lib/store/client/api'; +import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { useToastStore } from '@/stores/toast-store'; + +/** Under the store's LEASE_MS, with room for a hidden tab's slow timers. */ +const LOAN_RENEW_MS = 60_000; + +function viewOf(alert: ReminderAlert): CalendarAlertView { + const triggerTime = new Date(alert.fireTimeMs).toISOString(); + return alert.kind === 'task' + ? { id: alert.alertId, taskId: alert.baseEventId, taskTitle: alert.title, triggerTime } + : { id: alert.alertId, eventId: alert.baseEventId, eventTitle: alert.title, triggerTime }; +} + +function acknowledgeView(alert: ReminderAlert) { + return alert.kind === 'task' + ? { alertId: alert.alertId, taskId: alert.baseEventId } + : { alertId: alert.alertId, eventId: alert.baseEventId }; +} + +/** + * Reminders from the local store: on time with no network, and in the desktop app with + * the window hidden. For accounts without a store useCalendarAlerts carries on as before. + */ +export function useStoreReminders(): void { + const signedIn = useAuthStore((s) => s.isAuthenticated); + const t = useTranslations('desktopReminders'); + const tAlert = useTranslations('calendar.notifications'); + const locale = useLocale(); + const addToast = useToastStore((s) => s.addToast); + const removeToast = useToastStore((s) => s.removeToast); + const enabledKey = useOfflineStore((s) => s.enabled.join('\n')); + + const labels: ReminderLabels = { + generic: t('generic_title'), + untitled: tAlert('alert_title'), + when: (alert) => { + const minutes = alert.startMs === null ? 0 : Math.round((alert.startMs - Date.now()) / 60_000); + const time = alert.kind === 'task' + ? tAlert('task_due') + : minutes <= 0 ? tAlert('alert_now') : tAlert('alert_in_minutes', { count: minutes }); + return alert.calendarName ? `${time} · ${alert.calendarName}` : time; + }, + snooze: (minutes) => t('snooze', { count: minutes }), + snoozeShort: (minutes) => t('snooze_short', { count: minutes }), + open: t('open'), + join: t('join'), + joinHost: (host) => t('join_host', { host }), + missedTitle: (count) => t('missed_title', { count }), + missedMore: (count) => t('missed_more', { count }), + }; + const labelsRef = useRef(labels); + labelsRef.current = labels; + const calendarUrl = `${getPathPrefix(locale)}/${locale}/calendar`; + const calendarUrlRef = useRef(calendarUrl); + calendarUrlRef.current = calendarUrl; + const runnerRef = useRef | null>(null); + + useEffect(() => { + if (!signedIn || isPopoutWindow()) return; + const toastsByKey = new Map(); + const loans = new Set>(); + + const inAppReminder = (account: string, alert: ReminderAlert) => { + const current = labelsRef.current; + const desktop = !!getDesktopBridge()?.os; + // Set when a button says what happens next, so closing the toast doesn't undo it. + let handled = false; + const snoozeAction = (minutes: number) => ({ + label: current.snooze(minutes), + onClick: () => { + handled = true; + void snoozeReminder(account, alert.key, minutes).catch(() => {}); + }, + }); + const [five, ten, fifteen] = SNOOZE_MINUTES.map(snoozeAction); + const id = addToast({ + type: 'info', + title: alert.title || current.untitled, + message: current.when(alert), + duration: reminderToastDuration(), + onClick: () => { + window.location.href = calendarUrlRef.current; + }, + onDismiss: () => { + toastsByKey.delete(alert.key); + void calendarHooks.onCalendarAlertAcknowledge.emit(acknowledgeView(alert)); + if (!handled) dismissReminders(account, [alert.key]); + }, + ...(desktop ? { + tertiaryAction: five, + secondaryAction: ten, + action: fifteen, + extraActions: [ + ...(alert.joinUrl ? [{ + label: joinLabel(alert, current), + onClick: () => { + handled = true; + // Nothing opened, say a plugin stopped it: the reminder stands. + void joinMeeting(alert.joinUrl!).then((opened) => { + if (opened) dismissReminders(account, [alert.key]); + }); + }, + }] : []), + { label: current.open, onClick: () => { window.location.href = calendarUrlRef.current; } }, + ], + } : {}), + }); + toastsByKey.set(alert.key, id); + }; + + const showReminder = async (account: string, alert: ReminderAlert) => { + const settings = useSettingsStore.getState(); + const sound = reminderSoundOn(settings.calendarNotificationSound); + const os = getDesktopBridge()?.os; + const hold = () => getStore().area(account, 'reminders.hold', { keys: [alert.key] }).catch(() => {}); + if (os) { + const locked = isAppLocked(); + const result = await os.notify(await buildReminderNotification(account, alert, labelsRef.current, sound)).catch(() => null); + // Paused, quiet hours, a toast that failed, or nothing may be said now: the summary says it later. + if (!result || locked || result.reason === 'paused' || result.reason === 'quiet-hours' || result.reason === 'failed') { + await hold(); + return; + } + if (result.reason === 'duplicate') return; + if (result.playSound) playNotificationSound(settings.notificationSoundChoice); + if (result.shown) return; + inAppReminder(account, alert); + return; + } + if (sound) playNotificationSound(settings.notificationSoundChoice); + reminderTitleAlert(tAlert('title_alert', { title: alert.title || labelsRef.current.untitled })); + inAppReminder(account, alert); + void showSystemReminder({ + key: alert.key, + title: alert.title || labelsRef.current.untitled, + body: labelsRef.current.when(alert), + silent: !sound, + hidden: contentHiddenFor(account) ? { title: labelsRef.current.generic } : undefined, + }, calendarUrlRef.current); + }; + + const showMissed = async (account: string) => { + if (isAppLocked()) return; + const timeZone = reminderTimeZone(useSettingsStore.getState().timeZone); + const taken = await getStore().area(account, 'reminders.takeMissed', { timeZone }); + if (!taken) return; + // What the path without a store already rang, before the store took over, was seen. + const rung = taken.alerts.filter((alert) => firedWithoutStore(claimKey(account, alert.uid, alert.alertId, alert.fireTimeMs))); + if (rung.length > 0) await getStore().area(account, 'reminders.dismiss', { keys: rung.map((alert) => alert.key) }); + const batch: MissedBatch = { batch: taken.batch, alerts: taken.alerts.filter((alert) => !rung.includes(alert)) }; + if (batch.alerts.length === 0) return; + const current = labelsRef.current; + const os = getDesktopBridge()?.os; + if (os) { + const release = () => getStore().area(account, 'reminders.releaseMissed', { batch: batch.batch }); + const result = await os.notify(await buildMissedNotification(account, batch, current)).catch(() => null); + // Told nobody, so it has to wait for a time when it can. + if (!result || result.reason === 'paused' || result.reason === 'quiet-hours' || result.reason === 'duplicate' || result.reason === 'failed') { + await release(); + return; + } + if (result.shown) { + await getStore().area(account, 'reminders.settleMissed', { batch: batch.batch }); + return; + } + } + const keys = batch.alerts.map((alert) => alert.key); + let opened = false; + const body = missedBody(batch.alerts, current); + const id = addToast({ + type: 'info', + title: current.missedTitle(batch.alerts.length), + message: body, + duration: 0, + action: { + label: t('missed_show'), + onClick: () => { + opened = true; + useMissedReminders.getState().show(account, batch.alerts); + }, + }, + onDismiss: () => { + for (const key of keys) toastsByKey.delete(key); + if (!opened) dismissReminders(account, keys); + }, + }); + for (const key of keys) toastsByKey.set(key, id); + keepOnLoan(account, batch.batch, id, keys); + if (!os) { + void showSystemReminder({ + key: `missed:${batch.batch}`, + title: current.missedTitle(batch.alerts.length), + body, + silent: true, + hidden: contentHiddenFor(account) ? { title: current.missedTitle(batch.alerts.length) } : undefined, + }, calendarUrlRef.current); + } + }; + + // Stays with this window while it's on screen here. A reload loses the toast, the + // renewals stop, and the store hands the summary out again. + const keepOnLoan = (account: string, batch: number, toastId: string, keys: string[]) => { + const onScreen = () => useToastStore.getState().toasts.some((toast) => toast.id === toastId) + || useMissedReminders.getState().alerts.some((alert) => keys.includes(alert.key)); + const loan = setInterval(() => { + if (!onScreen()) { + clearInterval(loan); + loans.delete(loan); + return; + } + void getStore().area(account, 'reminders.renewMissed', { batch }).catch(() => {}); + }, LOAN_RENEW_MS); + loans.add(loan); + }; + + const runner = createReminderRunner({ + accounts: () => visibleAccounts().filter((account) => offlineActive(account)), + store: () => getStore(), + params: () => { + const settings = useSettingsStore.getState(); + return { + timeZone: reminderTimeZone(settings.timeZone), + tasks: settings.enableCalendarTasks, + muted: !settings.calendarNotificationsEnabled || !usePolicyStore.getState().isFeatureEnabled('calendarEnabled'), + }; + }, + // Only this account's client has a stream the page can see. For the others, look. + needsPolling: (account) => !(account === useAuthStore.getState().activeAccountId && useEmailStore.getState().isPushConnected), + hidden: () => document.visibilityState === 'hidden', + offline: () => navigator.onLine === false, + onDue: (account, alerts) => { + for (const alert of alerts) { + // The other path may have got there first, at a handover. + if (!noteStoreFired(claimKey(account, alert.uid, alert.alertId, alert.fireTimeMs), alert.fireTimeMs)) continue; + remind(viewOf(alert), () => { + void showReminder(account, alert).catch((error) => { + console.error('Failed to show a reminder:', error); + }); + }); + } + }, + onMissed: showMissed, + onSource: (account, driven) => useReminderSource.getState().setDriven(account, driven), + }); + runnerRef.current = runner; + setReminderPoke(() => runner.poke()); + runner.start(); + + const stopEvents = addStoreEventListener((event) => { + if (event.type === 'synced') runner.synced(event.account); + else if (event.type === 'changed' && event.collections.some((name) => name === 'CalendarEvent' || name === 'Calendar')) runner.poke(); + }); + const stopWake = addWakeHandler(() => runner.wake()); + const onOnline = () => runner.wake(); + const onVisible = () => { + if (document.visibilityState === 'visible') runner.poke(); + }; + window.addEventListener('online', onOnline); + document.addEventListener('visibilitychange', onVisible); + const stopAcks = onAcknowledged((keys) => { + for (const key of keys) { + const id = toastsByKey.get(key); + if (id) removeToast(id); + toastsByKey.delete(key); + } + void closeSystemReminders(keys); + }); + + return () => { + runner.stop(); + for (const loan of loans) clearInterval(loan); + runnerRef.current = null; + setReminderPoke(null); + stopEvents(); + stopWake(); + stopAcks(); + window.removeEventListener('online', onOnline); + document.removeEventListener('visibilitychange', onVisible); + }; + // Labels go through a ref: a language change mustn't restart the check. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [signedIn]); + + // An account just turned on or off. + useEffect(() => { + runnerRef.current?.poke(); + }, [enabledKey]); +} diff --git a/instrumentation.node.ts b/instrumentation.node.ts index a1f36ff11..971184ac8 100644 --- a/instrumentation.node.ts +++ b/instrumentation.node.ts @@ -134,6 +134,9 @@ migrateLegacyAdminLayout() { name: "telemetry-errors", start: instantErrors.startInstantErrors, stop: instantErrors.stopInstantErrors }, { name: "version-check", start: versionCheck.startScheduler, stop: versionCheck.stopScheduler }, ]); + if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === "1") { + (await import("./lib/desktop/server-links")).installDesktopServerLinks(); + } }) .catch((err) => { logger.warn("Admin dashboard init skipped", { error: err instanceof Error ? err.message : String(err) }); diff --git a/integration/tests/24-do-export.spec.ts b/integration/tests/24-do-export.spec.ts new file mode 100644 index 000000000..682ad72fe --- /dev/null +++ b/integration/tests/24-do-export.spec.ts @@ -0,0 +1,428 @@ +/** + * Export of the local store, through the real UI, as dso-export, against a VM 106 + * Stalwart: a folder written with the browser offline is the bytes the server has, + * the private files never hold readable mail, what has no original here is counted + * and can be tried again online, and "No export" holds. + * + * An account of its own: other specs running at the same time work in the ent-* + * accounts, and folders of this one went missing there mid-run. Check or create it + * on the VM first (~/desktop-offline/dso_export_provision.py), then run + * desktop-offline-rig/run-spec.sh with IT_DSO_PASSWORD from the vault item + * "Stalwart test – dso-* throwaway accounts". Works in its own folder and deletes + * it with its mail at the end. + */ +import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { adminContext, contextOptions, requireEntEnv, shot, withPolicy } from './helpers/ent'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; +import { JMAP_URL } from './helpers/config'; +import { JmapClient } from './helpers/jmap'; +import { listZipEntries, readZipEntry } from '../../lib/mail-import/zip-reader'; +import { MboxSplitter } from '../../lib/mail-import/mbox'; +import { withoutStateHeaders } from '../../lib/mail-import/mbox-status'; + +requireEntEnv(); +test.describe.configure({ mode: 'serial' }); +test.setTimeout(300_000); + +const USER = 'dso-export@example.org'; +const PASSWORD = process.env.IT_DSO_PASSWORD ?? ''; +if (!PASSWORD) throw new Error('Set IT_DSO_PASSWORD, and check the account on the VM first: see the top of this file'); +const STAMP = `D10-${Date.now().toString(36)}`; +const FOLDER = `${STAMP}-folder`; +const SECRET = `Secret${STAMP.replace(/\W/g, '')}`; +const COUNT = 3; +const DEVICES_OFF = { noDownloads: false, noExport: false, noPrint: false, noInstallPrompt: false, pushWithoutContent: false, watermark: false, noOffline: false }; + +const auth = () => 'Basic ' + Buffer.from(`${USER}:${PASSWORD}`).toString('base64'); +let jmap: Promise | null = null; +const account = () => (jmap ??= JmapClient.connect(USER, PASSWORD)); + +/** Bytes a rebuilt message wouldn't have: a Latin-1 byte, a bare LF, odd spacing, signature-like headers, mbox traps. */ +function rawMessage(n: number): Buffer { + return Buffer.concat([ + Buffer.from(`From: Seed \r\nTo: ${USER}\r\nSubject: ${SECRET} subject ${n} \r\nDate: Mon, 1 Jan 2018 08:00:0${n} +0000\r\nMessage-ID: <${STAMP}-${n}@example.org>\r\nDKIM-Signature: v=1; a=rsa-sha256; b=abc${n}\r\nContent-Type: text/plain; charset=iso-8859-1\r\n\r\n`), + Buffer.from([0xe4, 0x0a]), + Buffer.from(`From the start of a line ${n}\r\n>From quoted once\n${SECRET} body ${n}\r\n`), + ]); +} + +let mailbox = ''; +const blobIds: string[] = []; +const originals: Buffer[] = []; + +test.beforeAll(async () => { + const client = await account(); + mailbox = await client.createMailbox(FOLDER); + for (let n = 1; n <= COUNT; n += 1) { + const up = await fetch(`${JMAP_URL}/jmap/upload/${client.accountId}/`, { method: 'POST', headers: { Authorization: auth(), 'Content-Type': 'message/rfc822' }, body: rawMessage(n) }); + expect(up.ok).toBe(true); + const { blobId } = await up.json(); + const res = await client.request([['Email/import', { accountId: client.accountId, emails: { e: { blobId, mailboxIds: { [mailbox]: true }, keywords: { $seen: true }, receivedAt: new Date(Date.now() + n * 1000).toISOString().replace(/\.\d+Z/, 'Z') } } }, '0']]); + const created = res.methodResponses[0][1].created?.e; + expect(created?.id, JSON.stringify(res.methodResponses[0][1])).toBeTruthy(); + blobIds.push(created.blobId); + const download = await fetch(`${JMAP_URL}/jmap/download/${client.accountId}/${created.blobId}/m.eml?accept=message/rfc822`, { headers: { Authorization: auth() } }); + expect(download.ok).toBe(true); + // What the server has is the reference, whatever it did with the upload. + originals.push(Buffer.from(await download.arrayBuffer())); + } +}); + +test.afterAll(async () => { + const client = await account(); + await client.request([['Mailbox/set', { accountId: client.accountId, destroy: [mailbox], onDestroyRemoveEmails: true }, '0']]).catch(() => {}); +}); + +/** Every write to a private file is kept, and the export has no save picker, so it goes to the spool. */ +const INIT = () => { + const proto = FileSystemWritableFileStream.prototype as unknown as { write(data: unknown): Promise }; + const write = proto.write; + const seen: Uint8Array[] = []; + (window as unknown as { __spoolWrites: Uint8Array[] }).__spoolWrites = seen; + proto.write = function patched(data: unknown) { + if (data instanceof Uint8Array) seen.push(data.slice()); + return write.call(this, data); + }; + delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker; +}; + +let page: Page; +let context: BrowserContext; + +async function settings(target: Page): Promise { + await target.goto('/settings/backup/offline'); + await expect(target.getByTestId('offline-switch')).toBeVisible({ timeout: 30_000 }); +} + +/** A page load with no network: the app shell from the service worker, the rest from the device. */ +async function startOffline(): Promise { + await page.reload(); + await expect(page.getByTestId('offline-banner')).toContainText("You're offline", { timeout: 60_000 }); + // The shell can open Settings on its first page: from there through the menu, as a person would. + if (!(await page.getByTestId('offline-switch').waitFor({ state: 'visible', timeout: 5_000 }).then(() => true, () => false))) { + await page.getByText('Backup & reset', { exact: true }).first().click(); + } + await expect(page.getByTestId('offline-switch')).toBeVisible({ timeout: 30_000 }); +} + +async function openDialog(): Promise { + await page.getByTestId('local-export-open').click(); + await expect(page.getByTestId('local-export-dialog')).toBeVisible(); + await expect(page.getByTestId('local-export-summary')).toBeVisible({ timeout: 30_000 }); + // The test's folder only. + await page.getByTestId('local-export-all').click(); + await page.locator(`[data-folder="${FOLDER}"]`).click(); +} + +async function closeSetup(): Promise { + await page.getByTestId('local-export-dialog').getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page.getByTestId('local-export-dialog')).toBeHidden(); +} + +async function closeSummary(): Promise { + await page.getByTestId('local-export-dialog').getByRole('button', { name: 'Close', exact: true }).last().click(); + await expect(page.getByTestId('local-export-dialog')).toBeHidden(); +} + +async function waitForOriginals(wanted: string): Promise { + await expect.poll(async () => { + await openDialog(); + const text = await page.getByTestId('local-export-kept').textContent(); + await closeSetup(); + return text; + }, { timeout: 240_000, intervals: [5_000] }).toContain(wanted); +} + +async function exported(format: 'mbox' | 'eml'): Promise { + await page.getByTestId(`local-export-format-${format}`).check(); + const download = page.waitForEvent('download', { timeout: 60_000 }); + await page.getByTestId('local-export-start').click(); + const file = await download; + expect(file.suggestedFilename()).toBe(`${FOLDER}.${format === 'mbox' ? 'mbox' : 'zip'}`); + await expect(page.getByTestId('mail-export-status')).toBeVisible(); + return readFileSync(await file.path()); +} + +function mboxMessages(bytes: Buffer): Buffer[] { + const splitter = new MboxSplitter(); + return [...splitter.push(new Uint8Array(bytes)), ...splitter.end()].map((message) => Buffer.concat(withoutStateHeaders(message.parts))); +} + +function expectOriginals(messages: Buffer[]): void { + expect(messages.length).toBe(COUNT); + for (let i = 0; i < COUNT; i += 1) expect(messages[i].equals(originals[i]), `message ${i + 1} differs`).toBe(true); +} + +/** What this run wrote to the private files, for looking inside it. */ +async function spoolBytes(): Promise { + const base64 = await page.evaluate(() => { + const writes = (window as unknown as { __spoolWrites: Uint8Array[] }).__spoolWrites; + let binary = ''; + for (const write of writes) for (const byte of write) binary += String.fromCharCode(byte); + return btoa(binary); + }); + return Buffer.from(base64, 'base64'); +} + +async function forgetSpoolWrites(): Promise { + await page.evaluate(() => { (window as unknown as { __spoolWrites: Uint8Array[] }).__spoolWrites.length = 0; }); +} + +async function spoolReport(): Promise<{ left: string[]; bytes: number; readable: boolean }> { + return page.evaluate(async (secret) => { + const writes = (window as unknown as { __spoolWrites: Uint8Array[] }).__spoolWrites; + const decoder = new TextDecoder('latin1'); + const root = await navigator.storage.getDirectory(); + const left: string[] = []; + try { + const dir = await root.getDirectoryHandle('bulwark-exports'); + for await (const name of (dir as unknown as { keys(): AsyncIterable }).keys()) left.push(name); + } catch { /* never made */ } + return { + left, + bytes: writes.reduce((sum, write) => sum + write.length, 0), + readable: writes.some((write) => decoder.decode(write).includes(secret)), + }; + }, SECRET); +} + +test.beforeAll(async ({ browser }: { browser: Browser }) => { + context = await browser.newContext(contextOptions()); + page = await context.newPage(); + await neutralizeDevOverlay(page); + await page.goto('/'); + await page.fill('#username', USER); + await page.fill('#password', PASSWORD); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60_000 }); + await context.addInitScript(INIT); + await settings(page); + const toggle = page.getByTestId('offline-switch'); + if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'true'); + await expect(page.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 }); + await waitForOriginals(`${COUNT} of ${COUNT}`); +}); + +test.afterAll(async () => { + await context?.close(); +}); + +test('a folder exported offline as mbox is the bytes the server has, and the private files hold nothing readable', async () => { + await context.setOffline(true); + await openDialog(); + await expect(page.getByTestId('local-export-kept')).toContainText(`${COUNT} of ${COUNT}`); + await expect(page.getByTestId('local-export-missing')).toHaveCount(0); + await shot(page, 'd10-export-setup'); + const mbox = await exported('mbox'); + expectOriginals(mboxMessages(mbox)); + await expect(page.getByTestId('mail-export-status')).toContainText(`Messages exported: ${COUNT}`); + await shot(page, 'd10-export-done'); + const report = await spoolReport(); + expect(report.bytes).toBeGreaterThan(0); + expect(report.readable).toBe(false); + // Gone the moment the export was done, with the download still free to read its Blob. + expect(report.left).toEqual([]); + await closeSummary(); +}); + +test('the same folder as a ZIP of .eml files has the same bytes', async () => { + await forgetSpoolWrites(); + await openDialog(); + const zip = new Blob([new Uint8Array(await exported('eml'))]); + const entries = await listZipEntries(zip); + expect(entries.map((entry) => entry.name).every((name) => name.startsWith(`${FOLDER}/`) && name.endsWith('.eml'))).toBe(true); + expectOriginals(await Promise.all(entries.map(async (entry) => Buffer.from(await readZipEntry(zip, entry))))); + const report = await spoolReport(); + expect(report.left).toEqual([]); + // Deflated mail wouldn't show up in a search for its text: it has to be no ZIP at all. + const spool = await spoolBytes(); + expect(spool.length).toBeGreaterThan(0); + expect(spool.includes(Buffer.from('PK\x03\x04', 'latin1'))).toBe(false); + expect(spool.includes(Buffer.from(FOLDER))).toBe(false); + await expect(listZipEntries(new Blob([new Uint8Array(spool)]))).rejects.toThrow(); + await closeSummary(); +}); + +/** + * The picker's dialog is the only fake: what it answers is a real file handle, with + * a real writable and a real remove. `keepRemove: false` is a browser without remove. + */ +async function pickerAnswers(name: string, keepRemove = true): Promise { + await page.evaluate(async ({ name: file, keepRemove: keep }) => { + const root = await navigator.storage.getDirectory(); + const window_ = window as unknown as { showSaveFilePicker: unknown; __pickerClicks: number }; + window_.__pickerClicks = 0; + window_.showSaveFilePicker = async () => { + // The real one refuses a call that lost the click. + if (!navigator.userActivation.isActive) throw new DOMException('Must be handling a user gesture', 'SecurityError'); + window_.__pickerClicks += 1; + const handle = await root.getFileHandle(file, { create: true }); + return keep ? handle : { name: handle.name, createWritable: () => handle.createWritable() }; + }; + }, { name, keepRemove }); +} + +async function pickedFile(name: string): Promise { + const base64 = await page.evaluate(async (file) => { + const root = await navigator.storage.getDirectory(); + try { + const bytes = new Uint8Array(await (await (await root.getFileHandle(file)).getFile()).arrayBuffer()); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); + } catch { + return null; + } + }, name); + return base64 === null ? null : Buffer.from(base64, 'base64'); +} + +async function noPicker(): Promise { + await page.evaluate(() => { delete (window as { showSaveFilePicker?: unknown }).showSaveFilePicker; }); +} + +test('with a save picker, it writes straight to the chosen file, from the click, and no spool is left', async () => { + await pickerAnswers(`${STAMP}-picked.mbox`); + await openDialog(); + await page.getByTestId('local-export-format-mbox').check(); + await page.getByTestId('local-export-start').click(); + await expect(page.getByTestId('mail-export-status')).toContainText(`Messages exported: ${COUNT}`); + expect(await page.evaluate(() => (window as unknown as { __pickerClicks: number }).__pickerClicks)).toBe(1); + expectOriginals(mboxMessages((await pickedFile(`${STAMP}-picked.mbox`))!)); + expect((await spoolReport()).left).toEqual([]); + await closeSummary(); + await noPicker(); +}); + +test('with attachments off it says offline why it can\'t; a connection lost during the run is counted; the retry writes them all', async () => { + await context.setOffline(false); + await page.getByTestId('offline-attachments').click(); + await expect(page.getByTestId('offline-attachments')).toHaveAttribute('aria-checked', 'false'); + // A choice reaches the store after a short wait, so a quick change of mind costs nothing. + await waitForOriginals(`0 of ${COUNT}`); + await context.setOffline(true); + await openDialog(); + await expect(page.getByTestId('local-export-kept')).toContainText(`0 of ${COUNT}`); + await expect(page.getByTestId('local-export-missing')).toContainText(`offline: ${COUNT}`); + await expect(page.getByTestId('local-export-reason')).toContainText('Keep attachments'); + // Nothing could be written, so it doesn't offer to. + await expect(page.getByTestId('local-export-start')).toBeDisabled(); + await shot(page, 'd10-export-offline-missing'); + + // The browser says online, and the server goes as the export starts. + await context.setOffline(false); + await expect(page.getByTestId('local-export-start')).toBeEnabled({ timeout: 30_000 }); + const SERVER = `${new URL(JMAP_URL).origin}/**`; + await context.route(SERVER, (route) => route.abort('internetdisconnected')); + let downloads = 0; + const counted = () => { downloads += 1; }; + page.on('download', counted); + await page.getByTestId('local-export-format-mbox').check(); + await page.getByTestId('local-export-start').click(); + await expect(page.getByTestId('local-export-not-kept-attachments')).toContainText(`attachments aren't kept on this device: ${COUNT}`, { timeout: 60_000 }); + await expect(page.getByTestId('mail-export-status')).toContainText('Messages exported: 0'); + // No empty file that looks like a backup. + await expect(page.getByTestId('mail-export-file')).toHaveCount(0); + await expect(page.getByTestId('mail-export-download')).toHaveCount(0); + page.off('download', counted); + expect(downloads).toBe(0); + await shot(page, 'd10-export-not-kept'); + + await context.unroute(SERVER); + // The browser going off says so in the summary, and coming back opens the retry. + await context.setOffline(true); + await expect(page.getByTestId('local-export-retry')).toBeDisabled(); + await expect(page.getByTestId('local-export-retry-hint')).toHaveText("You can try again when you're online."); + await context.setOffline(false); + await expect(page.getByTestId('local-export-retry')).toBeEnabled({ timeout: 60_000 }); + await page.getByTestId('local-export-retry').click(); + // The same folder is still the one chosen. + await expect(page.getByTestId('local-export-kept')).toContainText(`0 of ${COUNT}`, { timeout: 30_000 }); + await expect(page.getByTestId('local-export-missing')).toContainText(`To download from the server: ${COUNT}`); + const again = await exported('mbox'); + expectOriginals(mboxMessages(again)); + await closeSummary(); + + // The same with a save picker: a run that wrote nothing takes the file the picker made with it. + const removed = `${STAMP}-removed.mbox`; + const stays = `${STAMP}-stays.mbox`; + for (const [file, keepRemove] of [[removed, true], [stays, false]] as const) { + await pickerAnswers(file, keepRemove); + await openDialog(); + await page.getByTestId('local-export-format-mbox').check(); + // The server goes once it has counted as reachable: offline with nothing here there's no Export to click. + await expect(page.getByTestId('local-export-start')).toBeEnabled({ timeout: 60_000 }); + await context.route(SERVER, (route) => route.abort('internetdisconnected')); + await page.getByTestId('local-export-start').click(); + await expect(page.getByTestId('local-export-not-kept-attachments')).toBeVisible({ timeout: 60_000 }); + await context.unroute(SERVER); + if (keepRemove) { + expect(await pickedFile(file), 'the picked file is gone').toBeNull(); + await expect(page.getByTestId('local-export-leftover')).toHaveCount(0); + } else { + await expect(page.getByTestId('local-export-leftover')).toContainText(file); + await shot(page, 'd10-export-leftover'); + } + await context.setOffline(true); + await context.setOffline(false); + await closeSummary(); + } + await noPicker(); + + await page.getByTestId('offline-attachments').click(); + await expect(page.getByTestId('offline-attachments')).toHaveAttribute('aria-checked', 'true'); +}); + +test('at 390 px the dialog fits, and works by keyboard', async () => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByTestId('local-export-open').scrollIntoViewIfNeeded(); + await page.getByTestId('local-export-open').focus(); + await page.keyboard.press('Enter'); + const dialog = page.getByTestId('local-export-dialog'); + await expect(dialog).toBeVisible(); + await expect(page.getByTestId('local-export-summary')).toBeVisible({ timeout: 30_000 }); + const widths = await page.evaluate(() => { + const panel = document.querySelector('[data-testid="local-export-dialog"]') as HTMLElement; + return { page: document.documentElement.scrollWidth, panel: panel.getBoundingClientRect().width, inner: panel.scrollWidth }; + }); + expect(widths.page).toBeLessThanOrEqual(390); + expect(widths.panel).toBeLessThanOrEqual(390); + expect(widths.inner).toBeLessThanOrEqual(390); + await shot(page, 'd10-export-phone'); + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + await page.setViewportSize({ width: 1440, height: 900 }); +}); + +test('"No export" locks it, also once offline', async ({ browser }) => { + const admin = await adminContext(browser); + try { + await withPolicy(admin, { organisation: { devices: { ...DEVICES_OFF, noExport: true } } }, async () => { + await settings(page); + await expect(page.getByTestId('local-export-open')).toBeDisabled({ timeout: 30_000 }); + await expect(page.getByText('Set by your organisation').first()).toBeVisible(); + // A start with no network: all it has is the policy it kept from the last fetch. + await context.setOffline(true); + await startOffline(); + await expect(page.getByTestId('local-export-open')).toBeDisabled({ timeout: 30_000 }); + await expect(page.getByTestId('local-export-dialog')).toHaveCount(0); + await shot(page, 'd10-export-locked-offline-start'); + await context.setOffline(false); + }); + } finally { + await admin.close(); + } + await settings(page); + await expect(page.getByTestId('local-export-open')).toBeEnabled({ timeout: 30_000 }); + // The other way round: a start with no network, allowed by the kept policy, exports. + await waitForOriginals(`${COUNT} of ${COUNT}`); + await context.setOffline(true); + await startOffline(); + await expect(page.getByTestId('local-export-open')).toBeEnabled({ timeout: 60_000 }); + await openDialog(); + expectOriginals(mboxMessages(await exported('mbox'))); + await closeSummary(); + await context.setOffline(false); +}); diff --git a/integration/tests/24-do-reminders.spec.ts b/integration/tests/24-do-reminders.spec.ts new file mode 100644 index 000000000..44976d7e6 --- /dev/null +++ b/integration/tests/24-do-reminders.spec.ts @@ -0,0 +1,226 @@ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { ENT, jmapAs, requireEntEnv, shot, userPage } from './helpers/ent'; + +/** + * Calendar reminders from the local store, in a real browser on a production build and + * a VM 106 test Stalwart, with real time passing: the store's worker has its own clock. + * + * The account is ent-dave, whom the other specs leave alone. Snooze and the native + * buttons belong to the desktop app: repos/desktop test/reminders-real.spec.ts. + */ + +test.describe.configure({ mode: 'serial' }); +test.setTimeout(900_000); +// The headless shell denies notifications; full Chromium really shows them. +test.use({ channel: 'chromium' }); + +const RUN = Date.now().toString(36); +const USER = ENT.dave; +const ZONE = 'Europe/Berlin'; +const CORE = 'urn:ietf:params:jmap:core'; +const CALENDARS = 'urn:ietf:params:jmap:calendars'; +/** ledger.ts AWAY_MS and a margin. */ +const AWAY_MS = 6.5 * 60_000; +/** How long a change on the server may take to reach the store. */ +const SYNC_MS = 30_000; + +type J = Awaited>; +let client: J; +let context: BrowserContext; +let page: Page; +const created: string[] = []; + +async function call(calls: [string, Record, string][]) { + const response = await client.request(calls, [CORE, CALENDARS]); + for (const [name, result] of response.methodResponses) { + if (name === 'error') throw new Error(`JMAP error: ${JSON.stringify(result)}`); + } + return response.methodResponses as [string, Record, string][]; +} + +/** A wall clock in the event's zone. */ +function wall(ms: number): string { + const parts = new Intl.DateTimeFormat('sv-SE', { + timeZone: ZONE, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', + }).format(new Date(ms)); + return parts.replace(' ', 'T'); +} + +const OFFSET_MIN = 5; + +/** An event whose alert fires at `fireAt`. */ +async function eventFiringAt(title: string, fireAt: number, extra: Record = {}): Promise { + const [[, calendars]] = await call([['Calendar/get', { accountId: client.accountId, properties: ['isDefault'] }, '0']]); + const list = calendars.list as Array<{ id: string; isDefault: boolean }>; + const calendar = list.find((entry) => entry.isDefault) ?? list[0]; + const [[, result]] = await call([['CalendarEvent/set', { + accountId: client.accountId, + create: { + e: { + '@type': 'Event', + calendarIds: { [calendar.id]: true }, + timeZone: ZONE, + title, + start: wall(fireAt + OFFSET_MIN * 60_000), + duration: 'PT30M', + useDefaultAlerts: false, + alerts: { a1: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: `-PT${OFFSET_MIN}M`, relativeTo: 'start' } } }, + ...extra, + }, + }, + sendSchedulingMessages: false, + }, '0']]); + const id = (result.created as Record | undefined)?.e?.id; + if (!id) throw new Error(`Event not created: ${JSON.stringify(result)}`); + created.push(id); + return id; +} + +/** Settings > Backup & reset > Offline, switched on, and the first sync done. */ +async function enableOffline(target: Page): Promise { + await target.goto('/settings/backup/offline'); + const toggle = target.getByTestId('offline-switch'); + await toggle.waitFor({ state: 'visible', timeout: 60_000 }); + if ((await toggle.getAttribute('aria-checked')) !== 'true') await toggle.click(); + await expect(target.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 }); +} + +const toast = (target: Page, text: string) => target.locator('.toast-item').filter({ hasText: text }); + +test.beforeAll(async ({ browser }) => { + requireEntEnv(); + client = await jmapAs(USER); + page = await userPage(browser, USER); + context = page.context(); + await enableOffline(page); + await page.goto('/'); +}); + +test.afterAll(async () => { + if (created.length > 0) { + await call([['CalendarEvent/set', { accountId: client.accountId, destroy: created, sendSchedulingMessages: false }, '0']]).catch(() => {}); + } + await context?.close(); +}); + +test('an occurrence of a series fires from the store with the network gone', async () => { + const title = `rem-series-${RUN}`; + const fireAt = Date.now() + SYNC_MS + 30_000; + // The series began weeks ago, so only an expansion finds today's occurrence. + const [day, time] = wall(fireAt + OFFSET_MIN * 60_000).split('T'); + const weeksAgo = new Date(Date.parse(`${day}T00:00:00Z`) - 28 * 24 * 3_600_000).toISOString().slice(0, 10); + await eventFiringAt(title, fireAt, { + start: `${weeksAgo}T${time}`, + recurrenceRule: { '@type': 'RecurrenceRule', frequency: 'weekly' }, + }); + await page.waitForTimeout(SYNC_MS); + await context.setOffline(true); + try { + expect(await page.evaluate(() => navigator.onLine)).toBe(false); + await page.waitForTimeout(Math.max(fireAt - Date.now() - 3000, 0)); + await expect(toast(page, title)).toHaveCount(0); + await expect(toast(page, title)).toBeVisible({ timeout: 30_000 }); + await shot(page, 'desktop-reminders-offline-toast'); + } finally { + await context.setOffline(false); + } + await toast(page, title).getByRole('button').first().click().catch(() => {}); +}); + +test('fires in one tab of two', async () => { + const second = await context.newPage(); + await second.goto('/'); + const title = `rem-tabs-${RUN}`; + const fireAt = Date.now() + SYNC_MS + 20_000; + await eventFiringAt(title, fireAt); + // A reminder's toast leaves after a while, so note the most each tab ever showed. + const most = [0, 0]; + const deadline = fireAt + 40_000; + let lastSeen = 0; + while (Date.now() < deadline && (lastSeen === 0 || Date.now() < lastSeen + 8000)) { + const counts = await Promise.all([page, second].map((target) => toast(target, title).count())); + counts.forEach((count, index) => { + most[index] = Math.max(most[index], count); + }); + if (lastSeen === 0 && counts.some((count) => count > 0)) lastSeen = Date.now(); + await second.waitForTimeout(250); + } + expect(most[0] + most[1]).toBe(1); + await second.close(); + await page.bringToFront(); +}); + +test('adds a system notification when permission is granted and the tab is not in front', async () => { + await context.grantPermissions(['notifications']); + await page.reload(); + await page.evaluate(() => { + document.hasFocus = () => false; + }); + const title = `rem-system-${RUN}`; + const fireAt = Date.now() + SYNC_MS + 15_000; + await eventFiringAt(title, fireAt); + await expect(toast(page, title)).toBeVisible({ timeout: fireAt - Date.now() + 30_000 }); + const shown = await page.evaluate(async (wanted) => { + const registration = await navigator.serviceWorker.ready; + return (await registration.getNotifications()).filter((item) => item.title === wanted).map((item) => ({ tag: item.tag, data: item.data as unknown })); + }, title); + expect(shown).toHaveLength(1); + expect(shown[0].tag).toContain('calendar-reminder:'); + expect(shown[0].data).toMatchObject({ kind: 'calendar-reminder', url: expect.stringMatching(/\/calendar$/) }); + await context.clearPermissions(); +}); + +test('what came due with the app closed is summarised once, and not as a burst', async () => { + const one = `rem-missed-a-${RUN}`; + const two = `rem-missed-b-${RUN}`; + const fireAt = Date.now() + SYNC_MS + 20_000; + await eventFiringAt(one, fireAt); + await eventFiringAt(two, fireAt + 20_000, { virtualLocations: { v: { '@type': 'VirtualLocation', name: 'Meet', uri: 'https://meet.jit.si/rem-test' } } }); + await page.waitForTimeout(SYNC_MS); + + // Closed like an app: nothing of the page is left, and the store's data stays. + const closedAt = Date.now(); + await page.close(); + await new Promise((resolve) => setTimeout(resolve, Math.max(fireAt + 20_000 + AWAY_MS - closedAt, AWAY_MS))); + + page = await context.newPage(); + await page.goto('/'); + const summary = page.locator('.toast-item').filter({ hasText: one }); + await expect(summary).toBeVisible({ timeout: 60_000 }); + await expect(summary).toContainText(two); + await expect(toast(page, one)).toHaveCount(1); + await shot(page, 'desktop-reminders-missed-toast'); + + await summary.getByRole('button', { name: 'Show' }).click(); + const list = page.getByTestId('missed-reminders'); + await expect(list).toContainText(one); + await expect(list).toContainText(two); + await expect(list.getByRole('button', { name: 'Join' })).toHaveCount(1); + await shot(page, 'desktop-reminders-missed'); + await page.getByRole('button', { name: 'Done' }).click(); + await expect(list).toHaveCount(0); + + // Once: not after a reload, nor from a new tab. + await page.reload(); + await page.waitForTimeout(30_000); + await expect(page.locator('.toast-item').filter({ hasText: 'rem-missed' })).toHaveCount(0); + await expect(page.getByTestId('missed-reminders')).toHaveCount(0); +}); + +test('with the store off, the app reminds as it always did', async () => { + await page.goto('/settings/backup/offline'); + const toggle = page.getByTestId('offline-switch'); + await toggle.waitFor({ state: 'visible', timeout: 60_000 }); + await toggle.click(); + const confirm = page.getByRole('button', { name: /turn off|switch off|delete/i }).last(); + if (await confirm.isVisible({ timeout: 3000 }).catch(() => false)) await confirm.click(); + await expect(toggle).toHaveAttribute('aria-checked', 'false', { timeout: 30_000 }); + await page.goto('/'); + const title = `rem-plain-${RUN}`; + const fireAt = Date.now() + 20_000; + await eventFiringAt(title, fireAt); + // The in-app path reads the server every five minutes, or on a push. + await expect(toast(page, title)).toBeVisible({ timeout: 120_000 }); + await page.waitForTimeout(5000); + await expect(toast(page, title)).toHaveCount(1); +}); diff --git a/lib/__tests__/collector-validator.ts b/lib/__tests__/collector-validator.ts new file mode 100644 index 000000000..9e895510e --- /dev/null +++ b/lib/__tests__/collector-validator.ts @@ -0,0 +1,26 @@ +// Not a test: loads the collector's real validator for the tests that check against it. +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export interface CollectorValidator { + validate(input: unknown): { schema: string; build: string | null; platform: string; version: string; os_family: string; node_version: string | null } | null; + validateErrorBatch(input: unknown): { errors: { name: string; count: string; days: number; version: string; frames: unknown[] }[] } | null; +} + +/** + * The validator on the branch the desktop app ships against, read from git so it doesn't + * matter what the dashboard checkout has out. Null where there's no dashboard repo (CI). + */ +export async function loadCollectorValidator(): Promise { + const repo = process.env.BULWARK_DASHBOARD_DIR ?? 'C:/Users/info/Downloads/jmap-webmail/repos/dashboard'; + const ref = process.env.BULWARK_DASHBOARD_REF ?? 'feat/desktop-platform'; + if (!existsSync(repo)) return null; + const shown = spawnSync('git', ['-C', repo, 'show', `${ref}:telemetry-collector/src/validate.ts`], { encoding: 'utf8' }); + if (shown.status !== 0) throw new Error(`no validator at ${ref}: ${shown.stderr}`); + const file = path.join(mkdtempSync(path.join(tmpdir(), 'bw-validator-')), 'validate.ts'); + writeFileSync(file, shown.stdout); + return await import(/* @vite-ignore */ pathToFileURL(file).href) as CollectorValidator; +} diff --git a/lib/__tests__/telemetry-desktop-endpoint.test.ts b/lib/__tests__/telemetry-desktop-endpoint.test.ts new file mode 100644 index 000000000..7fa525dd2 --- /dev/null +++ b/lib/__tests__/telemetry-desktop-endpoint.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment node +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// What's on disk can't aim the desktop app's telemetry anywhere: a state file or a config +// file is something any program on the machine can write. Nothing here reaches a network. + +const flag = vi.hoisted(() => ({ IS_DESKTOP_BUILD: true, dsn: '' })); +vi.mock('@/lib/desktop/flag', () => ({ + get IS_DESKTOP_BUILD() { + return flag.IS_DESKTOP_BUILD; + }, +})); +vi.mock('@/lib/admin/third-party-mode', () => ({ isNoThirdPartyTrafficOn: async () => false })); +vi.mock('@/lib/admin/plugin-registry', () => ({ + getPluginRegistry: async () => ({ plugins: [] }), + getThemeRegistry: async () => ({ themes: [] }), +})); +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: async () => {}, + getPolicy: () => ({ features: {} }), + getAll: () => ({}), + get: (key: string, fallback: unknown) => (key === 'errorReportingDsn' ? flag.dsn : fallback), + }, +})); +const sent = vi.fn(async (_url: string, _init?: { body?: string }) => new Response('{"ok":true}')); +vi.mock('@/lib/telemetry/endpoint-guard', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveEndpointAllowed: vi.fn(async () => ({ ok: true })), + fetchTelemetryTarget: (url: string, init?: { body?: string }) => sent(url, init), + }; +}); + +import { DEFAULT_ENDPOINT, loadState, saveState, sendOnce } from '@/lib/telemetry'; +import { resetInstantErrorsForTests, sendInstantErrors } from '@/lib/telemetry/instant-errors'; +import { flushErrors, recordError, resetAggregateForTests } from '@/lib/error-reporting/aggregate'; +import { browserErrorSettings, errorReportingSettings } from '@/lib/error-reporting/settings'; +import { captureDesktopMainError } from '@/lib/error-reporting/server'; + +const PRIVACY = Symbol.for('bulwark.desktop.privacy'); +const holder = globalThis as unknown as Record; +const EVIL = 'https://collector.evil.example/v1/heartbeat'; +const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.evil.example/3'; + +let dir = ''; + +beforeEach(async () => { + flag.IS_DESKTOP_BUILD = true; + flag.dsn = ''; + sent.mockClear(); + dir = mkdtempSync(path.join(tmpdir(), 'bw-telemetry-endpoint-')); + vi.stubEnv('TELEMETRY_DATA_DIR', dir); + vi.stubEnv('BULWARK_TELEMETRY', ''); + resetAggregateForTests(); + resetInstantErrorsForTests(); + holder[PRIVACY] = { telemetry: true, crashReports: true, appVersion: '0.3.0', build: 'desktop.standalone.stable' }; + await saveState({ consent: 'off', endpoint: EVIL, consentedAt: null, lastSentAt: null, nextScheduledAt: null }); +}); + +afterEach(() => { + delete holder[PRIVACY]; + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('the desktop build', () => { + it('sends a heartbeat to the project\'s collector, whatever the state file names', async () => { + expect((await sendOnce()).ok).toBe(true); + expect(sent.mock.calls.map(([url]) => url)).toEqual([DEFAULT_ENDPOINT]); + }); + + it('isn\'t switched off by a blank endpoint in the state file either: consent is the switch', async () => { + await saveState({ ...(await loadState()), endpoint: '' }); + expect((await sendOnce()).ok).toBe(true); + expect(sent.mock.calls.map(([url]) => url)).toEqual([DEFAULT_ENDPOINT]); + }); + + it('sends error counts there too', async () => { + recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0+desktop'); + await flushErrors(); + await sendInstantErrors(); + expect(sent.mock.calls.map(([url]) => url)).toEqual([new URL('errors', DEFAULT_ENDPOINT).toString()]); + }); + + it('has no DSN, whatever a config file names', async () => { + flag.dsn = DSN; + expect(errorReportingSettings().dsn).toBe(''); + expect((await browserErrorSettings()).dsn).toBe(''); + }); + + it('sends a main-process error to no config-file DSN, with consent on', async () => { + flag.dsn = DSN; + const fetchSpy = vi.fn().mockResolvedValue(new Response(null)); + vi.stubGlobal('fetch', fetchSpy); + await captureDesktopMainError({ name: 'TypeError', message: 'x', stack: 'TypeError: x\n at f (/desktop/main.mjs:1:2)' }); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe('a server build', () => { + beforeEach(() => { + flag.IS_DESKTOP_BUILD = false; + vi.stubEnv('BULWARK_TELEMETRY', 'on'); + }); + + it('still uses the endpoint its admin stored', async () => { + await saveState({ ...(await loadState()), endpoint: 'https://collector.example.org/v1/heartbeat' }); + await sendOnce(); + expect(sent.mock.calls.map(([url]) => url)).toEqual(['https://collector.example.org/v1/heartbeat']); + }); + + it('still takes a blank endpoint as off', async () => { + await saveState({ ...(await loadState()), endpoint: '' }); + expect(await sendOnce()).toMatchObject({ ok: false, error: 'endpoint blank' }); + expect(sent).not.toHaveBeenCalled(); + }); + + it('still has its admin\'s DSN', async () => { + flag.dsn = DSN; + expect(errorReportingSettings().dsn).toBe(DSN); + expect((await browserErrorSettings()).dsn).toBe(DSN); + }); +}); diff --git a/lib/__tests__/telemetry-desktop.test.ts b/lib/__tests__/telemetry-desktop.test.ts new file mode 100644 index 000000000..9d1c66860 --- /dev/null +++ b/lib/__tests__/telemetry-desktop.test.ts @@ -0,0 +1,397 @@ +// @vitest-environment node +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The desktop app's heartbeat and error counts, on a real socket: what counts is what +// reaches the collector, so these listen on a port rather than stubbing fetch. Consent +// comes from the shell, not from a file or the environment. The bodies go through the +// collector's real validator. + +const flag = vi.hoisted(() => ({ IS_DESKTOP_BUILD: true })); +vi.mock('@/lib/desktop/flag', () => ({ + get IS_DESKTOP_BUILD() { + return flag.IS_DESKTOP_BUILD; + }, +})); +vi.mock('@/lib/admin/third-party-mode', () => ({ isNoThirdPartyTrafficOn: async () => false })); +vi.mock('@/lib/admin/plugin-registry', () => ({ + getPluginRegistry: async () => ({ plugins: [] }), + getThemeRegistry: async () => ({ themes: [] }), +})); +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: async () => {}, + getPolicy: () => ({ features: { calendarEnabled: true } }), + getAll: () => ({}), + get: (_key: string, fallback: unknown) => fallback, + }, +})); + +import { buildPayload, loadState, saveState, sendOnce, startScheduler, stopScheduler } from '@/lib/telemetry'; +import { resetInstantErrorsForTests, sendInstantErrors } from '@/lib/telemetry/instant-errors'; +import { flushErrors, recordError, resetAggregateForTests } from '@/lib/error-reporting/aggregate'; +import { shouldCollectErrors } from '@/lib/error-reporting/settings'; +import { fetchTelemetryTarget } from '@/lib/telemetry/endpoint-guard'; +import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry'; +import { DESKTOP_CATCH_UP_MAX_MS, DESKTOP_CATCH_UP_MIN_MS } from '@/lib/desktop/desktop-telemetry'; +import { loadCollectorValidator } from './collector-validator'; + +const collector = await loadCollectorValidator(); + +interface Received { + path: string; + body: Record; +} + +let server: Server; +let received: Received[] = []; +let answer = 200; +let base = ''; +let dir = ''; + +const PRIVACY = Symbol.for('bulwark.desktop.privacy'); +const TRUSTED = Symbol.for('bulwark.desktop.trustedJmapOrigins'); +const holder = globalThis as unknown as Record; + +function shellSays(privacy: { telemetry: boolean; crashReports: boolean } | null): void { + holder[PRIVACY] = privacy ? { ...privacy, appVersion: '0.3.0', build: 'desktop.standalone.beta', endpoint: `${base}/v1/heartbeat` } : undefined; +} + +beforeAll(async () => { + server = createServer((req, res) => { + let raw = ''; + req.on('data', (chunk) => { raw += chunk; }); + req.on('end', () => { + received.push({ path: req.url ?? '', body: JSON.parse(raw) }); + res.writeHead(answer, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(resolve)); +}); + +beforeEach(async () => { + flag.IS_DESKTOP_BUILD = true; + received = []; + answer = 200; + dir = mkdtempSync(path.join(tmpdir(), 'bw-telemetry-desktop-')); + vi.stubEnv('TELEMETRY_DATA_DIR', dir); + vi.stubEnv('BULWARK_TELEMETRY_ALLOW_PRIVATE', '1'); + vi.stubEnv('BULWARK_TELEMETRY', ''); + vi.stubEnv('BULWARK_TELEMETRY_DISABLED', ''); + vi.stubEnv('BULWARK_TELEMETRY_ERRORS', ''); + shellSays(null); + resetAggregateForTests(); + resetInstantErrorsForTests(); + // Never used on the desktop build. Reaching it would show up as a failed send. + await saveState({ consent: 'off', endpoint: 'http://127.0.0.1:1/ignored', consentedAt: null, lastSentAt: null, nextScheduledAt: null }); +}); + +afterEach(async () => { + await stopScheduler(); + delete holder[PRIVACY]; + delete holder[TRUSTED]; + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('nothing leaves before consent', () => { + it('sends no heartbeat when the shell has said nothing', async () => { + expect((await sendOnce()).ok).toBe(false); + expect(received).toEqual([]); + }); + + it('sends no heartbeat on a no from the shell, whatever the file or the environment say', async () => { + shellSays({ telemetry: false, crashReports: false }); + vi.stubEnv('BULWARK_TELEMETRY', 'on'); + await saveState({ ...(await loadState()), consent: 'on' }); + expect((await sendOnce()).ok).toBe(false); + expect(received).toEqual([]); + }); + + it('sends no error counts when the shell has said nothing', async () => { + recordError({ name: 'TypeError', frames: [] }, 'server', '1.0.0'); + await flushErrors(); + await sendInstantErrors(); + expect(received).toEqual([]); + expect(await shouldCollectErrors()).toBe(false); + }); +}); + +describe('the heartbeat with consent', () => { + it('goes out, and stops the moment consent goes', async () => { + shellSays({ telemetry: true, crashReports: false }); + expect((await sendOnce()).ok).toBe(true); + expect(received).toHaveLength(1); + expect(received[0].path).toBe('/v1/heartbeat'); + shellSays({ telemetry: false, crashReports: false }); + expect((await sendOnce()).ok).toBe(false); + expect(received).toHaveLength(1); + }); + + it('says desktop, the app version, mode and channel, in schema 1', async () => { + shellSays({ telemetry: true, crashReports: false }); + await sendOnce(); + expect(received[0].body).toMatchObject({ schema: '1', platform: 'desktop', version: '0.3.0', build: 'desktop.standalone.beta' }); + expect(received[0].body).not.toHaveProperty('errors'); + expect(received[0].body).not.toHaveProperty('mode'); + expect(received[0].body).not.toHaveProperty('channel'); + }); + + it.skipIf(!collector)('is what the real collector accepts, and it keeps the build', async () => { + shellSays({ telemetry: true, crashReports: false }); + await sendOnce(); + const clean = collector!.validate(received[0].body)!; + expect(clean).not.toBeNull(); + expect(clean.schema).toBe('1'); + expect(clean.build).toBe('desktop.standalone.beta'); + expect(clean.version).toBe('0.3.0'); + expect(clean.platform).toBe('desktop'); + // Schema 2 refuses any extra key, so this proves there is none. + expect(collector!.validate({ ...received[0].body, schema: '2', errors: [] })).not.toBeNull(); + }); + + it('stays schema 1 with crash reports on too', async () => { + shellSays({ telemetry: true, crashReports: true }); + expect((await buildPayload()).schema).toBe('1'); + }); +}); + +describe('quit within an hour of each start', () => { + const nextIn = async () => new Date((await loadState()).nextScheduledAt!).getTime() - Date.now(); + + it.each([ + [0, DESKTOP_CATCH_UP_MIN_MS], + [0.5, (DESKTOP_CATCH_UP_MIN_MS + DESKTOP_CATCH_UP_MAX_MS) / 2], + [0.999999, DESKTOP_CATCH_UP_MAX_MS], + ])('after a restart with a slot in the past, at random %s it goes after about %s ms, not an hour', async (random, expected) => { + shellSays({ telemetry: true, crashReports: false }); + vi.spyOn(Math, 'random').mockReturnValue(random); + await saveState({ ...(await loadState()), nextScheduledAt: new Date(Date.now() - 3 * 86_400_000).toISOString() }); + await startScheduler(); + const wait = await nextIn(); + vi.restoreAllMocks(); + expect(wait).toBeGreaterThan(expected - 5_000); + expect(wait).toBeLessThanOrEqual(expected); + }); + + it('the wait is between 30 s and 10 min, and differs from start to start', () => { + expect(DESKTOP_CATCH_UP_MIN_MS).toBe(30_000); + expect(DESKTOP_CATCH_UP_MAX_MS).toBe(600_000); + }); + + it('the first run with consent does not wait an hour either', async () => { + shellSays({ telemetry: true, crashReports: false }); + await startScheduler(); + expect(await nextIn()).toBeLessThanOrEqual(DESKTOP_CATCH_UP_MAX_MS); + }); + + it('a slot still ahead stays where it is', async () => { + shellSays({ telemetry: true, crashReports: false }); + const slot = new Date(Date.now() + 5 * 3_600_000).toISOString(); + await saveState({ ...(await loadState()), nextScheduledAt: slot }); + await startScheduler(); + expect(await nextIn()).toBeGreaterThan(4 * 3_600_000); + }); + + it('a server build still waits the hour', async () => { + flag.IS_DESKTOP_BUILD = false; + vi.stubEnv('BULWARK_TELEMETRY', 'on'); + await saveState({ ...(await loadState()), endpoint: `${base}/v1/heartbeat`, nextScheduledAt: new Date(Date.now() - 86_400_000).toISOString() }); + await startScheduler(); + const wait = await nextIn(); + expect(wait).toBeGreaterThan(59 * 60_000); + expect(wait).toBeLessThanOrEqual(3_600_000); + }); +}); + +describe('BULWARK_TELEMETRY=off', () => { + beforeEach(() => { + vi.stubEnv('BULWARK_TELEMETRY', 'off'); + shellSays({ telemetry: true, crashReports: true }); + }); + + it('beats the user\'s yes for the heartbeat', async () => { + expect((await sendOnce()).ok).toBe(false); + expect(received).toEqual([]); + }); + + it('beats it for error counts too, and nothing is collected', async () => { + expect(await shouldCollectErrors()).toBe(false); + recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0+desktop'); + await flushErrors(); + await sendInstantErrors(); + expect(received).toEqual([]); + }); + + it('the legacy variable does the same', async () => { + vi.stubEnv('BULWARK_TELEMETRY', ''); + vi.stubEnv('BULWARK_TELEMETRY_DISABLED', '1'); + expect((await sendOnce()).ok).toBe(false); + expect(await shouldCollectErrors()).toBe(false); + expect(received).toEqual([]); + }); + + it('=on is no yes by itself', async () => { + vi.stubEnv('BULWARK_TELEMETRY', 'on'); + shellSays({ telemetry: false, crashReports: false }); + expect((await sendOnce()).ok).toBe(false); + expect(received).toEqual([]); + }); + + it('the list of outgoing hosts says how the desktop app differs', () => { + const entry = OUTGOING_HOSTS.find((host) => host.id === 'telemetry')!; + expect(entry.usedBy).toMatch(/crash report consent alone/i); + expect(entry.off).toMatch(/desktop app/i); + expect(entry.off).toMatch(/no admin page/i); + }); +}); + +describe('a refusal from the collector', () => { + it('isn\'t retried on every tick', async () => { + answer = 400; + shellSays({ telemetry: false, crashReports: true }); + recordError({ name: 'TypeError', frames: [] }, 'server', '0.3.0-beta.1+desktop'); + await flushErrors(); + await sendInstantErrors(); + await sendInstantErrors(); + await sendInstantErrors(); + expect(received).toHaveLength(1); + }); + + it('a passing failure is tried again', async () => { + answer = 503; + shellSays({ telemetry: false, crashReports: true }); + recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0+desktop'); + await flushErrors(); + await sendInstantErrors(); + await sendInstantErrors(); + expect(received).toHaveLength(2); + }); + + it('rate limiting is tried again', async () => { + answer = 429; + shellSays({ telemetry: false, crashReports: true }); + recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0+desktop'); + await flushErrors(); + await sendInstantErrors(); + await sendInstantErrors(); + expect(received).toHaveLength(2); + }); + + it.skipIf(!collector)('a beta version with the tag is what the patched collector takes', async () => { + shellSays({ telemetry: false, crashReports: true }); + recordError({ name: 'TypeError', frames: [] }, 'server', '0.3.0-beta.1+desktop'); + await flushErrors(); + await sendInstantErrors(); + expect(collector!.validateErrorBatch(received[0].body)!.errors[0].version).toBe('0.3.0-beta.1+desktop'); + }); + + it('a server build still retries as before', async () => { + flag.IS_DESKTOP_BUILD = false; + vi.stubEnv('BULWARK_TELEMETRY', 'on'); + vi.stubEnv('BULWARK_TELEMETRY_ERRORS', 'instant'); + answer = 400; + await saveState({ ...(await loadState()), endpoint: `${base}/v1/heartbeat` }); + recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0'); + await flushErrors(); + await sendInstantErrors(); + await sendInstantErrors(); + expect(received).toHaveLength(2); + }); +}); + +describe('crash reports have their own consent', () => { + const mainFrame = { path: '/desktop/main.mjs', line: 4, column: 2 }; + const bundleFrame = { path: '/.next/server/chunks/1.js', line: 9, column: 1 }; + + it('collects and sends error counts with telemetry off, and no heartbeat', async () => { + shellSays({ telemetry: false, crashReports: true }); + expect(await shouldCollectErrors()).toBe(true); + recordError({ name: 'TypeError', frames: [bundleFrame] }, 'server', '1.20.0+desktop'); + await flushErrors(); + await sendInstantErrors(); + expect(received.map((r) => r.path)).toEqual(['/v1/errors']); + expect((await sendOnce()).ok).toBe(false); + expect(received).toHaveLength(1); + }); + + it('telemetry alone collects no errors', async () => { + shellSays({ telemetry: true, crashReports: false }); + expect(await shouldCollectErrors()).toBe(false); + recordError({ name: 'TypeError', frames: [] }, 'server', '1.20.0+desktop'); + await flushErrors(); + await sendInstantErrors(); + expect(received).toEqual([]); + }); + + it.skipIf(!collector)('a batch with a main-process error is what the real collector accepts', async () => { + shellSays({ telemetry: false, crashReports: true }); + recordError({ name: 'TypeError', frames: [mainFrame, bundleFrame] }, 'server', '1.20.0+desktop'); + await flushErrors(); + await sendInstantErrors(); + const clean = collector!.validateErrorBatch(received[0].body)!; + expect(clean).not.toBeNull(); + expect(clean.errors[0].version).toBe('1.20.0+desktop'); + // The unpatched collector refuses a whole batch over a frame outside the bundles. + expect(clean.errors[0].frames).toEqual([expect.objectContaining({ path: '/.next/server/chunks/1.js' })]); + }); + + it('keeps main-process frames in what stays on this computer', async () => { + shellSays({ telemetry: false, crashReports: true }); + recordError({ name: 'TypeError', frames: [mainFrame] }, 'server', '1.20.0+desktop'); + await flushErrors(); + const stored = JSON.parse(readFileSync(path.join(dir, 'errors.json'), 'utf8')) as Record>; + const entry = Object.values(Object.values(stored)[0])[0]; + expect(entry.frames[0].path).toBe('/desktop/main.mjs'); + }); +}); + +describe('the endpoint guard', () => { + it('still refuses a private address when the shell trusts it as a mail server', async () => { + vi.stubEnv('BULWARK_TELEMETRY_ALLOW_PRIVATE', ''); + holder[TRUSTED] = new Set([base, 'http://192.168.1.5']); + await expect(fetchTelemetryTarget(`${base}/v1/heartbeat`, { method: 'POST', body: '{}' })).rejects.toThrow(); + await expect(fetchTelemetryTarget('http://192.168.1.5/v1/heartbeat', { method: 'POST', body: '{}' })).rejects.toThrow(); + expect(received).toEqual([]); + }); + + it('a stored endpoint on a private address goes nowhere with consent', async () => { + vi.stubEnv('BULWARK_TELEMETRY_ALLOW_PRIVATE', ''); + shellSays({ telemetry: true, crashReports: false }); + holder[TRUSTED] = new Set([base]); + const result = await sendOnce(); + expect(result.ok).toBe(false); + expect(result.error).toContain('endpoint blocked'); + expect(received).toEqual([]); + }); +}); + +describe('a server build', () => { + it('ignores what a shell would have said, so the admin\'s consent stands', async () => { + flag.IS_DESKTOP_BUILD = false; + shellSays({ telemetry: true, crashReports: true }); + vi.stubEnv('BULWARK_TELEMETRY', ''); + expect((await sendOnce()).ok).toBe(false); + expect(received).toEqual([]); + expect(await shouldCollectErrors()).toBe(false); + await saveState({ ...(await loadState()), consent: 'on', endpoint: `${base}/v1/heartbeat` }); + expect((await sendOnce()).ok).toBe(true); + expect(received[0].body.platform).not.toBe('desktop'); + expect(received[0].body.build).not.toBe('desktop.standalone.beta'); + }); +}); + +// So a typo in the path can't quietly turn the checks above off on a machine with the repo. +it.skipIf(process.env.CI)('finds the collector on this machine', () => { + expect(collector).not.toBeNull(); +}); diff --git a/lib/admin/__tests__/server-device-offline-caps.test.ts b/lib/admin/__tests__/server-device-offline-caps.test.ts index 1b9ba98e4..1364b243b 100644 --- a/lib/admin/__tests__/server-device-offline-caps.test.ts +++ b/lib/admin/__tests__/server-device-offline-caps.test.ts @@ -7,15 +7,15 @@ import { sanitizeOrganisationPartial } from '../organisation-policy'; const scope = (id: string, devices: Record) => ({ id, label: id, match: { domains: [`${id}.example.org`] }, policy: { organisation: { devices } } }); -describe('wipeOnRefusedLogin', () => { +describe('wipeOnForcedSignOut', () => { it('is off unless asked for, and survives the sanitizer', () => { - expect(DEFAULT_POLICY.organisation?.devices?.wipeOnRefusedLogin ?? false).toBe(false); - expect(sanitizeOrganisationPartial({ devices: { wipeOnRefusedLogin: true } }).devices).toMatchObject({ wipeOnRefusedLogin: true }); - expect(sanitizeOrganisationPartial({ devices: { wipeOnRefusedLogin: 'yes' } }).devices?.wipeOnRefusedLogin).not.toBe(true); + expect(DEFAULT_POLICY.organisation?.devices?.wipeOnForcedSignOut ?? false).toBe(false); + expect(sanitizeOrganisationPartial({ devices: { wipeOnForcedSignOut: true } }).devices).toMatchObject({ wipeOnForcedSignOut: true }); + expect(sanitizeOrganisationPartial({ devices: { wipeOnForcedSignOut: 'yes' } }).devices?.wipeOnForcedSignOut).not.toBe(true); }); it('isn\'t set by the stand-in for a login nobody can read: that\'s for what stays on the server', () => { - expect(ALL_DEVICE_LIMITS.wipeOnRefusedLogin).toBe(false); + expect(ALL_DEVICE_LIMITS.wipeOnForcedSignOut).toBe(false); }); }); diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts index e8e6f5d4f..0fe813574 100644 --- a/lib/admin/organisation-policy.ts +++ b/lib/admin/organisation-policy.ts @@ -176,11 +176,11 @@ export interface OrganisationPolicy { /** All attachments and files of one account on a device, in MB. 0 is no cap. */ offlineMaxTotalMb: number; /** - * A device deletes an account's offline data as soon as the server refuses its - * login, instead of hiding it for 30 days until the new password. Unsent mail - * goes with it. + * A device deletes an account's offline data as soon as it signs out with nobody + * asking (the server refused the login, or the idle or session limit ran out), + * instead of hiding it for 30 days until the next sign-in. Unsent mail goes with it. */ - wipeOnRefusedLogin: boolean; + wipeOnForcedSignOut: boolean; }; calendars: { /** Adds up across scopes by id. */ @@ -206,7 +206,7 @@ export const DEFAULT_ORGANISATION_POLICY: OrganisationPolicy = { noDownloads: false, noExport: false, noPrint: false, noInstallPrompt: false, pushWithoutContent: false, watermark: false, noOffline: false, offlineMaxDays: 0, noOfflineAttachments: false, offlineMaxAttachmentMb: 0, offlineMaxTotalMb: 0, - wipeOnRefusedLogin: false, + wipeOnForcedSignOut: false, }, calendars: { feeds: [], freeBusyGroup: '' }, }; @@ -551,7 +551,7 @@ export function sanitizeOrganisationPartial(raw: unknown): DeepPartial Usage stats (switch it off or clear the collector URL).', + usedBy: 'Anonymous daily heartbeat, sent only after an admin turned telemetry on; with instant error counts on, also the current day\'s error counts as they change. In the desktop app the user decides: the heartbeat with its usage info switch, and with crash report consent alone the error counts, with no heartbeat.', + off: 'Off by default. BULWARK_TELEMETRY=off, or System > Usage stats (switch it off or clear the collector URL). The desktop app has no admin page and takes no collector URL: its two switches in Settings, the TelemetryAllowed and CrashReportsAllowed policies, or BULWARK_TELEMETRY=off in its environment, which stops both.', inMode: 'Never sent, whatever the telemetry setting says.', lite: false, }, diff --git a/lib/admin/server-device-policy.ts b/lib/admin/server-device-policy.ts index 49b62b53e..7027c7222 100644 --- a/lib/admin/server-device-policy.ts +++ b/lib/admin/server-device-policy.ts @@ -93,7 +93,7 @@ export const ALL_DEVICE_LIMITS: ServerDevicePolicy = { ...Object.fromEntries( Object.entries(DEFAULT_ORGANISATION_POLICY.devices).map(([key, off]) => [key, typeof off === 'number' ? 1 : true]), ), - wipeOnRefusedLogin: false, + wipeOnForcedSignOut: false, } as ServerDevicePolicy; /** diff --git a/lib/app-lock/__tests__/controller.test.ts b/lib/app-lock/__tests__/controller.test.ts index 25384dc21..9d193247c 100644 --- a/lib/app-lock/__tests__/controller.test.ts +++ b/lib/app-lock/__tests__/controller.test.ts @@ -508,6 +508,23 @@ describe('the desktop app', () => { expect(isAppLockedNow()).toBe(false); }); + it('tells the OS integration when it locks, and its check says so until the unlock', async () => { + bridge(); + const setLocked = vi.fn(); + (window as unknown as { bulwarkDesktop: Record }).bulwarkDesktop.os = { setLocked }; + await enableAppLock(PIN); + expect(setLocked).not.toHaveBeenCalled(); + await lockedAndReloaded(); + expect(setLocked).toHaveBeenCalledWith(true); + setLocked.mockClear(); + asNewDocument(); + expect(setLocked, 'a new page behind the lock says so at once').toHaveBeenCalledWith(true); + expect(isAppLocked()).toBe(true); + await unlockWithPin(PIN); + // What components/desktop/desktop-os-host.tsx tells main as the app starts. + expect(isAppLocked()).toBe(false); + }); + it('a page takes the two calls a frame must not reach once, before any frame runs', async () => { const appLock = bridge(); bootAppLock(); diff --git a/lib/app-lock/controller.ts b/lib/app-lock/controller.ts index 068fc265f..df9478caa 100644 --- a/lib/app-lock/controller.ts +++ b/lib/app-lock/controller.ts @@ -288,8 +288,10 @@ async function settleCheck(): Promise { lockedFromNow(); } +/** Main's gate, and the OS integration's own state: tray, jump list and toasts (the shell's os-hub.ts). */ function tellDesktopLocked(): void { desktopControls()?.lock(); + getDesktopBridge()?.os?.setLocked(true); } /** The service worker shows push notifications with no page around. Kept across its restarts. */ diff --git a/lib/calendar-reminders/__tests__/claims.test.ts b/lib/calendar-reminders/__tests__/claims.test.ts new file mode 100644 index 000000000..7e60f078c --- /dev/null +++ b/lib/calendar-reminders/__tests__/claims.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { claimAlert, claimKey, claimNow, CLAIMS_STORAGE_KEY, firedWithoutStore, HIDDEN_GRACE_MS, noteStoreFired } from '../claims'; + +const FIRE = Date.UTC(2026, 10, 10, 9); + +function setVisibility(state: 'visible' | 'hidden') { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state }); +} + +/** A Web Lock: one holder at a time, across "tabs". */ +function fakeLocks() { + let tail: Promise = Promise.resolve(); + return { + request: vi.fn((_name: string, callback: () => T | Promise) => { + const run = tail.then(callback); + tail = run.catch(() => {}); + return run; + }), + }; +} + +beforeEach(() => { + localStorage.clear(); + setVisibility('visible'); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + Reflect.deleteProperty(navigator, 'locks'); +}); + +describe('claimNow', () => { + it('says yes once', () => { + const key = claimKey('acc', 'uid-1', 'a', FIRE); + expect(claimNow(key, FIRE)).toBe(true); + expect(claimNow(key, FIRE)).toBe(false); + }); + + it('tells occurrences and accounts apart', () => { + expect(claimNow(claimKey('acc', 'uid-1', 'a', FIRE), FIRE)).toBe(true); + expect(claimNow(claimKey('acc', 'uid-1', 'a', FIRE + 1000), FIRE + 1000)).toBe(true); + expect(claimNow(claimKey('other', 'uid-1', 'a', FIRE), FIRE)).toBe(true); + }); + + it('forgets what is a day old', () => { + vi.useFakeTimers({ now: FIRE }); + claimNow('old', FIRE - 25 * 3_600_000); + claimNow('new', FIRE); + expect(Object.keys(JSON.parse(localStorage.getItem(CLAIMS_STORAGE_KEY)!))).toEqual(['new']); + }); + + it('tells what the store path rang from what the other path rang', () => { + const key = claimKey('acc', 'uid-1', 'a', FIRE); + expect(noteStoreFired(key, FIRE)).toBe(true); + // The path without a store won't ring it again... + expect(claimNow(key, FIRE)).toBe(false); + // ...and a summary still may, since the store path keeps its own record. + expect(firedWithoutStore(key)).toBe(false); + expect(noteStoreFired(key, FIRE)).toBe(true); + const other = claimKey('acc', 'uid-2', 'a', FIRE); + claimNow(other, FIRE); + expect(firedWithoutStore(other)).toBe(true); + expect(noteStoreFired(other, FIRE)).toBe(false); + }); + + it('holds only the key, nothing of the event', () => { + claimNow(claimKey('acc', 'uid-1', 'a', FIRE), FIRE); + expect(localStorage.getItem(CLAIMS_STORAGE_KEY)).toBe(JSON.stringify({ [`acc:uid-1:a:${FIRE}`]: FIRE })); + }); +}); + +describe('claimAlert', () => { + it('reads and writes the list only with the lock held, one tab at a time', async () => { + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + const asked: string[] = []; + let queue: Promise = held; + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { + request: (name: string, callback: () => T | Promise) => { + asked.push(name); + const run = queue.then(callback); + queue = run; + return run; + }, + }, + }); + const won = vi.fn(); + claimAlert('k', FIRE, () => won('tab 1')); + claimAlert('k', FIRE, () => won('tab 2')); + await new Promise((resolve) => setTimeout(resolve, 0)); + // Another tab has the lock: nothing has looked at the list yet. + expect(localStorage.getItem(CLAIMS_STORAGE_KEY)).toBeNull(); + expect(won).not.toHaveBeenCalled(); + expect(asked).toEqual(['bulwark-calendar-alert-claim', 'bulwark-calendar-alert-claim']); + + release(); + await vi.waitFor(() => expect(won).toHaveBeenCalled()); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(won.mock.calls).toEqual([['tab 1']]); + }); + + it('still rings when the lock fails, rather than losing the reminder', async () => { + Object.defineProperty(navigator, 'locks', { configurable: true, value: { request: () => Promise.reject(new Error('Locks are off in this context')) } }); + const won = vi.fn(); + claimAlert('k', FIRE, won); + await vi.waitFor(() => expect(won).toHaveBeenCalledTimes(1)); + }); + + it('runs at once where there is no Web Lock', () => { + const won = vi.fn(); + claimAlert('k', FIRE, won); + expect(won).toHaveBeenCalledTimes(1); + }); + + it('lets a tab in view go first', () => { + vi.useFakeTimers({ now: FIRE }); + setVisibility('hidden'); + const hidden = vi.fn(); + claimAlert('k', FIRE, hidden); + setVisibility('visible'); + const visible = vi.fn(); + claimAlert('k', FIRE, visible); + expect(visible).toHaveBeenCalledTimes(1); + vi.advanceTimersByTime(HIDDEN_GRACE_MS); + expect(hidden).not.toHaveBeenCalled(); + }); + + it('still fires in a hidden tab when no other took it', () => { + vi.useFakeTimers({ now: FIRE }); + setVisibility('hidden'); + const won = vi.fn(); + claimAlert('k', FIRE, won); + expect(won).not.toHaveBeenCalled(); + vi.advanceTimersByTime(HIDDEN_GRACE_MS); + expect(won).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/calendar-reminders/__tests__/runner.test.ts b/lib/calendar-reminders/__tests__/runner.test.ts new file mode 100644 index 000000000..a9639f899 --- /dev/null +++ b/lib/calendar-reminders/__tests__/runner.test.ts @@ -0,0 +1,356 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar'; +import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store'; +import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger'; +import { HIDDEN_GRACE_MS } from '../claims'; +import { createReminderRunner, SUMMARY_WAIT_MS, type ReminderRunner, type ReminderRunnerEnv } from '../runner'; + +const MIN = 60_000; +const ACCOUNT = 'acc'; +const START = Date.UTC(2026, 10, 10, 9); +const FIRE = START - 15 * MIN; +const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } }; + +function event(partial: Record = {}) { + return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial }; +} + +interface Tab { + runner: ReminderRunner; + due: ReminderAlert[]; + firedAt: number[]; + missed: MissedBatch[]; + source: boolean[]; + env: ReminderRunnerEnv; + hidden: boolean; + offline: boolean; + muted: boolean; +} + +let store: InProcessStore; +let server: FakeCalendarJmap; +const tabs: Tab[] = []; + +function tab(options: Partial> = {}): Tab { + const made = { due: [], firedAt: [], missed: [], source: [], hidden: false, offline: false, muted: false, ...options } as unknown as Tab; + made.env = { + accounts: () => [ACCOUNT], + store: () => store, + params: () => ({ timeZone: 'Europe/Berlin', tasks: true, muted: made.muted }), + needsPolling: () => false, + hidden: () => made.hidden, + offline: () => made.offline, + onDue: (_account, alerts) => { + made.due.push(...alerts); + made.firedAt.push(...alerts.map(() => Date.now())); + }, + onMissed: async (account) => { + const batch = await store.area(account, 'reminders.takeMissed', { timeZone: 'Europe/Berlin' }); + if (!batch) return; + made.missed.push(batch); + await store.area(account, 'reminders.settleMissed', { batch: batch.batch }); + }, + onSource: (_account, driven) => { + made.source.push(driven); + }, + }; + made.runner = createReminderRunner(made.env); + tabs.push(made); + return made; +} + +async function syncedStore(...events: Record[]): Promise { + for (const partial of events) server.addEvent(partial); + await store.sync(ACCOUNT); +} + +beforeEach(() => { + vi.useFakeTimers({ now: FIRE - 30 * MIN, toFake: ['Date', 'setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); + server = new FakeCalendarJmap('1.0'); + store = new InProcessStore(server, CALENDAR_SESSION); +}); + +afterEach(() => { + for (const made of tabs.splice(0)) made.runner.stop(); + vi.useRealTimers(); +}); + +describe('firing from the store', () => { + it('fires at the moment it is due, with the network gone', async () => { + await syncedStore(event()); + await store.setOnline(false); + server.request = (() => { + throw new Error('A reminder needs no server'); + }) as unknown as FakeCalendarJmap['request']; + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() - 1000); + expect(one.due).toEqual([]); + await vi.advanceTimersByTimeAsync(1000); + expect(one.due.map((alert) => alert.title)).toEqual(['Standup']); + expect(one.firedAt).toEqual([FIRE]); + }); + + it('takes over from the old path once the store has synced, and gives it back when the store fails', async () => { + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(0); + expect(one.source.at(-1)).toBe(false); + await syncedStore(event()); + one.runner.poke(); + await vi.advanceTimersByTimeAsync(0); + expect(one.source.at(-1)).toBe(true); + store.failArea = () => true; + one.runner.poke(); + await vi.advanceTimersByTimeAsync(0); + expect(one.source.at(-1)).toBe(false); + }); + + it('fires in one tab only, when both ask at the same moment', async () => { + await syncedStore(event()); + const [one, two] = [tab(), tab()]; + one.runner.start(); + two.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() + MIN); + expect(one.due.length + two.due.length).toBe(1); + }); + + it('leaves it to the tab in view when another is hidden', async () => { + await syncedStore(event()); + const hidden = tab({ hidden: true }); + const visible = tab(); + hidden.runner.start(); + await vi.advanceTimersByTimeAsync(0); + visible.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() + HIDDEN_GRACE_MS + MIN); + expect(visible.due).toHaveLength(1); + expect(hidden.due).toHaveLength(0); + }); + + it('still fires when only a hidden tab is left', async () => { + await syncedStore(event()); + const hidden = tab({ hidden: true }); + hidden.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() + HIDDEN_GRACE_MS); + expect(hidden.due).toHaveLength(1); + }); + + it('notices an event that just arrived, and a snooze just set', async () => { + await syncedStore(); + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() - 2 * MIN); + await syncedStore(event()); + one.runner.poke(); + await vi.advanceTimersByTimeAsync(2 * MIN); + expect(one.firedAt).toEqual([FIRE]); + + await store.area(ACCOUNT, 'reminders.snooze', { key: one.due[0].key, minutes: 5 }); + one.runner.poke(); + await vi.advanceTimersByTimeAsync(5 * MIN); + expect(one.firedAt).toEqual([FIRE, FIRE + 5 * MIN]); + }); + + it('brings a snoozed reminder back after a restart', async () => { + await syncedStore(event()); + const before = tab(); + before.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now()); + await store.area(ACCOUNT, 'reminders.snooze', { key: before.due[0].key, minutes: 10 }); + before.runner.stop(); + await vi.advanceTimersByTimeAsync(MIN); + + const after = tab(); + after.runner.start(); + await vi.advanceTimersByTimeAsync(10 * MIN); + expect(before.due).toHaveLength(1); + expect(after.firedAt).toEqual([FIRE + 10 * MIN]); + expect(after.due[0].snoozedUntil).toBe(FIRE + 10 * MIN); + }); + + it('stays quiet while reminders are off', async () => { + await syncedStore(event()); + const one = tab(); + one.muted = true; + one.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() + MIN); + expect(one.due).toEqual([]); + }); +}); + +describe('a tab out of view', () => { + it('waits before any check, also after a wake, so the tab in view gets the alert', async () => { + await syncedStore(event()); + const hidden = tab({ hidden: true }); + const visible = tab(); + hidden.runner.start(); + await vi.advanceTimersByTimeAsync(HIDDEN_GRACE_MS); + visible.runner.start(); + await vi.advanceTimersByTimeAsync(0); + vi.setSystemTime(FIRE + MIN); + hidden.runner.wake(); + visible.runner.wake(); + await vi.advanceTimersByTimeAsync(HIDDEN_GRACE_MS); + expect(hidden.due).toEqual([]); + expect(visible.due).toHaveLength(1); + }); + + it('waits on a poke and on a sync too', async () => { + await syncedStore(event()); + const hidden = tab({ hidden: true }); + hidden.runner.start(); + await vi.advanceTimersByTimeAsync(HIDDEN_GRACE_MS); + const area = vi.spyOn(store, 'area'); + hidden.runner.poke(); + hidden.runner.synced(ACCOUNT); + await vi.advanceTimersByTimeAsync(HIDDEN_GRACE_MS - 1); + expect(area).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(area).toHaveBeenCalledTimes(1); + }); + + it('a stream of pokes cannot put it off for ever', async () => { + await syncedStore(event()); + const hidden = tab({ hidden: true }); + hidden.runner.start(); + await vi.advanceTimersByTimeAsync(HIDDEN_GRACE_MS); + const area = vi.spyOn(store, 'area'); + for (let i = 0; i < 5; i += 1) { + hidden.runner.poke(); + await vi.advanceTimersByTimeAsync(HIDDEN_GRACE_MS / 2); + } + expect(area).toHaveBeenCalled(); + }); +}); + +describe('going down in the middle', () => { + it('keeps what the store just handed over when the page stopped meanwhile', async () => { + await syncedStore(event()); + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() - 1); + const area = store.area.bind(store); + vi.spyOn(store, 'area').mockImplementationOnce(async (account: string, name: string, params?: unknown) => { + const result = await area(account, name, params); + one.runner.stop(); + return result as never; + }); + await vi.advanceTimersByTimeAsync(1); + expect(one.due).toEqual([]); + // Not lost: it waits for the summary. + await vi.advanceTimersByTimeAsync(0); + const next = tab({ offline: true }); + next.runner.start(); + await vi.advanceTimersByTimeAsync(0); + expect(next.missed.flatMap((batch) => batch.alerts.map((alert) => alert.title))).toEqual(['Standup']); + }); + + it('checks again after showing one threw', async () => { + await syncedStore(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review', start: '2026-11-10T10:05:00' })); + const one = tab(); + const plain = one.env.onDue; + let first = true; + one.env.onDue = (account, alerts) => { + if (first) { + first = false; + throw new Error('A plugin threw'); + } + plain(account, alerts); + }; + one.runner.start(); + await vi.advanceTimersByTimeAsync(FIRE - Date.now() + 5 * MIN); + expect(one.due.map((alert) => alert.title)).toEqual(['Review']); + }); +}); + +describe('after being away', () => { + async function awayFromBothAlerts(): Promise { + await syncedStore(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + const before = tab(); + before.runner.start(); + await vi.advanceTimersByTimeAsync(0); + before.runner.stop(); + vi.setSystemTime(FIRE + 60 * MIN); + return before; + } + + it('waits a moment for the first sync, then summarises once, not as a burst', async () => { + await awayFromBothAlerts(); + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS - 1000); + expect(one.missed).toEqual([]); + await vi.advanceTimersByTimeAsync(1000); + expect(one.due).toEqual([]); + expect(one.missed.map((batch) => batch.alerts.map((alert) => alert.title))).toEqual([['Standup', 'Review']]); + + await vi.advanceTimersByTimeAsync(30 * MIN); + one.runner.wake(); + await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS); + expect(one.missed).toHaveLength(1); + }); + + it('does not wait once the sync is done, and has nothing to wait for offline', async () => { + await awayFromBothAlerts(); + const synced = tab(); + synced.runner.start(); + await vi.advanceTimersByTimeAsync(100); + synced.runner.synced(ACCOUNT); + await vi.advanceTimersByTimeAsync(0); + expect(synced.missed).toHaveLength(1); + }); + + it('gives the summary at once with no network', async () => { + await awayFromBothAlerts(); + const offline = tab({ offline: true }); + offline.runner.start(); + await vi.advanceTimersByTimeAsync(0); + expect(offline.missed).toHaveLength(1); + }); + + it('gives the summary to one window of two, and neither again after a restart', async () => { + await awayFromBothAlerts(); + const [one, two] = [tab({ offline: true }), tab({ offline: true })]; + one.runner.start(); + two.runner.start(); + await vi.advanceTimersByTimeAsync(0); + expect(one.missed.length + two.missed.length).toBe(1); + one.runner.stop(); + two.runner.stop(); + await vi.advanceTimersByTimeAsync(MIN); + const next = tab({ offline: true }); + next.runner.start(); + await vi.advanceTimersByTimeAsync(SUMMARY_WAIT_MS); + expect(next.missed).toEqual([]); + }); + + it('sees a jump of the clock without being told, as after a sleep', async () => { + await syncedStore(event(), event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + const one = tab(); + one.runner.start(); + await vi.advanceTimersByTimeAsync(MIN); + // Asleep: timers stood still and the clock did not. + vi.setSystemTime(FIRE + 60 * MIN); + await vi.advanceTimersByTimeAsync(MIN + SUMMARY_WAIT_MS); + expect(one.due).toEqual([]); + expect(one.missed.map((batch) => batch.alerts.length)).toEqual([1 + 1]); + }); + + it('tries a summary again that could not be shown', async () => { + await awayFromBothAlerts(); + const one = tab({ offline: true }); + let refuse = true; + const plain = one.env.onMissed; + one.env.onMissed = async (account) => { + if (!refuse) return plain(account); + const batch = await store.area(account, 'reminders.takeMissed', { timeZone: 'Europe/Berlin' }); + await store.area(account, 'reminders.releaseMissed', { batch: batch.batch }); + }; + one.runner.start(); + await vi.advanceTimersByTimeAsync(0); + expect(one.missed).toEqual([]); + refuse = false; + await vi.advanceTimersByTimeAsync(5 * MIN); + expect(one.missed).toHaveLength(1); + }); +}); diff --git a/lib/calendar-reminders/channel.ts b/lib/calendar-reminders/channel.ts new file mode 100644 index 000000000..9e32f74ac --- /dev/null +++ b/lib/calendar-reminders/channel.ts @@ -0,0 +1,32 @@ +/** + * Telling the other windows that a reminder has been dealt with, so its toast and system + * notification go there too. What's done is recorded in the store; this only clears what's + * on screen. Only keys cross it, and they say nothing about the event but its uid. + */ + +const CHANNEL_NAME = 'bulwark-calendar-reminders'; + +interface AcknowledgedMessage { + kind: 'acknowledged'; + keys: string[]; +} + +function open(): BroadcastChannel | null { + return typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(CHANNEL_NAME); +} + +export function announceAcknowledged(keys: readonly string[]): void { + const channel = open(); + if (!channel) return; + channel.postMessage({ kind: 'acknowledged', keys: [...keys] } satisfies AcknowledgedMessage); + channel.close(); +} + +export function onAcknowledged(listener: (keys: string[]) => void): () => void { + const channel = open(); + if (!channel) return () => {}; + channel.onmessage = (event: MessageEvent) => { + if (event.data?.kind === 'acknowledged' && Array.isArray(event.data.keys)) listener(event.data.keys); + }; + return () => channel.close(); +} diff --git a/lib/calendar-reminders/claims.ts b/lib/calendar-reminders/claims.ts new file mode 100644 index 000000000..d50dc4035 --- /dev/null +++ b/lib/calendar-reminders/claims.ts @@ -0,0 +1,95 @@ +/** + * One tab fires a reminder when there's no local store to decide it. The list sits in + * localStorage and holds no more than the event's uid, and the Web Lock makes reading + * and writing it one step across tabs: their timers run out at the same moment, so a + * plain read and write would let both through. + * + * Keys are the series' uid, never the id: synthetic ids reshuffle when an override + * changes, and a reminder fired again inside its ten minutes. + * + * The store path notes what it rang under its own prefix. That keeps the other path off + * it at a handover, and doesn't count against the store's own summary later: a reminder + * it rang and snoozed can still be missed. + */ + +const STORAGE_KEY = 'calendar-alert-claims'; +const LOCK = 'bulwark-calendar-alert-claim'; +const KEEP_MS = 24 * 60 * 60 * 1000; +const STORE_PREFIX = 'store:'; +/** A tab out of view waits, so the one in view is who shows the reminder. */ +export const HIDDEN_GRACE_MS = 2000; + +export function claimKey(account: string | null | undefined, uid: string, alertId: string, fireTimeMs: number): string { + return `${account ?? ''}:${uid}:${alertId}:${fireTimeMs}`; +} + +function readClaims(): Record { + try { + const parsed = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as unknown; + return parsed && typeof parsed === 'object' ? parsed as Record : {}; + } catch { + return {}; + } +} + +function writeClaim(claims: Record, key: string, fireTimeMs: number): void { + const now = Date.now(); + const kept = Object.fromEntries(Object.entries(claims).filter(([, fire]) => now - fire < KEEP_MS)); + kept[key] = fireTimeMs; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(kept)); + } catch { + // Without storage each tab is on its own, as it always was. + } +} + +/** True for the first to ask. */ +export function claimNow(key: string, fireTimeMs: number): boolean { + const claims = readClaims(); + if (key in claims || `${STORE_PREFIX}${key}` in claims) return false; + writeClaim(claims, key, fireTimeMs); + return true; +} + +/** The path without a store rang this one. */ +export function firedWithoutStore(key: string): boolean { + return key in readClaims(); +} + +/** The store path is ringing this. False when the other path already did. */ +export function noteStoreFired(key: string, fireTimeMs: number): boolean { + const claims = readClaims(); + if (key in claims) return false; + writeClaim(claims, `${STORE_PREFIX}${key}`, fireTimeMs); + return true; +} + +/** Runs `won` in the one tab that gets the reminder. At once, when nothing makes it wait. */ +export function claimAlert(key: string, fireTimeMs: number, won: () => void): void { + const direct = () => { + if (claimNow(key, fireTimeMs)) won(); + }; + const attempt = () => { + const locks = typeof navigator === 'undefined' ? undefined : navigator.locks; + if (!locks) { + direct(); + return; + } + void locks.request(LOCK, () => claimNow(key, fireTimeMs)).then((claimed) => { + if (claimed) won(); + }, direct); + }; + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') setTimeout(attempt, HIDDEN_GRACE_MS); + else attempt(); +} + +/** A sign-out: the next person starts with a clean slate. */ +export function clearClaims(): void { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // Nothing was kept, then. + } +} + +export const CLAIMS_STORAGE_KEY = STORAGE_KEY; diff --git a/lib/calendar-reminders/runner.ts b/lib/calendar-reminders/runner.ts new file mode 100644 index 000000000..876f85ade --- /dev/null +++ b/lib/calendar-reminders/runner.ts @@ -0,0 +1,194 @@ +/** + * Asks the local store what's due, at the moment it's due. Runs in the page: in the + * desktop app that's the main window, which keeps its timers while hidden in the tray, + * and the store's worker answers with no network. Every tab or window may run one: the + * store hands each alert to whichever asks first, so a tab out of view waits a moment + * before every check, whatever set it off. + * + * Between checks it sleeps until the store's next fire time, and looks in every few + * minutes in case something changed with nobody saying so. + */ + +import type { StoreBackend } from '@/lib/store/client/api'; +import type { ReminderAlert, TickParams, TickResult } from '@/lib/store/reminders/ledger'; +import { HIDDEN_GRACE_MS } from './claims'; + +const REFRESH_MS = 5 * 60_000; +/** Timers stand still while a machine sleeps. The clock doesn't. */ +const CLOCK_WATCH_MS = 60_000; +const CLOCK_JUMP_MS = 2 * CLOCK_WATCH_MS; +/** + * After a start or a wake, how long a summary waits for the first sync, so it leaves out + * what was deleted elsewhere in the meantime. Reminders that are due don't wait. + */ +export const SUMMARY_WAIT_MS = 20_000; + +export interface ReminderRunnerEnv { + /** The local accounts with a store right now. */ + accounts(): string[]; + store(): Pick; + params(): TickParams; + /** Out of view: another tab in view should get to go first. */ + hidden(): boolean; + offline(): boolean; + /** No push channel to say the calendar changed, so the store has to go and look. */ + needsPolling(account: string): boolean; + /** Runs in the one tab that got these. */ + onDue(account: string, alerts: ReminderAlert[]): void; + /** There's a summary to give. Resolves when that's been tried. */ + onMissed(account: string): Promise; + /** Whether this account's reminders come from the store now, or the app's old path keeps them. */ + onSource(account: string, driven: boolean): void; +} + +export interface ReminderRunner { + start(): void; + stop(): void; + /** Check now: the calendar changed, a snooze was set. */ + poke(): void; + /** Back from sleep, or back online. */ + wake(): void; + /** The store finished a sync for this account. */ + synced(account: string): void; +} + +export function createReminderRunner(env: ReminderRunnerEnv): ReminderRunner { + let timer: ReturnType | null = null; + let timerAt = 0; + let clockWatch: ReturnType | null = null; + let lastWatch = 0; + let running = false; + let again = false; + let stopped = true; + let summaryAt = 0; + const presenting = new Set(); + const known = new Set(); + const lastPoll = new Map(); + + const schedule = (at: number) => { + if (timer) clearTimeout(timer); + timerAt = at + (env.hidden() ? HIDDEN_GRACE_MS : 0); + timer = setTimeout(() => void run(), Math.max(timerAt - Date.now(), 0)); + }; + + /** At once in a tab in view. Out of view, after the grace, however often it's asked. */ + const request = () => { + if (stopped) return; + if (!env.hidden()) { + void run(); + return; + } + if (timer && timerAt <= Date.now() + HIDDEN_GRACE_MS) return; + schedule(Date.now()); + }; + + const poll = (account: string) => { + const now = Date.now(); + if (!lastPoll.has(account)) lastPoll.set(account, now); + if (now - lastPoll.get(account)! < REFRESH_MS || env.offline() || !env.needsPolling(account)) return; + lastPoll.set(account, now); + void env.store().sync(account).catch(() => {}); + }; + + const check = async (account: string): Promise => { + let result: TickResult; + try { + result = await env.store().area(account, 'reminders.tick', env.params()); + } catch { + // The store is out of reach: the old path takes these over until it's back. + if (!stopped) env.onSource(account, false); + return null; + } + // Stopped while asking: the store has given these to this page and nobody will show them. + if (stopped) { + if (result.due.length > 0) { + await env.store().area(account, 'reminders.hold', { keys: result.due.map((alert) => alert.key) }).catch(() => {}); + } + return null; + } + env.onSource(account, result.ready); + if (result.due.length > 0) env.onDue(account, result.due); + if (result.missed === 0) return result.nextAt; + const wait = env.offline() ? 0 : summaryAt - Date.now(); + if (wait > 0) return result.nextAt === null ? summaryAt : Math.min(result.nextAt, summaryAt); + if (!presenting.has(account)) { + presenting.add(account); + void env.onMissed(account) + .catch(() => {}) + .finally(() => presenting.delete(account)); + } + return result.nextAt; + }; + + const run = async () => { + if (stopped) return; + if (running) { + again = true; + return; + } + running = true; + let next = Date.now() + REFRESH_MS; + try { + const accounts = env.accounts(); + for (const gone of [...known]) { + if (!accounts.includes(gone)) { + known.delete(gone); + env.onSource(gone, false); + } + } + for (const account of accounts) { + known.add(account); + poll(account); + const at = await check(account); + if (at !== null) next = Math.min(next, at); + } + } catch (error) { + // A plugin or a toast threw on the way out. The store has the rest for the next check. + console.error('A reminder check failed:', error); + } finally { + running = false; + } + if (stopped) return; + if (again) { + again = false; + request(); + return; + } + schedule(next); + }; + + return { + start() { + stopped = false; + summaryAt = Date.now() + SUMMARY_WAIT_MS; + lastWatch = Date.now(); + clockWatch = setInterval(() => { + const now = Date.now(); + const jumped = now - lastWatch > CLOCK_JUMP_MS; + lastWatch = now; + if (jumped) this.wake(); + }, CLOCK_WATCH_MS); + request(); + }, + stop() { + stopped = true; + if (timer) clearTimeout(timer); + if (clockWatch) clearInterval(clockWatch); + timer = null; + clockWatch = null; + for (const account of known) env.onSource(account, false); + known.clear(); + }, + poke() { + request(); + }, + wake() { + summaryAt = Date.now() + SUMMARY_WAIT_MS; + request(); + }, + synced() { + summaryAt = 0; + request(); + }, + }; +} diff --git a/lib/calendar-reminders/state.ts b/lib/calendar-reminders/state.ts new file mode 100644 index 000000000..109df52ee --- /dev/null +++ b/lib/calendar-reminders/state.ts @@ -0,0 +1,55 @@ +/** + * What the reminder code shares across the page, kept free of the rest of the app so + * useCalendarAlerts can read it without pulling in the local store. + */ + +import { create } from 'zustand'; +import type { ReminderAlert } from '@/lib/store/reminders/ledger'; + +interface ReminderSourceState { + /** Local accounts whose reminders come from the store: useCalendarAlerts leaves those alone. */ + driven: Record; + setDriven: (account: string, driven: boolean) => void; +} + +export const useReminderSource = create()((set) => ({ + driven: {}, + setDriven: (account, driven) => set((state) => { + if (!!state.driven[account] === driven) return state; + const next = { ...state.driven }; + if (driven) next[account] = true; + else delete next[account]; + return { driven: next }; + }), +})); + +interface MissedRemindersState { + account: string | null; + alerts: ReminderAlert[]; + show: (account: string, alerts: ReminderAlert[]) => void; + close: () => void; +} + +/** The summary dialog's contents. Empty: it's closed. */ +export const useMissedReminders = create()((set) => ({ + account: null, + alerts: [], + show: (account, alerts) => set({ account, alerts }), + close: () => set({ account: null, alerts: [] }), +})); + +let poke: (() => void) | null = null; + +/** The running check, so something done from a toast takes effect at once. */ +export function setReminderPoke(next: (() => void) | null): void { + poke = next; +} + +export function pokeReminders(): void { + poke?.(); +} + +/** The zone that reads a floating event: the setting's, or the system's on `auto`. */ +export function reminderTimeZone(setting: string | undefined): string { + return setting && setting !== 'auto' ? setting : Intl.DateTimeFormat().resolvedOptions().timeZone; +} diff --git a/lib/calendar-reminders/system-notification.ts b/lib/calendar-reminders/system-notification.ts new file mode 100644 index 000000000..2427f7c19 --- /dev/null +++ b/lib/calendar-reminders/system-notification.ts @@ -0,0 +1,73 @@ +/** + * A browser's system notification for a reminder, where the user has already granted the + * permission. This never asks for it. Through the service worker where there is one, since + * Chrome on Android refuses `new Notification`. The tag keeps one per reminder, whichever + * tab asks. + */ + +export interface SystemReminder { + key: string; + title: string; + body: string; + /** Do not disturb: on screen, but quiet. */ + silent: boolean; + /** Says nothing about the event: the app is locked, or the organisation says so. */ + hidden?: { title: string }; +} + +function tagOf(key: string): string { + return `calendar-reminder:${key}`; +} + +function permitted(): boolean { + return typeof Notification !== 'undefined' && Notification.permission === 'granted'; +} + +async function registration(): Promise { + try { + return await navigator.serviceWorker?.getRegistration(); + } catch { + return undefined; + } +} + +/** In front of the user the in-app toast says it already. */ +export function wantsSystemNotification(): boolean { + if (!permitted() || typeof document === 'undefined') return false; + return document.visibilityState === 'hidden' || !document.hasFocus(); +} + +export async function showSystemReminder(reminder: SystemReminder, openUrl: string): Promise { + if (!wantsSystemNotification()) return; + const title = reminder.hidden ? reminder.hidden.title : reminder.title; + const options = { body: reminder.hidden ? '' : reminder.body, tag: tagOf(reminder.key), silent: reminder.silent }; + try { + const worker = await registration(); + if (worker) { + await worker.showNotification(title, { ...options, data: { kind: 'calendar-reminder', url: openUrl } }); + return; + } + const shown = new Notification(title, options); + shown.onclick = () => { + window.focus(); + window.location.href = openUrl; + shown.close(); + }; + } catch (error) { + console.warn('A system notification could not be shown:', error); + } +} + +/** Dealt with elsewhere: it shouldn't stay in the notification list. */ +export async function closeSystemReminders(keys: readonly string[]): Promise { + if (!permitted() || keys.length === 0) return; + const worker = await registration(); + if (!worker) return; + try { + for (const key of keys) { + for (const shown of await worker.getNotifications({ tag: tagOf(key) })) shown.close(); + } + } catch { + // Nothing that matters is left behind. + } +} diff --git a/lib/desktop/__tests__/desktop-telemetry.test.ts b/lib/desktop/__tests__/desktop-telemetry.test.ts new file mode 100644 index 000000000..1d7b0bc21 --- /dev/null +++ b/lib/desktop/__tests__/desktop-telemetry.test.ts @@ -0,0 +1,160 @@ +// @vitest-environment node +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { loadCollectorValidator } from '@/lib/__tests__/collector-validator'; +import { + DESKTOP_CATCH_UP_MAX_MS, + DESKTOP_CATCH_UP_MIN_MS, + DESKTOP_HEARTBEAT_INTERVAL_MS, + buildDesktopErrorBatch, + buildDesktopPing, + consentInForce, + desktopBuildTag, + desktopCatchUpMs, + desktopChannel, + desktopOsFamily, + forcedOffByPolicy, + heartbeatDelayMs, +} from '../desktop-telemetry'; + +const collector = await loadCollectorValidator(); + +const NOT_SET = { value: true, locked: false }; +const FORCED_OFF = { value: false, locked: true }; +const FORCED_ON = { value: true, locked: true }; +const RECOMMENDED_OFF = { value: false, locked: false }; + +describe('consent', () => { + it.each([ + [undefined, NOT_SET, false], + [null, NOT_SET, false], + [false, NOT_SET, false], + [true, NOT_SET, true], + // A policy forcing it off beats the user's yes. + [true, FORCED_OFF, false], + // A policy allowing it doesn't answer for the user. + [undefined, FORCED_ON, false], + [true, FORCED_ON, true], + // Only forced counts as forced. + [true, RECOMMENDED_OFF, true], + [undefined, RECOMMENDED_OFF, false], + ])('choice %s under %j is %s', (choice, policy, expected) => { + expect(consentInForce(choice, policy)).toBe(expected); + }); + + it('knows forced off from the rest', () => { + expect(forcedOffByPolicy(FORCED_OFF)).toBe(true); + expect(forcedOffByPolicy(RECOMMENDED_OFF)).toBe(false); + expect(forcedOffByPolicy(FORCED_ON)).toBe(false); + }); +}); + +describe('when a heartbeat is due', () => { + const now = Date.parse('2026-11-03T12:00:00Z'); + const CATCH_UP = 123_000; + + it('soon, with none sent yet or the last one long ago', () => { + expect(heartbeatDelayMs(undefined, now, CATCH_UP)).toBe(CATCH_UP); + expect(heartbeatDelayMs(null, now, CATCH_UP)).toBe(CATCH_UP); + expect(heartbeatDelayMs(now - 3 * DESKTOP_HEARTBEAT_INTERVAL_MS, now, CATCH_UP)).toBe(CATCH_UP); + }); + + it('a day after the last, and not sooner', () => { + expect(heartbeatDelayMs(now - 3_600_000, now, CATCH_UP)).toBe(DESKTOP_HEARTBEAT_INTERVAL_MS - 3_600_000); + }); + + it('soon again if the clock went back', () => { + expect(heartbeatDelayMs(now + 3_600_000, now, CATCH_UP)).toBe(CATCH_UP); + }); + + it('soon means somewhere from 30 s to 10 min, so a start time can\'t be read from it', () => { + expect(desktopCatchUpMs(() => 0)).toBe(30_000); + expect(desktopCatchUpMs(() => 0.5)).toBe(315_000); + expect(desktopCatchUpMs(() => 0.999999)).toBeLessThanOrEqual(600_000); + expect(desktopCatchUpMs(() => 0.999999)).toBeGreaterThan(599_000); + expect(DESKTOP_CATCH_UP_MIN_MS).toBe(30_000); + expect(DESKTOP_CATCH_UP_MAX_MS).toBe(600_000); + const seen = new Set(Array.from({ length: 50 }, () => desktopCatchUpMs())); + expect(seen.size).toBeGreaterThan(1); + for (const value of seen) { + expect(value).toBeGreaterThanOrEqual(30_000); + expect(value).toBeLessThanOrEqual(600_000); + } + expect(heartbeatDelayMs(null, now)).toBeLessThanOrEqual(600_000); + }); +}); + +describe('the shell\'s own heartbeat', () => { + const ping = () => buildDesktopPing({ + instanceId: 'f0c1a2b3-c4d5-4e6f-8a9b-0c1d2e3f4a5b', + appVersion: '0.3.0-beta.1', + nodeVersion: '22.11.0', + osFamily: 'windows', + mode: 'instance', + channel: 'beta', + now: Date.parse('2026-11-03T12:00:00Z'), + }); + + it('claims nothing that is the instance\'s to report', () => { + const body = ping(); + expect(Object.values(body.features)).toEqual([false, false, false, false, false, false]); + expect(body.counts).toEqual({ accounts: '0', accounts_active_7d: '0', extensions_installed: 0, themes_installed: 0 }); + expect(body.stalwart_version).toBeNull(); + expect(body.instance_id).toBe('f0c1a2b3-c4d5-4e6f-8a9b-0c1d2e3f4a5b'); + }); + + it.skipIf(!collector)('is what the real collector accepts, as schema 1 and as schema 2', () => { + const clean = collector!.validate(ping())!; + expect(clean).toMatchObject({ schema: '1', build: 'desktop.instance.beta', version: '0.3.0-beta.1', os_family: 'windows', node_version: '22.11.0' }); + expect(clean.platform).toBe('desktop'); + // Any extra top-level field would make schema 2 refuse the whole heartbeat. + expect(collector!.validate({ ...ping(), schema: '2', errors: [] })).not.toBeNull(); + }); + + it.skipIf(!collector)('main\'s error batch is what the real collector accepts', () => { + const now = Date.now(); + const batch = buildDesktopErrorBatch('f0c1a2b3-c4d5-4e6f-8a9b-0c1d2e3f4a5b', now, [ + { name: 'TypeError', fingerprint: '0123456789abcdef', version: '0.3.0+desktop', count: 7 }, + ]); + const clean = collector!.validateErrorBatch(batch)!; + expect(clean).not.toBeNull(); + expect(collector!.validateErrorBatch(buildDesktopErrorBatch('f0c1a2b3-c4d5-4e6f-8a9b-0c1d2e3f4a5b', now, [ + { name: 'TypeError', fingerprint: '0123456789abcdef', version: '0.3.0-beta.1+desktop', count: 1 }, + ]))).not.toBeNull(); + expect(clean.errors).toEqual([expect.objectContaining({ name: 'TypeError', count: '6-10', version: '0.3.0+desktop', days: 1, frames: [] })]); + }); + + it('fits the collector\'s pattern for build on every mode and channel', () => { + for (const mode of ['standalone', 'instance'] as const) { + for (const channel of ['stable', 'beta'] as const) { + expect(desktopBuildTag(mode, channel)).toMatch(/^[\w.+-]{1,64}$/); + } + } + }); + + it('names the channel: forced, then the user, then the version', () => { + expect(desktopChannel({ value: 'stable', locked: true }, 'beta', '1.0.0-beta.1')).toBe('stable'); + expect(desktopChannel({ value: 'stable', locked: false }, 'beta', '1.0.0')).toBe('beta'); + expect(desktopChannel({ value: 'stable', locked: false }, undefined, '1.0.0-beta.1')).toBe('beta'); + expect(desktopChannel({ value: 'stable', locked: false }, 'nonsense', '1.0.0')).toBe('stable'); + }); + + it('names the OS the way the collector does', () => { + expect(desktopOsFamily('win32')).toBe('windows'); + expect(desktopOsFamily('darwin')).toBe('darwin'); + expect(desktopOsFamily('linux')).toBe('linux'); + expect(desktopOsFamily('freebsd')).toBe('unknown'); + }); + + it('holds no host name, so only one place has the collector', () => { + const source = readFileSync(path.join(__dirname, '..', 'desktop-telemetry.ts'), 'utf8'); + expect(source).not.toContain('bulwarkmail.org'); + }); + + it('the policy says which shell file acts on the two keys', async () => { + const { POLICY_CONSUMERS } = await import('../policy-schema'); + expect(POLICY_CONSUMERS.TelemetryAllowed).toEqual({ repo: 'shell', file: 'src/main/features/privacy.ts' }); + expect(POLICY_CONSUMERS.CrashReportsAllowed).toEqual({ repo: 'shell', file: 'src/main/features/privacy.ts' }); + }); +}); diff --git a/lib/desktop/__tests__/os-commands.test.ts b/lib/desktop/__tests__/os-commands.test.ts new file mode 100644 index 000000000..d3f717f64 --- /dev/null +++ b/lib/desktop/__tests__/os-commands.test.ts @@ -0,0 +1,273 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BulwarkDesktopBridge, DesktopOsCommand } from '../types'; +import type { NotificationActionRequest, NotificationActionStatus } from '@/lib/pwa-notify/page-actions'; + +/** What the OS asks for, carried out by the parts the app already has. */ + +const runNotificationAction = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/pwa-notify/page-actions', () => ({ runNotificationAction })); + +const accounts = [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }]; +let authenticated = true; +vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } })); +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: { + getState: () => ({ isAuthenticated: authenticated }), + subscribe: () => () => {}, + }, +})); + +const session = vi.hoisted(() => ({ + savePendingMailto: vi.fn(), + notifyPendingMailto: vi.fn(), + savePendingWebcal: vi.fn(), + notifyPendingWebcal: vi.fn(), +})); +vi.mock('@/lib/protocol-handlers/session', () => session); + +async function fingerprint() { + const { loginFingerprint } = await import('@/lib/pwa-notify/login-fingerprint'); + return loginFingerprint('https://one.example', 'alice@example.org'); +} + +const leaveFor = vi.fn(); +const push = vi.fn(); +const notifyFailed = vi.fn(); +const notifyReplyLost = vi.fn(); +const env = { leaveFor, push, notifyFailed, notifyReplyLost }; +const focus = vi.fn(); + +async function run(command: DesktopOsCommand) { + const { handleOsCommand } = await import('../os-commands'); + await handleOsCommand(command, env); +} + +const text = (value: string) => new TextEncoder().encode(value); + +beforeEach(async () => { + vi.clearAllMocks(); + authenticated = true; + window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { focus } } as unknown as BulwarkDesktopBridge; + const { resetMailLinks } = await import('../mail-links'); + resetMailLinks(); + const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff'); + clearHandoffs(); + for (const kind of ['compose', 'eml', 'calendar', 'contacts'] as const) takeHandoff(kind); +}); + +afterEach(() => { + window.bulwarkDesktop = undefined; +}); + +describe('launches', () => { + it('New message goes through the launch handoff, with no question asked', async () => { + const { subscribeHandoff } = await import('@/lib/launch-handoff'); + const fired = vi.fn(); + const stop = subscribeHandoff('compose', fired); + await run({ type: 'new-message' }); + expect(fired).toHaveBeenCalled(); + stop(); + }); + + it('a mailto goes through the protocol handler to the mail page', async () => { + await run({ type: 'mailto', url: 'mailto:bob@example.org?subject=Hi&body=Text' }); + expect(session.savePendingMailto).toHaveBeenCalledTimes(1); + expect(session.savePendingMailto.mock.calls[0][0]).toMatchObject({ to: ['bob@example.org'], subject: 'Hi' }); + expect(session.notifyPendingMailto).toHaveBeenCalled(); + expect(push).toHaveBeenCalledWith('/'); + }); + + it('a mailto that is not one does nothing', async () => { + await run({ type: 'mailto', url: 'mailto:' + '%'.repeat(3) }); + expect(push).not.toHaveBeenCalledWith('/calendar'); + }); + + it('a webcal goes to the calendar', async () => { + await run({ type: 'webcal', url: 'webcal://example.org/cal.ics' }); + expect(session.savePendingWebcal).toHaveBeenCalledTimes(1); + expect(session.notifyPendingWebcal).toHaveBeenCalled(); + expect(push).toHaveBeenCalledWith('/calendar'); + }); + + it('a double-clicked .eml, .ics and .vcf each reach their handler', async () => { + const { takeHandoff } = await import('@/lib/launch-handoff'); + await run({ + type: 'files', + reason: 'open', + failed: [], + files: [ + { name: 'letter.eml', data: text('Subject: x') }, + { name: 'party.ics', data: text('BEGIN:VCALENDAR') }, + { name: 'bob.vcf', data: text('BEGIN:VCARD') }, + ], + }); + expect(await takeHandoff('eml')?.files[0].text()).toBe('Subject: x'); + expect(takeHandoff('calendar')?.files[0].name).toBe('party.ics'); + expect(takeHandoff('contacts')?.files[0].name).toBe('bob.vcf'); + expect(notifyFailed).not.toHaveBeenCalled(); + }); + + it('a file dropped on the icon becomes an attachment on a new draft', async () => { + const { takeHandoff } = await import('@/lib/launch-handoff'); + await run({ type: 'files', reason: 'attach', failed: [], files: [{ name: 'report.pdf', data: text('pdf') }] }); + const draft = takeHandoff('compose'); + expect(draft?.files.map((file) => file.name)).toEqual(['report.pdf']); + }); + + it('says so when a file could not be read', async () => { + await run({ type: 'files', reason: 'open', failed: ['gone.eml'], files: [] }); + expect(notifyFailed).toHaveBeenCalledWith(['gone.eml']); + }); +}); + +describe('going somewhere', () => { + it('Calendar and Contacts leave through the save-first path', async () => { + await run({ type: 'navigate', target: 'calendar' }); + expect(leaveFor).toHaveBeenCalledWith('/calendar'); + }); + + it('Inbox opens the folder on the mail page when it is mounted', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + await run({ type: 'navigate', target: 'inbox' }); + expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox' }, undefined); + expect(leaveFor).not.toHaveBeenCalled(); + stop(); + }); + + it('with the mail page not mounted the link waits for it, and the app goes there', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + await run({ type: 'open-message', emailId: 'e1', accountId: 'acc' }); + expect(leaveFor).toHaveBeenCalledWith('/'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + expect(opener).toHaveBeenCalledWith({ kind: 'message', id: 'e1', ownerAccountId: 'acc' }, undefined); + stop(); + }); +}); + +describe('a toast\'s buttons', () => { + const context = async (over: object = {}) => JSON.stringify({ emailId: 'e1', threadId: 't1', accountId: 'acc', slot: 3, login: await fingerprint(), ...over }); + const finishWith = (status: NotificationActionStatus) => { + runNotificationAction.mockImplementation(async (_request: NotificationActionRequest, answer: (s: NotificationActionStatus) => void) => { + answer('accepted'); + answer(status); + }); + }; + + it.each(['archive', 'mark-read', 'delete'] as const)('%s runs through the page\'s notification action and stays out of sight', async (action) => { + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + finishWith('done'); + await run({ type: 'notification', action, context: await context() }); + expect(runNotificationAction.mock.calls[0][0]).toMatchObject({ action, emailId: 'e1', accountId: 'acc', slot: 3, login: await fingerprint() }); + expect(opener).not.toHaveBeenCalled(); + expect(focus).not.toHaveBeenCalled(); + stop(); + }); + + it('a button that can\'t be carried out opens the message instead', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + finishWith('failed'); + await run({ type: 'notification', action: 'archive', context: await context() }); + expect(focus).toHaveBeenCalled(); + expect(opener).toHaveBeenCalledWith(expect.objectContaining({ kind: 'message', id: 'e1', slot: 3 }), undefined); + stop(); + }); + + it('a reply opens the composer with the typed text, and is not sent', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + await run({ type: 'notification', action: 'reply', context: await context(), reply: 'On my way' }); + expect(runNotificationAction).not.toHaveBeenCalled(); + expect(opener).toHaveBeenCalledWith( + { kind: 'message', id: 'e1', slot: 3, ownerAccountId: 'acc', reply: true }, + { replyText: 'On my way' }, + ); + stop(); + }); + + it('behind the app lock an old toast acts on nothing, and the window comes up', async () => { + const { setAppLockCheck } = await import('../app-lock-state'); + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + setAppLockCheck(() => true); + for (const action of ['delete', 'archive', 'mark-read', 'open', 'reply']) { + await run({ type: 'notification', action, context: await context(), reply: 'x' }); + } + expect(runNotificationAction).not.toHaveBeenCalled(); + expect(opener).not.toHaveBeenCalled(); + expect(focus).toHaveBeenCalled(); + setAppLockCheck(null); + stop(); + }); + + it('typed text that has nowhere to go says so', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const stop = setMailLinkOpener(vi.fn()); + await run({ type: 'notification', action: 'reply', context: await context({ login: 'someone-else' }), reply: 'On my way' }); + expect(notifyReplyLost).toHaveBeenCalled(); + notifyReplyLost.mockClear(); + await run({ type: 'notification', action: 'reply', context: await context({ login: 'someone-else' }) }); + expect(notifyReplyLost).not.toHaveBeenCalled(); + stop(); + }); + + it('a click on an old toast whose slot went to someone else opens the Inbox, not their message', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + await run({ type: 'notification', action: 'open', context: await context({ login: 'someone-else' }) }); + expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox' }, undefined); + stop(); + }); + + it('a click on a summary toast opens the Inbox of that login', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + await run({ type: 'notification', action: 'open', context: await context({ emailId: undefined }) }); + expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox', slot: 3 }, undefined); + stop(); + }); + + it('junk in the context does no harm', async () => { + const { setMailLinkOpener } = await import('../mail-links'); + const opener = vi.fn(); + const stop = setMailLinkOpener(opener); + await run({ type: 'notification', action: 'archive', context: '{not json' }); + expect(runNotificationAction).not.toHaveBeenCalled(); + expect(opener).toHaveBeenCalledWith({ kind: 'folder', ref: 'inbox' }, undefined); + stop(); + }); +}); + +describe('listening', () => { + it('does nothing in a browser', async () => { + window.bulwarkDesktop = undefined; + const { startOsCommands } = await import('../os-commands'); + expect(() => startOsCommands(env)()).not.toThrow(); + }); + + it('subscribes, and carries out what arrives', async () => { + let listener: ((command: DesktopOsCommand) => void) | null = null; + window.bulwarkDesktop = { + version: 1, + platform: 'win32', + os: { onCommand: (l: (command: DesktopOsCommand) => void) => { listener = l; return () => { listener = null; }; } }, + } as unknown as BulwarkDesktopBridge; + const { startOsCommands } = await import('../os-commands'); + const stop = startOsCommands(env); + listener!({ type: 'navigate', target: 'contacts' }); + await Promise.resolve(); + expect(leaveFor).toHaveBeenCalledWith('/contacts'); + stop(); + expect(listener).toBeNull(); + }); +}); diff --git a/lib/desktop/__tests__/os-mail.test.ts b/lib/desktop/__tests__/os-mail.test.ts new file mode 100644 index 000000000..da28971f6 --- /dev/null +++ b/lib/desktop/__tests__/os-mail.test.ts @@ -0,0 +1,371 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BulwarkDesktopBridge, DesktopOsNotification } from '../types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +/** New mail to a native notification: which messages count, what the toast may say. */ + +const playNotificationSound = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/notification-sound', () => ({ playNotificationSound })); + +let pushWithoutContent = false; +vi.mock('@/lib/device-policy', () => ({ devicesForAccount: () => ({ pushWithoutContent }) })); + +const accounts = [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org', label: 'Work', email: 'alice@example.org' }]; +let client: IJMAPClient; +vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } })); +vi.mock('@/stores/auth-store', () => ({ + useAuthStore: { + getState: () => ({ + activeAccountId: 'alice@one', + client, + getClientForAccount: () => client, + }), + }, +})); + +const INBOX = { id: 'in', role: 'inbox', name: 'Inbox' } as unknown as Mailbox; +const SENT = { id: 'out', role: 'sent', name: 'Sent' } as unknown as Mailbox; +const NEWS = { id: 'news', name: 'News' } as unknown as Mailbox; +const JUNK = { id: 'bad', role: 'junk', name: 'Junk' } as unknown as Mailbox; +const MAILBOXES = [INBOX, SENT, NEWS, JUNK]; + +function email(id: string, over: Partial = {}): Email { + return { + id, + threadId: `t-${id}`, + subject: `Secret ${id}`, + preview: 'preview text', + from: [{ name: 'Alice Sender', email: 'sender@example.org' }], + mailboxIds: { in: true }, + keywords: {}, + receivedAt: new Date().toISOString(), + ...over, + } as unknown as Email; +} + +function fakeClient(mail: Record, changes: { created: string[]; newState: string }[] = []) { + const queue = [...changes]; + return { + getAccountId: () => 'acc', + getAllMailboxes: vi.fn(async () => MAILBOXES), + getEmails: vi.fn(async () => ({ emails: [], hasMore: false, total: 0, state: 'seeded' })), + getEmailChanges: vi.fn(async (since: string) => { + const next = queue.shift(); + return next ? { oldState: since, newState: next.newState, created: next.created, updated: [], destroyed: [], hasMoreChanges: false } : null; + }), + getSomeEmails: vi.fn(async (ids: string[]) => ids.flatMap((id) => mail[id] ?? [])), + } as unknown as IJMAPClient; +} + +const labels = { + newMail: 'new_mail', + newMailCount: (count: number) => `count:${count}`, + noSubject: 'no_subject', + archive: 'archive', + markRead: 'mark_read', + delete: 'delete', + reply: 'reply', + replyPlaceholder: 'placeholder', +}; + +function setBridge(bridge: Partial | undefined) { + window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined; +} + +beforeEach(async () => { + vi.clearAllMocks(); + pushWithoutContent = false; + const { useSettingsStore } = await import('@/stores/settings-store'); + useSettingsStore.setState({ + emailNotificationsEnabled: true, + emailNotificationSound: true, + doNotDisturb: false, + pushNotifyInboxOnly: true, + notificationMutes: { accounts: [], mailboxes: {} }, + notificationSoundChoice: 'involved', + }); + const { setAppLockCheck } = await import('../app-lock-state'); + setAppLockCheck(null); + const { usePolicyStore } = await import('@/stores/policy-store'); + usePolicyStore.setState({ loaded: true }); +}); + +afterEach(() => { + setBridge(undefined); + vi.useRealTimers(); +}); + +describe('which new messages notify', () => { + it('a new unread message in the Inbox', async () => { + const { notifyingMailbox } = await import('../new-mail'); + expect(notifyingMailbox(email('a'), 'acc', 'acc', MAILBOXES)?.id).toBe('in'); + }); + + it('nothing already read, a draft, or old mail coming back from an import', async () => { + const { notifyingMailbox } = await import('../new-mail'); + expect(notifyingMailbox(email('a', { keywords: { $seen: true } }), 'acc', 'acc', MAILBOXES)).toBeNull(); + expect(notifyingMailbox(email('a', { keywords: { $draft: true } }), 'acc', 'acc', MAILBOXES)).toBeNull(); + const old = new Date(Date.now() - 3 * 86400_000).toISOString(); + expect(notifyingMailbox(email('a', { receivedAt: old }), 'acc', 'acc', MAILBOXES)).toBeNull(); + }); + + it('with Inbox only on, mail filed elsewhere stays silent; off, it notifies, but never Junk or Sent', async () => { + const { notifyingMailbox } = await import('../new-mail'); + const { useSettingsStore } = await import('@/stores/settings-store'); + const filed = email('a', { mailboxIds: { news: true } }); + expect(notifyingMailbox(filed, 'acc', 'acc', MAILBOXES)).toBeNull(); + useSettingsStore.setState({ pushNotifyInboxOnly: false }); + expect(notifyingMailbox(filed, 'acc', 'acc', MAILBOXES)?.id).toBe('news'); + expect(notifyingMailbox(email('b', { mailboxIds: { bad: true } }), 'acc', 'acc', MAILBOXES)).toBeNull(); + expect(notifyingMailbox(email('c', { mailboxIds: { out: true } }), 'acc', 'acc', MAILBOXES)).toBeNull(); + }); + + it('a muted folder or account stays silent', async () => { + const { notifyingMailbox } = await import('../new-mail'); + const { useSettingsStore } = await import('@/stores/settings-store'); + useSettingsStore.setState({ notificationMutes: { accounts: [], mailboxes: { 'acc/in': true } } }); + expect(notifyingMailbox(email('a'), 'acc', 'acc', MAILBOXES)).toBeNull(); + useSettingsStore.setState({ notificationMutes: { accounts: ['acc'], mailboxes: {} } }); + expect(notifyingMailbox(email('a'), 'acc', 'acc', MAILBOXES)).toBeNull(); + }); +}); + +describe('the watch', () => { + const flush = async () => { + for (let i = 0; i < 20; i += 1) await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }; + + it('resolves a push against its baseline, whatever page is open', async () => { + const { startNewMailWatch } = await import('../new-mail'); + const { emitPushStateChange } = await import('@/lib/push-events'); + const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]); + const seen: string[] = []; + const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id))); + watch.seed(fake); + await flush(); + emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'background'); + await flush(); + expect(fake.getEmailChanges).toHaveBeenCalledWith('seeded', 'acc', 200); + expect(seen).toEqual(['a']); + watch.stop(); + }); + + it('tells a message once when the store and the push both report it', async () => { + const { startNewMailWatch } = await import('../new-mail'); + const { emitPushStateChange } = await import('@/lib/push-events'); + const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]); + const seen: string[] = []; + const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id))); + watch.seed(fake); + await flush(); + watch.noteCreated(fake, 'acc', ['a']); + await flush(); + emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active'); + await flush(); + expect(seen).toEqual(['a']); + watch.stop(); + }); + + it('a message the store has not got yet is not written off: its created event still tells it', async () => { + const { startNewMailWatch } = await import('../new-mail'); + const { emitPushStateChange } = await import('@/lib/push-events'); + const held: Record = {}; + const fake = fakeClient(held, [{ created: ['a'], newState: 's2' }]); + const seen: string[] = []; + const watch = startNewMailWatch((mail) => seen.push(...mail.map((m) => m.email.id))); + watch.seed(fake); + await flush(); + // The push got here first. The device answers, and it doesn't have the message. + emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active'); + await flush(); + expect(seen).toEqual([]); + // The store has synced it now. + held.a = email('a'); + watch.noteCreated(fake, 'acc', ['a']); + await flush(); + expect(seen).toEqual(['a']); + watch.stop(); + }); + + it('without a baseline the first push only sets one: old mail is not news', async () => { + const { startNewMailWatch } = await import('../new-mail'); + const { emitPushStateChange } = await import('@/lib/push-events'); + const fake = fakeClient({}, []); + const onMail = vi.fn(); + const watch = startNewMailWatch(onMail); + emitPushStateChange({ changed: { acc: { Email: 's1' } } } as never, fake, 'active'); + await flush(); + expect(fake.getEmailChanges).not.toHaveBeenCalled(); + expect(onMail).not.toHaveBeenCalled(); + watch.stop(); + }); + + it('ignores a change after it stopped', async () => { + const { startNewMailWatch } = await import('../new-mail'); + const { emitPushStateChange } = await import('@/lib/push-events'); + const fake = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]); + const onMail = vi.fn(); + const watch = startNewMailWatch(onMail); + watch.seed(fake); + await flush(); + watch.stop(); + emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, fake, 'active'); + await flush(); + expect(onMail).not.toHaveBeenCalled(); + }); +}); + +describe('what the notification says', () => { + const one = (id = 'a', over: Partial = {}) => ({ client, accountId: 'acc', email: email(id, over) }); + + beforeEach(() => { + client = fakeClient({}); + }); + + it('sender, subject, the buttons, and what a button needs after a restart', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + const { loginFingerprint } = await import('@/lib/pwa-notify/login-fingerprint'); + const notification = await buildMailNotification([one()], labels); + expect(notification.title).toBe('Alice Sender'); + expect(notification.body).toBe('Secret a'); + expect(notification.actions?.map((a) => a.id)).toEqual(['archive', 'mark-read', 'delete']); + expect(notification.reply?.label).toBe('reply'); + expect(JSON.parse(notification.context)).toEqual({ + accountId: 'acc', + slot: 3, + login: await loginFingerprint('https://one.example', 'alice@example.org'), + emailId: 'a', + threadId: 't-a', + }); + }); + + it('no Archive button where the organisation has it off', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + const notification = await buildMailNotification([one()], { ...labels, archive: null }); + expect(notification.actions?.map((a) => a.id)).toEqual(['mark-read', 'delete']); + }); + + it('while the app is locked: no sender, no subject, no buttons', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + const { setAppLockCheck } = await import('../app-lock-state'); + setAppLockCheck(() => true); + const notification = await buildMailNotification([one()], labels); + const text = JSON.stringify(notification); + expect(notification.title).toBe('new_mail'); + expect(text).not.toContain('Alice Sender'); + expect(text).not.toContain('Secret'); + expect(notification.actions).toBeUndefined(); + expect(notification.reply).toBeUndefined(); + }); + + it('where the organisation keeps content off notifications: the same', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + pushWithoutContent = true; + const notification = await buildMailNotification([one()], labels); + expect(JSON.stringify(notification)).not.toContain('Secret'); + expect(JSON.stringify(notification)).not.toContain('Alice Sender'); + }); + + it('before the policy is known: the same', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + const { usePolicyStore } = await import('@/stores/policy-store'); + usePolicyStore.setState({ loaded: false }); + const notification = await buildMailNotification([one()], labels); + expect(JSON.stringify(notification)).not.toContain('Secret'); + expect(JSON.stringify(notification)).not.toContain('Alice Sender'); + }); + + it('several at once are told about once, and that one has no message to act on', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + const notification = await buildMailNotification([one('a'), one('b'), one('c')], labels); + expect(notification.title).toBe('count:3'); + expect(notification.actions).toBeUndefined(); + expect(JSON.parse(notification.context).emailId).toBeUndefined(); + }); + + it('several at once while locked say only how many', async () => { + const { buildMailNotification } = await import('../mail-notifications'); + const { setAppLockCheck } = await import('../app-lock-state'); + setAppLockCheck(() => true); + const notification = await buildMailNotification([one('a'), one('b')], labels); + expect(notification.title).toBe('count:2'); + expect(notification.body).toBe(''); + }); +}); + +describe('from arrival to toast and sound', () => { + /** The login's fingerprint comes from crypto.subtle, which no fake timer moves. */ + const settle = async () => { + const end = performance.now() + 80; + while (performance.now() < end) await new Promise((resolve) => setImmediate(resolve)); + }; + + async function arrive(over: { result?: { shown: boolean; playSound: boolean } } = {}) { + const { startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications'); + const { emitPushStateChange } = await import('@/lib/push-events'); + const notify = vi.fn(async (_n: DesktopOsNotification) => over.result ?? { shown: true, playSound: true }); + setBridge({ version: 1, platform: 'win32', os: { notify } as never }); + client = fakeClient({ a: email('a') }, [{ created: ['a'], newState: 's2' }]); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const mail = startMailNotifications(labels); + mail.seed(client); + await vi.advanceTimersByTimeAsync(10); + emitPushStateChange({ changed: { acc: { Email: 's2' } } } as never, client, 'active'); + await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10); + await settle(); + mail.stop(); + return notify; + } + + it('asks main, and plays the picked sound when main says so', async () => { + const notify = await arrive(); + expect(notify).toHaveBeenCalledTimes(1); + expect(notify.mock.calls[0][0].sound).toBe(true); + expect(notify.mock.calls[0][0].attention).toBe(true); + expect(playNotificationSound).toHaveBeenCalledWith('involved'); + }); + + it('no sound when main says it is quiet', async () => { + const notify = await arrive({ result: { shown: true, playSound: false } }); + expect(notify).toHaveBeenCalledTimes(1); + expect(playNotificationSound).not.toHaveBeenCalled(); + }); + + it('nothing at all with mail notifications off or the app\'s Do not disturb on', async () => { + const { useSettingsStore } = await import('@/stores/settings-store'); + useSettingsStore.setState({ emailNotificationsEnabled: false }); + expect(await arrive()).not.toHaveBeenCalled(); + useSettingsStore.setState({ emailNotificationsEnabled: true, doNotDisturb: true }); + expect(await arrive()).not.toHaveBeenCalled(); + expect(playNotificationSound).not.toHaveBeenCalled(); + }); + + it("the store's created event reaches a running watch, and nothing else", async () => { + const { startMailNotifications, MAIL_BATCH_MS } = await import('../mail-notifications'); + const { reportStoreCreatedMail: noteStoreCreated } = await import('../system-events'); + const notify = vi.fn(async () => ({ shown: true, playSound: false })); + setBridge({ version: 1, platform: 'win32', os: { notify } as never }); + client = fakeClient({ a: email('a') }); + noteStoreCreated(client, 'acc', ['a']); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const mail = startMailNotifications(labels); + noteStoreCreated(client, 'acc', ['a']); + await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10); + await settle(); + expect(notify).toHaveBeenCalledTimes(1); + mail.stop(); + noteStoreCreated(client, 'acc', ['b']); + await vi.advanceTimersByTimeAsync(MAIL_BATCH_MS + 10); + await settle(); + expect(notify).toHaveBeenCalledTimes(1); + }); + + it('tells main the sound is off when the user has it off', async () => { + const { useSettingsStore } = await import('@/stores/settings-store'); + useSettingsStore.setState({ emailNotificationSound: false }); + const notify = await arrive({ result: { shown: true, playSound: false } }); + expect(notify.mock.calls[0][0].sound).toBe(false); + }); +}); diff --git a/lib/desktop/__tests__/os-signout.test.ts b/lib/desktop/__tests__/os-signout.test.ts new file mode 100644 index 000000000..d640b50b7 --- /dev/null +++ b/lib/desktop/__tests__/os-signout.test.ts @@ -0,0 +1,24 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +/** A sign-out leaves nothing of the account on the OS's surfaces. */ + +vi.setConfig({ testTimeout: 60_000 }); + +describe('sign-out', () => { + afterEach(() => { + window.bulwarkDesktop = undefined; + }); + + it('clears what the desktop app shows outside its windows', async () => { + const clearSurfaces = vi.fn(); + window.bulwarkDesktop = { version: 1, platform: 'win32', os: { clearSurfaces } } as never; + const { purgeSignedOutData } = await import('@/lib/sign-out-cleanup'); + purgeSignedOutData(); + expect(clearSurfaces).toHaveBeenCalled(); + }); + + it('in a browser there is nothing to clear', async () => { + const { clearDesktopSurfaces } = await import('../bridge'); + expect(() => clearDesktopSurfaces()).not.toThrow(); + }); +}); diff --git a/lib/desktop/__tests__/os-store.test.ts b/lib/desktop/__tests__/os-store.test.ts new file mode 100644 index 000000000..925e4d024 --- /dev/null +++ b/lib/desktop/__tests__/os-store.test.ts @@ -0,0 +1,94 @@ +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { StorePageHooks } from '@/lib/store/client/api'; + +/** The local store and the OS integration: mail it found, and a machine going to sleep. */ + +// The first import of offline-mode compiles a good part of the app. +vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 }); + +const configured = vi.hoisted(() => ({ hooks: null as StorePageHooks | null })); +const setOnline = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock('@/lib/store/client/api', async (original) => ({ + ...(await original()), + configureStore: (hooks: StorePageHooks | null) => { + if (hooks) configured.hooks = hooks; + }, + getStore: () => ({ + supported: () => true, + setOnline, + ensureOpen: async () => {}, + refresh: () => {}, + sync: async () => {}, + stop: () => {}, + }), +})); + +describe('the store and the OS integration', () => { + const client = { isOfflineOnly: () => false, getAccountId: () => 'acc' }; + + beforeEach(async () => { + vi.clearAllMocks(); + const { useAuthStore } = await import('@/stores/auth-store'); + useAuthStore.setState({ getClientForAccount: () => client as never } as never); + const { registerOfflineHooks } = await import('@/lib/offline/offline-mode'); + registerOfflineHooks(); + }); + + afterEach(async () => { + const { setStoreCreatedListener } = await import('../system-events'); + setStoreCreatedListener(null); + }); + + it("hands the store's new mail to the notification path, with the login's client", async () => { + const { setStoreCreatedListener } = await import('../system-events'); + const listener = vi.fn(); + setStoreCreatedListener(listener); + configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'Email', ids: ['e1', 'e2'] }); + expect(listener).toHaveBeenCalledWith(client, 'acc', ['e1', 'e2']); + }); + + it('a wipe clears what the OS shows, and a browser has nothing to clear', async () => { + const { clearDesktopSurfaces } = await import('../bridge'); + expect(() => clearDesktopSurfaces()).not.toThrow(); + const clearSurfaces = vi.fn(); + window.bulwarkDesktop = { version: 1, platform: 'win32', os: { clearSurfaces } } as never; + const { wipeAccount } = await import('@/lib/offline/offline-mode'); + const { useOfflineStore } = await import('@/lib/offline/state'); + // Not the last account, so this isn't the wipe of everything, which clears as well. + useOfflineStore.setState({ enabled: ['alice@one', 'bob@two'] }); + await wipeAccount('alice@one').catch(() => {}); + expect(clearSurfaces).toHaveBeenCalled(); + useOfflineStore.setState({ enabled: [] }); + window.bulwarkDesktop = undefined; + }); + + it('other collections are not mail', async () => { + const { setStoreCreatedListener } = await import('../system-events'); + const listener = vi.fn(); + setStoreCreatedListener(listener); + configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'CalendarEvent', ids: ['c1'] }); + expect(listener).not.toHaveBeenCalled(); + }); + + it('with nothing listening, as in a browser, it does nothing', () => { + expect(() => configured.hooks?.onEvent({ type: 'created', account: 'alice@one', jmap: 'acc', collection: 'Email', ids: ['e1'] })).not.toThrow(); + }); + + it('a sleep takes the engine offline; waking brings it back and syncs through the events it already listens to', async () => { + const { startOfflineMode, offlineSupported } = await import('@/lib/offline/offline-mode'); + expect(offlineSupported()).toBe(true); + const { runSleepHandlers } = await import('../system-events'); + const stop = startOfflineMode(); + runSleepHandlers(); + expect(setOnline).toHaveBeenLastCalledWith(false); + window.bulwarkDesktop = { version: 1, platform: 'win32', os: { onPower: (listener: (event: string) => void) => { listener('resume'); return () => {}; } } } as never; + const { startPowerWatch } = await import('../power'); + const stopPower = startPowerWatch(); + expect(setOnline).toHaveBeenLastCalledWith(true); + stopPower(); + stop(); + window.bulwarkDesktop = undefined; + }); +}); diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx new file mode 100644 index 000000000..b6df496bb --- /dev/null +++ b/lib/desktop/__tests__/os-window.test.tsx @@ -0,0 +1,596 @@ +import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BulwarkDesktopBridge, DesktopOsPowerEvent, DesktopOsStatus } from '../types'; +import type { CalendarEvent } from '@/lib/jmap/types'; + +/** The window's end: badge, dropped and pasted files, drag-out, waking up, the settings section. */ + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key), + useLocale: () => 'en', +})); + +function setBridge(bridge: Partial | undefined, platform: 'win32' | 'darwin' = 'win32') { + window.bulwarkDesktop = bridge ? ({ version: 1, platform, os: bridge } as unknown as BulwarkDesktopBridge) : undefined; +} + +afterEach(() => { + setBridge(undefined); + vi.unstubAllGlobals(); +}); + +describe('the badge', () => { + it('goes to the bridge in the app, and the Badging API is left alone', async () => { + const { useAppBadge } = await import('@/hooks/use-app-badge'); + const setBadge = vi.fn(); + const setAppBadge = vi.fn(async () => {}); + Object.defineProperty(navigator, 'setAppBadge', { value: setAppBadge, configurable: true }); + setBridge({ setBadge }); + const { rerender } = renderHook(({ count, enabled }) => useAppBadge(count, enabled), { initialProps: { count: 4, enabled: true } }); + expect(setBadge).toHaveBeenLastCalledWith(4); + rerender({ count: 4, enabled: false }); + expect(setBadge).toHaveBeenLastCalledWith(0); + expect(setAppBadge).not.toHaveBeenCalled(); + }); + + it('a pop-out has no say in it', async () => { + const { useAppBadge } = await import('@/hooks/use-app-badge'); + const role = await import('@/lib/popout/window-role'); + const popout = vi.spyOn(role, 'isPopoutWindow').mockReturnValue(true); + const setBadge = vi.fn(); + setBridge({ setBadge }); + renderHook(() => useAppBadge(4, true)); + expect(setBadge).not.toHaveBeenCalled(); + popout.mockRestore(); + }); +}); + +describe('new-mail sound', () => { + it('is left to the notification path in the app, not played twice', async () => { + const { desktopPlaysMailSound } = await import('../mail-notifications'); + expect(desktopPlaysMailSound()).toBe(false); + setBridge({ notify: vi.fn() as never }); + expect(desktopPlaysMailSound()).toBe(true); + }); +}); + +describe('the policy keeps refreshing', () => { + const fetchPolicy = vi.fn(async () => {}); + + beforeEach(async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval', 'Date'] }); + fetchPolicy.mockClear(); + const { usePolicyStore } = await import('@/stores/policy-store'); + usePolicyStore.setState({ loaded: true, fetching: false, fetchPolicy }); + const { resetPolicyRefresh } = await import('@/hooks/use-policy-refresh'); + resetPolicyRefresh(); + Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + delete (document as unknown as Record).visibilityState; + }); + + it('in the desktop app while hidden in the tray', async () => { + const { POLICY_REFRESH_MS, usePolicyRefresh } = await import('@/hooks/use-policy-refresh'); + setBridge({}); + const { unmount } = renderHook(() => usePolicyRefresh()); + vi.advanceTimersByTime(POLICY_REFRESH_MS); + expect(fetchPolicy).toHaveBeenCalledTimes(1); + unmount(); + }); + + it('but not in a browser tab out of view', async () => { + const { POLICY_REFRESH_MS, usePolicyRefresh } = await import('@/hooks/use-policy-refresh'); + const { unmount } = renderHook(() => usePolicyRefresh()); + vi.advanceTimersByTime(POLICY_REFRESH_MS); + expect(fetchPolicy).not.toHaveBeenCalled(); + unmount(); + }); +}); + +function dropEvent(type: string, files: File[]): Event { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { value: { types: files.length ? ['Files'] : ['text/plain'], files, dropEffect: 'none' } }); + return event; +} + +describe('files on the window', () => { + const file = new File(['x'], 'report.pdf'); + + beforeEach(async () => { + const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff'); + clearHandoffs(); + takeHandoff('compose'); + }); + + it('nothing happens in a browser', async () => { + const { startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + const stop = startWindowFiles(); + const event = dropEvent('drop', [file]); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + expect(takeHandoff('compose')).toBeNull(); + stop(); + }); + + it('a drop on the window starts a new draft with the file', async () => { + const { startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + const over = dropEvent('dragover', [file]); + window.dispatchEvent(over); + expect(over.defaultPrevented).toBe(true); + const drop = dropEvent('drop', [file]); + window.dispatchEvent(drop); + expect(drop.defaultPrevented).toBe(true); + expect(takeHandoff('compose')?.files).toEqual([file]); + stop(); + }); + + it('a drop something else took is left alone', async () => { + const { startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + const event = dropEvent('drop', [file]); + event.preventDefault(); + window.dispatchEvent(event); + expect(takeHandoff('compose')).toBeNull(); + stop(); + }); + + it('text dragged around is not a file', async () => { + const { startWindowFiles } = await import('../window-files'); + setBridge({}); + const stop = startWindowFiles(); + const event = dropEvent('dragover', []); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + stop(); + }); + + it('an open composer takes the file itself', async () => { + const { setComposerFileTarget, startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + const target = vi.fn(); + const clear = setComposerFileTarget(target, () => true); + window.dispatchEvent(dropEvent('drop', [file])); + expect(target).toHaveBeenCalledWith([file]); + expect(takeHandoff('compose')).toBeNull(); + clear(); + stop(); + }); + + it('a composer out of sight on a hidden tab takes nothing', async () => { + const { setComposerFileTarget, startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + const hidden = vi.fn(); + const clear = setComposerFileTarget(hidden, () => false); + window.dispatchEvent(dropEvent('drop', [file])); + expect(hidden).not.toHaveBeenCalled(); + expect(takeHandoff('compose')?.files).toEqual([file]); + clear(); + stop(); + }); + + it('a pasted file is taken; pasted text is not', async () => { + const { startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + const paste = (files: File[]) => { + const event = new Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'clipboardData', { value: { files, types: files.length ? ['Files'] : ['text/plain'] } }); + window.dispatchEvent(event); + return event; + }; + expect(paste([]).defaultPrevented).toBe(false); + expect(takeHandoff('compose')).toBeNull(); + expect(paste([file]).defaultPrevented).toBe(true); + expect(takeHandoff('compose')?.files).toEqual([file]); + stop(); + }); + + describe('a paste that is not for us', () => { + const pasteOn = (target: EventTarget, types: string[]) => { + const event = new Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'clipboardData', { value: { files: [file], types } }); + target.dispatchEvent(event); + return event; + }; + + it('text with a file, as a spreadsheet copies, stays text', async () => { + const { startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + expect(pasteOn(document.body, ['text/plain', 'Files']).defaultPrevented).toBe(false); + expect(takeHandoff('compose')).toBeNull(); + stop(); + }); + + it('in a search field or any other input, a file is not an attachment', async () => { + const { startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + const input = document.body.appendChild(document.createElement('input')); + expect(pasteOn(input, ['Files']).defaultPrevented).toBe(false); + expect(takeHandoff('compose')).toBeNull(); + input.remove(); + stop(); + }); + + it('in the body of a composer on screen, a bare file attaches', async () => { + const { setComposerFileTarget, startWindowFiles } = await import('../window-files'); + setBridge({}); + const stop = startWindowFiles(); + const target = vi.fn(); + const clear = setComposerFileTarget(target, () => true); + const composer = document.body.appendChild(document.createElement('div')); + composer.setAttribute('data-testid', 'email-composer'); + const body = composer.appendChild(document.createElement('div')); + body.setAttribute('contenteditable', 'true'); + pasteOn(body, ['Files']); + expect(target).toHaveBeenCalledWith([file]); + composer.remove(); + clear(); + stop(); + }); + }); + + it('our own native drag over our own window is not a new file', async () => { + const { setOwnDragActive, startWindowFiles } = await import('../window-files'); + const { takeHandoff } = await import('@/lib/launch-handoff'); + setBridge({}); + const stop = startWindowFiles(); + setOwnDragActive(true); + window.dispatchEvent(dropEvent('drop', [file])); + expect(takeHandoff('compose')).toBeNull(); + setOwnDragActive(false); + stop(); + }); +}); + +describe('dragging an attachment out', () => { + const blobUrl = 'blob:https://app/1'; + const source = { + name: 'report.pdf', + type: 'application/pdf', + getBlobUrl: async () => blobUrl, + getBlob: async () => new Blob([new Uint8Array([1, 2, 3])]), + }; + + async function ready() { + const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag'); + const hook = renderHook(() => useAttachmentDrag(source, true)); + act(() => hook.result.current.onPointerEnter()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + return hook; + } + + it('in the app: the OS\'s own drag, with the bytes for a real file', async () => { + const startDrag = vi.fn(async () => true); + setBridge({ startDrag, copyFiles: vi.fn() as never }); + const hook = await ready(); + const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } }; + hook.result.current.onDragStart(event as never); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.dataTransfer.setData).not.toHaveBeenCalled(); + await waitFor(() => expect(startDrag).toHaveBeenCalled()); + const [files] = startDrag.mock.calls[0] as unknown as [{ name: string; data: Uint8Array }[]]; + expect(files[0].name).toBe('report.pdf'); + expect([...files[0].data]).toEqual([1, 2, 3]); + }); + + it('in the app, a drag before the bytes are here starts nothing and fetches them for the next', async () => { + const startDrag = vi.fn(async () => true); + setBridge({ startDrag, copyFiles: vi.fn() as never }); + const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag'); + const hook = renderHook(() => useAttachmentDrag(source, true)); + const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } }; + hook.result.current.onDragStart(event as never); + expect(event.preventDefault).toHaveBeenCalled(); + expect(startDrag).not.toHaveBeenCalled(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + hook.result.current.onDragStart(event as never); + await waitFor(() => expect(startDrag).toHaveBeenCalled()); + }); + + it('in the app, without getBlob (a decrypted attachment): the browser drag, and no copy button', async () => { + const startDrag = vi.fn(async () => true); + setBridge({ startDrag, copyFiles: vi.fn() as never }); + const { useAttachmentDrag } = await import('@/hooks/use-attachment-drag'); + const { name, type, getBlobUrl } = source; + const hook = renderHook(() => useAttachmentDrag({ name, type, getBlobUrl }, true)); + act(() => hook.result.current.onPointerEnter()); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } }; + hook.result.current.onDragStart(event as never); + expect(startDrag).not.toHaveBeenCalled(); + expect(event.dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', expect.stringContaining(blobUrl)); + expect(hook.result.current.onCopyAsFile).toBeUndefined(); + }); + + it('in a browser: still DownloadURL, and no copy button', async () => { + const hook = await ready(); + const event = { preventDefault: vi.fn(), dataTransfer: { setData: vi.fn(), effectAllowed: '' } }; + hook.result.current.onDragStart(event as never); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', `application/pdf:report.pdf:${blobUrl}`); + expect(hook.result.current.onCopyAsFile).toBeUndefined(); + }); + + it('Copy as file hands the bytes to main', async () => { + const copyFiles = vi.fn(async () => true); + setBridge({ startDrag: vi.fn() as never, copyFiles }); + const hook = await ready(); + await expect(hook.result.current.onCopyAsFile?.()).resolves.toBe(true); + const [files] = copyFiles.mock.calls[0] as unknown as [{ name: string; data: Uint8Array }[]]; + expect(files[0].name).toBe('report.pdf'); + }); +}); + +describe('waking up', () => { + async function started(os: Partial = {}) { + let fire: (event: DesktopOsPowerEvent) => void = () => {}; + setBridge({ onPower: (listener: (event: DesktopOsPowerEvent) => void) => { fire = listener; return () => {}; }, ...os }); + const power = await import('../power'); + const events = await import('../system-events'); + return { fire: (event: DesktopOsPowerEvent) => fire(event), power: events, stop: power.startPowerWatch() }; + } + + it('a resume checks for changes at once, the way a tab coming back does, and only once with the unlock', async () => { + const { fire, power, stop } = await started(); + const { usePolicyStore } = await import('@/stores/policy-store'); + const fetchPolicy = vi.fn(async () => {}); + usePolicyStore.setState({ loaded: true, fetching: false, fetchPolicy }); + const visible = vi.fn(); + const online = vi.fn(); + const wake = vi.fn(); + document.addEventListener('visibilitychange', visible); + window.addEventListener('online', online); + const stopWake = power.addWakeHandler(wake); + fire('resume'); + fire('unlock-screen'); + expect(visible).toHaveBeenCalledTimes(1); + expect(online).toHaveBeenCalledTimes(1); + expect(fetchPolicy).toHaveBeenCalledTimes(1); + expect(wake).toHaveBeenCalledTimes(1); + document.removeEventListener('visibilitychange', visible); + window.removeEventListener('online', online); + stopWake(); + stop(); + }); + + it('a suspend tells the sleep handlers and wakes nothing', async () => { + const { fire, power, stop } = await started(); + const sleep = vi.fn(); + const visible = vi.fn(); + const stopSleep = power.addSleepHandler(sleep); + document.addEventListener('visibilitychange', visible); + fire('suspend'); + fire('lock-screen'); + expect(sleep).toHaveBeenCalledTimes(1); + expect(visible).not.toHaveBeenCalled(); + document.removeEventListener('visibilitychange', visible); + stopSleep(); + stop(); + }); + + it('a new kind of network counts as waking; a change in its speed estimate does not', async () => { + const connection = Object.assign(new EventTarget(), { type: 'wifi' }); + vi.stubGlobal('navigator', Object.create(navigator, { connection: { value: connection }, onLine: { value: true } })); + const { power, stop } = await started(); + const wake = vi.fn(); + const stopWake = power.addWakeHandler(wake); + connection.dispatchEvent(new Event('change')); + expect(wake).not.toHaveBeenCalled(); + connection.type = 'ethernet'; + connection.dispatchEvent(new Event('change')); + expect(wake).toHaveBeenCalledTimes(1); + stopWake(); + stop(); + }); + + it('where Chromium gives no type, a change is nothing to go on', async () => { + const connection = new EventTarget(); + vi.stubGlobal('navigator', Object.create(navigator, { connection: { value: connection }, onLine: { value: true } })); + const { power, stop } = await started(); + const wake = vi.fn(); + const stopWake = power.addWakeHandler(wake); + connection.dispatchEvent(new Event('change')); + expect(wake).not.toHaveBeenCalled(); + stopWake(); + stop(); + }); + + it('woken up with no network, it does not say the network is back, and a wake within 30 s is the same wake', async () => { + const online = vi.fn(); + window.addEventListener('online', online); + vi.stubGlobal('navigator', Object.create(navigator, { onLine: { value: false } })); + const { fire, power, stop } = await started(); + const wake = vi.fn(); + const stopWake = power.addWakeHandler(wake); + fire('resume'); + expect(wake).toHaveBeenCalledTimes(1); + expect(online).not.toHaveBeenCalled(); + const now = Date.now(); + const clock = vi.spyOn(Date, 'now').mockReturnValue(now + 29_000); + fire('unlock-screen'); + expect(wake).toHaveBeenCalledTimes(1); + clock.mockReturnValue(now + 31_000); + fire('unlock-screen'); + expect(wake).toHaveBeenCalledTimes(2); + clock.mockRestore(); + window.removeEventListener('online', online); + stopWake(); + stop(); + }); +}); + +describe('the summary', () => { + const event = (id: string, start: string, over: Partial = {}) => ({ id, title: id, start, utcStart: start, status: 'confirmed', showWithoutTime: false, ...over }) as unknown as CalendarEvent; + const now = new Date('2026-11-03T10:00:00Z').getTime(); + + it('the next event is the soonest one still ahead, not cancelled and not all-day', async () => { + const { nextEventOf } = await import('../os-summary'); + const events = [ + event('later', '2026-11-03T15:00:00Z'), + event('past', '2026-11-03T09:00:00Z'), + event('cancelled', '2026-11-03T11:00:00Z', { status: 'cancelled' }), + event('allday', '2026-11-03T10:30:00Z', { showWithoutTime: true }), + event('soon', '2026-11-03T12:00:00Z'), + ]; + expect(nextEventOf(events, now)?.id).toBe('soon'); + expect(nextEventOf([event('past', '2026-11-03T09:00:00Z')], now)).toBeNull(); + }); + + describe('with a login', () => { + const row = { id: 'e1', threadId: 't1', subject: 'Lunch?', from: [{ name: 'Alice', email: 'a@example.org' }] }; + const getEmails = vi.fn(async () => ({ emails: [row], hasMore: false, total: 1 })); + const fake = { getAccountId: () => 'acc', getEmails, queryCalendarEvents: vi.fn(async () => []) }; + + beforeEach(async () => { + getEmails.mockClear(); + const { useAuthStore } = await import('@/stores/auth-store'); + const { useAccountStore } = await import('@/stores/account-store'); + const { useEmailStore } = await import('@/stores/email-store'); + useAuthStore.setState({ isAuthenticated: true, client: fake as never, activeAccountId: 'alice@one' }); + useAccountStore.setState({ accounts: [{ id: 'alice@one', cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }] as never }); + useEmailStore.setState({ mailboxes: [{ id: 'in', role: 'inbox', name: 'Inbox' }] as never }); + const { usePolicyStore } = await import('@/stores/policy-store'); + usePolicyStore.setState({ loaded: true }); + }); + + afterEach(async () => { + const { useAuthStore } = await import('@/stores/auth-store'); + useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null }); + const { setAppLockCheck } = await import('../app-lock-state'); + setAppLockCheck(null); + }); + + it('lists unread mail with what the tray item needs to open it', async () => { + const { readSummary } = await import('../os-summary'); + const summary = await readSummary('en'); + expect(summary.unread).toEqual([expect.objectContaining({ emailId: 'e1', accountId: 'acc', sender: 'Alice', subject: 'Lunch?' })]); + expect(JSON.parse(summary.unread[0].context!)).toMatchObject({ emailId: 'e1', slot: 3 }); + expect(getEmails).toHaveBeenCalledWith('in', undefined, 5, 0, undefined, false, { notKeyword: '$seen' }); + }); + + it('before the policy is known it is empty', async () => { + const { usePolicyStore } = await import('@/stores/policy-store'); + const { readSummary, EMPTY_SUMMARY } = await import('../os-summary'); + usePolicyStore.setState({ loaded: false }); + await expect(readSummary('en')).resolves.toEqual(EMPTY_SUMMARY); + expect(getEmails).not.toHaveBeenCalled(); + }); + + it('a read under way when the login goes doesn\'t bring it back; a policy change reads again', async () => { + const setSummary = vi.fn(); + setBridge({ setSummary }); + const { startOsSummary, EMPTY_SUMMARY } = await import('../os-summary'); + const { useAuthStore } = await import('@/stores/auth-store'); + const { usePolicyStore } = await import('@/stores/policy-store'); + let answer: (value: unknown) => void = () => {}; + getEmails.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve as never; }) as never); + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }); + const summary = startOsSummary(() => 'en'); + await vi.advanceTimersByTimeAsync(2100); + useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null }); + expect(setSummary).toHaveBeenLastCalledWith(EMPTY_SUMMARY); + answer({ emails: [row], hasMore: false, total: 1 }); + await vi.advanceTimersByTimeAsync(10); + await new Promise((resolve) => setImmediate(resolve)); + await vi.advanceTimersByTimeAsync(2100); + expect(setSummary).toHaveBeenLastCalledWith(EMPTY_SUMMARY); + expect(JSON.stringify(setSummary.mock.calls)).not.toContain('Lunch'); + + setSummary.mockClear(); + usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy } }); + await vi.advanceTimersByTimeAsync(2100); + expect(setSummary).toHaveBeenCalled(); + summary.stop(); + vi.useRealTimers(); + }); + + it('while locked it is empty, without asking anyone anything', async () => { + const { setAppLockCheck } = await import('../app-lock-state'); + const { readSummary, EMPTY_SUMMARY } = await import('../os-summary'); + setAppLockCheck(() => true); + await expect(readSummary('en')).resolves.toEqual(EMPTY_SUMMARY); + expect(getEmails).not.toHaveBeenCalled(); + }); + }); +}); + +describe('the settings section', () => { + const settings = { closeToTray: true, startAtLogin: false, startMinimized: false, attentionOnNewMail: true, quietHoursEnabled: false, quietHoursStart: '22:00', quietHoursEnd: '07:00', showShortcut: null }; + const status = (over = {}): DesktopOsStatus => ({ settings: { ...settings, ...over }, pausedUntil: null, shortcutActive: false }); + + function bridgeFor(current: DesktopOsStatus) { + return { + getStatus: vi.fn(async () => current), + setSettings: vi.fn(async (patch: Partial) => ({ ...current, settings: { ...current.settings, ...patch } })), + onStatusChange: vi.fn(() => () => {}), + defaultAppStatus: vi.fn(async () => ({ registered: false, mailto: false, webcal: false })), + openDefaultAppSettings: vi.fn(async () => ({ registered: true, mailto: false, webcal: false })), + }; + } + + it('a browser never sees it', async () => { + const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings'); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('in the app it shows the state and changes it', async () => { + const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings'); + const os = bridgeFor(status()); + setBridge(os as never); + render(); + expect(await screen.findByText('section_title')).toBeInTheDocument(); + expect(screen.queryByText('start_minimized_label')).toBeNull(); + // Second in the section, after Close to tray. + fireEvent.click(screen.getAllByRole('switch')[1]); + await waitFor(() => expect(os.setSettings).toHaveBeenCalledWith({ startAtLogin: true })); + expect(await screen.findByText('start_minimized_label')).toBeInTheDocument(); + }); + + it('the button offers the app and opens where the choice is made', async () => { + const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings'); + const os = bridgeFor(status()); + setBridge(os as never); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'default_app_button' })); + await waitFor(() => expect(os.openDefaultAppSettings).toHaveBeenCalled()); + }); + + it('says when another app holds the shortcut', async () => { + const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings'); + setBridge(bridgeFor(status({ showShortcut: 'CommandOrControl+Shift+B' })) as never); + render(); + expect(await screen.findByText(/show_shortcut_taken/)).toBeInTheDocument(); + }); + + it('macOS gets its own word for the bounce', async () => { + const { DesktopOsSettings } = await import('@/components/settings/desktop-os-settings'); + setBridge(bridgeFor(status()) as never, 'darwin'); + render(); + expect(await screen.findByText('attention_mac_label')).toBeInTheDocument(); + }); +}); diff --git a/lib/desktop/__tests__/reminder-notifications.test.ts b/lib/desktop/__tests__/reminder-notifications.test.ts new file mode 100644 index 000000000..f609dae3b --- /dev/null +++ b/lib/desktop/__tests__/reminder-notifications.test.ts @@ -0,0 +1,305 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useMissedReminders } from '@/lib/calendar-reminders/state'; +import { CALENDAR_SESSION, FakeCalendarJmap } from '@/lib/store/__tests__/helpers/fake-calendar'; +import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store'; +import { setStoreBackend } from '@/lib/store/client/api'; +import type { ReminderAlert, TickResult } from '@/lib/store/reminders/ledger'; +import { setAppLockCheck } from '../app-lock-state'; +import { + buildMissedNotification, + buildReminderNotification, + handleReminderCommand, + joinMeeting, + missedBody, + notifyWithoutStore, + type ReminderLabels, +} from '../reminder-notifications'; +import type { BulwarkDesktopBridge } from '../types'; + +const ACCOUNT = 'alice@one'; +const accounts = [{ id: ACCOUNT, cookieSlot: 3, serverUrl: 'https://one.example', username: 'alice@example.org' }]; +let activeAccountId: string | null = ACCOUNT; +vi.mock('@/stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts }) } })); +vi.mock('@/stores/auth-store', () => ({ useAuthStore: { getState: () => ({ activeAccountId }), subscribe: () => () => {} } })); +vi.mock('@/stores/policy-store', () => ({ usePolicyStore: { getState: () => ({ loaded: true }), subscribe: () => () => {} } })); + +const policy = vi.hoisted(() => ({ pushWithoutContent: false })); +vi.mock('@/lib/device-policy', () => ({ devicesForAccount: () => ({ pushWithoutContent: policy.pushWithoutContent }) })); + +const MIN = 60_000; +const START = Date.UTC(2026, 10, 10, 9); +const FIRE = START - 15 * MIN; + +const labels: ReminderLabels = { + generic: 'GENERIC', + when: () => 'In 15 min · Personal', + untitled: 'UNTITLED', + snooze: (minutes) => `Snooze ${minutes} min`, + snoozeShort: (minutes) => `${minutes} min`, + open: 'Open', + join: 'Join', + joinHost: (host) => `Join (${host})`, + missedTitle: (count) => `${count} missed`, + missedMore: (count) => `and ${count} more`, +}; + +function alert(partial: Partial = {}): ReminderAlert { + return { + key: `j1:uid-1:a:${FIRE}`, jmap: 'j1', uid: 'uid-1', kind: 'event', baseEventId: 'e1', recurrenceId: null, alertId: 'a', fireTimeMs: FIRE, + title: 'Secret board meeting', start: '2026-11-10T10:00:00', startMs: START, joinUrl: null, joinHost: null, timeZone: 'Europe/Berlin', calendarId: 'cal1', calendarName: 'Personal', + ...partial, + }; +} + +const focus = vi.fn(); +const openExternal = vi.fn(); +const notify = vi.fn(async () => ({ shown: true, playSound: false })); + +function setPlatform(platform: string) { + window.bulwarkDesktop = { version: 1, platform, windows: { focus }, shell: { openExternal }, os: { notify } } as unknown as BulwarkDesktopBridge; +} + +let store: InProcessStore; +let server: FakeCalendarJmap; +const env = { leaveFor: vi.fn(), push: vi.fn() }; + +beforeEach(() => { + vi.clearAllMocks(); + policy.pushWithoutContent = false; + activeAccountId = ACCOUNT; + setAppLockCheck(null); + setPlatform('win32'); + useMissedReminders.getState().close(); + server = new FakeCalendarJmap('1.0'); + store = new InProcessStore(server, CALENDAR_SESSION); + setStoreBackend(store); +}); + +afterEach(() => { + setStoreBackend(null); + window.bulwarkDesktop = undefined; + setAppLockCheck(null); + vi.useRealTimers(); +}); + +async function contextFor(reminder: ReminderAlert): Promise { + return (await buildReminderNotification(ACCOUNT, reminder, labels, false)).context; +} + +describe('what a reminder says', () => { + it('offers Join only when the event has a link', async () => { + const without = await buildReminderNotification(ACCOUNT, alert(), labels, false); + expect(without.actions!.map((action) => action.id)).toEqual(['open', 'snooze-5', 'snooze-10', 'snooze-15']); + const withLink = await buildReminderNotification(ACCOUNT, alert({ joinUrl: 'https://meet.jit.si/x' }), labels, false); + expect(withLink.actions!.map((action) => action.id)).toEqual(['join', 'open', 'snooze-5', 'snooze-10', 'snooze-15']); + expect(withLink.actions![0].label).toBe('Join'); + const foreign = await buildReminderNotification(ACCOUNT, alert({ joinUrl: 'https://calls.example.org/x', joinHost: 'calls.example.org' }), labels, false); + expect(foreign.actions![0].label).toBe('Join (calls.example.org)'); + }); + + it('uses short snooze labels where five buttons share a row', async () => { + const windows = await buildReminderNotification(ACCOUNT, alert(), labels, false); + expect(windows.actions!.find((action) => action.id === 'snooze-5')!.label).toBe('5 min'); + setPlatform('darwin'); + const mac = await buildReminderNotification(ACCOUNT, alert(), labels, false); + expect(mac.actions!.find((action) => action.id === 'snooze-5')!.label).toBe('Snooze 5 min'); + }); + + it('sends nothing of the event while the app is locked: no title, no time, no key, no buttons', async () => { + setAppLockCheck(() => true); + const locked = await buildReminderNotification(ACCOUNT, alert({ joinUrl: 'https://meet.jit.si/x' }), labels, true); + expect(locked).toMatchObject({ title: 'GENERIC', body: '', context: '', kind: 'reminder' }); + expect(locked.actions).toBeUndefined(); + expect(JSON.stringify(locked)).not.toContain('Secret'); + expect(JSON.stringify(locked)).not.toContain('uid-1'); + }); + + it('keeps the title off when the organisation says so, the buttons stay', async () => { + policy.pushWithoutContent = true; + const hidden = await buildReminderNotification(ACCOUNT, alert(), labels, false); + expect(hidden).toMatchObject({ title: 'GENERIC', body: '' }); + expect(hidden.actions).toHaveLength(4); + }); + + it('gets a new id when it comes back from a snooze, since main drops a repeat', async () => { + const first = await buildReminderNotification(ACCOUNT, alert(), labels, false); + const back = await buildReminderNotification(ACCOUNT, alert({ snoozedUntil: FIRE + 5 * MIN }), labels, false); + expect(back.id).not.toBe(first.id); + }); + + it('falls back on a neutral title for an event without one', async () => { + expect((await buildReminderNotification(ACCOUNT, alert({ title: '' }), labels, false)).title).toBe('UNTITLED'); + }); + + it('keeps titles out of a summary when locked or forbidden, and names three at most', async () => { + const alerts = ['A', 'B', 'C', 'D', 'E'].map((title) => alert({ title, key: title })); + expect(missedBody(alerts, labels)).toBe('A, B, C, and 2 more'); + const open = await buildMissedNotification(ACCOUNT, { batch: 7, alerts }, labels); + expect(open).toMatchObject({ title: '5 missed', body: 'A, B, C, and 2 more' }); + setAppLockCheck(() => true); + expect((await buildMissedNotification(ACCOUNT, { batch: 7, alerts }, labels)).body).toBe(''); + }); + + it('without a store says what it can, and nothing when locked', () => { + notifyWithoutStore({ key: 'e1:a:1', fireTimeMs: 1, title: 'Dentist', body: 'In 5 min', sound: true }, { generic: 'GENERIC' }, ACCOUNT); + expect(notify).toHaveBeenCalledTimes(1); + expect(notify.mock.calls[0]).toEqual([expect.objectContaining({ id: 'reminder:e1:a:1', title: 'Dentist', body: 'In 5 min', context: '{"cal":true}', sound: true })]); + setAppLockCheck(() => true); + notifyWithoutStore({ key: 'e1:a:1', fireTimeMs: 1, title: 'Dentist', body: 'In 5 min', sound: true }, { generic: 'GENERIC' }, ACCOUNT); + expect(notify).toHaveBeenCalledTimes(2); + expect(notify.mock.calls[1]).toEqual([expect.objectContaining({ title: 'GENERIC', body: '' })]); + expect(JSON.stringify(notify.mock.calls[1])).not.toContain('Dentist'); + expect(JSON.stringify(notify.mock.calls[1])).not.toContain('e1'); + }); +}); + +describe('sizes', () => { + it('keeps the context under what main will take, with the longest key the store makes', async () => { + const key = `${'j'.repeat(40)}:#abc:9999:${'a'.repeat(64)}:${FIRE}`; + const { context } = await buildReminderNotification(ACCOUNT, alert({ key, uid: 'u'.repeat(5000) }), labels, false); + expect(context.length).toBeLessThan(1024); + expect(JSON.parse(context).r).toBe(key); + }); +}); + +describe('Join is only ever a web link', () => { + for (const url of ['ms-msdt:/id PCWDiagnostic', 'search-ms:query=x', 'smb://evil.example/share', 'https://meet.google.com@evil.example/x', 'javascript:alert(1)', 'not a link']) { + it(`opens nothing for ${url}, in the app`, async () => { + expect(await joinMeeting(url)).toBe(false); + expect(openExternal).not.toHaveBeenCalled(); + }); + + it(`opens nothing for ${url}, in a browser`, async () => { + window.bulwarkDesktop = undefined; + const open = vi.spyOn(window, 'open').mockImplementation(() => null); + expect(await joinMeeting(url)).toBe(false); + expect(open).not.toHaveBeenCalled(); + open.mockRestore(); + }); + } + + it('opens a web link, in the app and in a browser', async () => { + expect(await joinMeeting('https://meet.jit.si/x')).toBe(true); + expect(openExternal).toHaveBeenCalledWith('https://meet.jit.si/x'); + window.bulwarkDesktop = undefined; + const open = vi.spyOn(window, 'open').mockImplementation(() => null); + expect(await joinMeeting('https://meet.jit.si/y')).toBe(true); + expect(open).toHaveBeenCalledWith('https://meet.jit.si/y', '_blank', 'noopener,noreferrer'); + open.mockRestore(); + }); + + it('leaves the reminder standing when Join from a toast opened nothing', async () => { + vi.useFakeTimers({ now: FIRE - MIN, toFake: ['Date'] }); + server.addEvent({ id: 'e1', uid: 'uid-1', title: 'Standup', virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } }, start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } }); + await store.sync(ACCOUNT); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + await store.area(ACCOUNT, 'reminders.tick', params); + vi.setSystemTime(FIRE); + const [reminder] = (await store.area(ACCOUNT, 'reminders.tick', params)).due; + const { uiHooks } = await import('@/lib/plugin-hooks'); + const stop = uiHooks.onBeforeExternalLink.register('t', () => false); + const area = vi.spyOn(store, 'area'); + await handleReminderCommand('join', await contextFor(reminder), env); + stop.dispose(); + expect(openExternal).not.toHaveBeenCalled(); + expect(area.mock.calls.filter(([, name]) => name === 'reminders.dismiss')).toEqual([]); + // The window comes up, since the button did nothing else. + expect(focus).toHaveBeenCalled(); + }); +}); + +describe('what its buttons do', () => { + /** A reminder really on record in the store. */ + async function fired(partial: Record = {}): Promise { + vi.useFakeTimers({ now: FIRE - MIN, toFake: ['Date'] }); + server.addEvent({ id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } }, ...partial }); + await store.sync(ACCOUNT); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + await store.area(ACCOUNT, 'reminders.tick', params); + vi.setSystemTime(FIRE); + return (await store.area(ACCOUNT, 'reminders.tick', params)).due[0]; + } + + it('snoozes from the store with the window left hidden', async () => { + const reminder = await fired(); + expect(await handleReminderCommand('snooze-10', await contextFor(reminder), env)).toBe(true); + expect(focus).not.toHaveBeenCalled(); + expect(env.leaveFor).not.toHaveBeenCalled(); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + vi.setSystemTime(FIRE + 9 * MIN); + expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toEqual([]); + vi.setSystemTime(FIRE + 10 * MIN); + expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toHaveLength(1); + }); + + it('refuses a snooze length no button offers', async () => { + const reminder = await fired(); + await handleReminderCommand('snooze-999', await contextFor(reminder), env); + expect(focus).toHaveBeenCalled(); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + vi.setSystemTime(FIRE + 16 * MIN); + expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toEqual([]); + }); + + it('joins through the OS from what the store holds, with the window left hidden', async () => { + const reminder = await fired({ virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } } }); + await handleReminderCommand('join', await contextFor(reminder), env); + expect(openExternal).toHaveBeenCalledWith('https://meet.jit.si/standup'); + expect(focus).not.toHaveBeenCalled(); + }); + + it('opens the event in the account on screen', async () => { + const reminder = await fired(); + await handleReminderCommand('open', await contextFor(reminder), env); + expect(focus).toHaveBeenCalled(); + expect(env.leaveFor).toHaveBeenCalledWith('/calendar'); + expect(env.push).toHaveBeenCalledWith('/calendar/event/e1?calendar=cal1'); + }); + + it('opens only the calendar for an account that is not on screen', async () => { + const reminder = await fired(); + const context = await contextFor(reminder); + activeAccountId = 'someone-else'; + await handleReminderCommand('open', context, env); + expect(env.leaveFor).toHaveBeenCalledWith('/calendar'); + expect(env.push).not.toHaveBeenCalled(); + }); + + it('touches nothing when the slot has gone to another login since', async () => { + const reminder = await fired(); + const context = JSON.parse(await contextFor(reminder)) as { login: string }; + context.login = 'not-this-login'; + await handleReminderCommand('snooze-5', JSON.stringify(context), env); + expect(env.leaveFor).toHaveBeenCalledWith('/calendar'); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + vi.setSystemTime(FIRE + 5 * MIN); + expect((await store.area(ACCOUNT, 'reminders.tick', params)).due).toEqual([]); + }); + + it('opens the summary from a click on its toast, even after a restart', async () => { + vi.useFakeTimers({ now: FIRE - MIN, toFake: ['Date'] }); + server.addEvent({ id: 'e1', uid: 'uid-1', title: 'One', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } }); + server.addEvent({ id: 'e2', uid: 'uid-2', title: 'Two', alerts: { a: { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } } }); + await store.sync(ACCOUNT); + const params = { timeZone: 'Europe/Berlin', tasks: true }; + await store.area(ACCOUNT, 'reminders.tick', params); + vi.setSystemTime(FIRE + 60 * MIN); + await store.area(ACCOUNT, 'reminders.tick', params); + const batch = await store.area<{ batch: number; alerts: ReminderAlert[] }>(ACCOUNT, 'reminders.takeMissed', params); + const toast = await buildMissedNotification(ACCOUNT, batch, labels); + + await handleReminderCommand('open', toast.context, env); + expect(focus).toHaveBeenCalled(); + expect(useMissedReminders.getState().alerts.map((a) => a.title)).toEqual(['One', 'Two']); + expect(useMissedReminders.getState().account).toBe(ACCOUNT); + }); + + it('goes to the calendar for a store-less reminder, and leaves mail toasts alone', async () => { + expect(await handleReminderCommand('open', JSON.stringify({ cal: true }), env)).toBe(true); + expect(env.leaveFor).toHaveBeenCalledTimes(1); + expect(env.leaveFor).toHaveBeenCalledWith('/calendar'); + expect(await handleReminderCommand('open', JSON.stringify({ emailId: 'm1', slot: 3 }), env)).toBe(false); + expect(await handleReminderCommand('open', '', env)).toBe(false); + expect(await handleReminderCommand('open', '{broken', env)).toBe(false); + }); +}); diff --git a/lib/desktop/app-lock-state.ts b/lib/desktop/app-lock-state.ts index b85f90145..9366d8eeb 100644 --- a/lib/desktop/app-lock-state.ts +++ b/lib/desktop/app-lock-state.ts @@ -2,8 +2,11 @@ * Whether the app is locked, for everything that would show mail outside the app. * * lib/app-lock/controller.ts calls setAppLockCheck once, at the start of each page, with - * a function that answers from its own state. Main keeps its own answer for the tray, the - * jump list and toasts (the shell's app-lock-gate.ts). + * a function that answers from its own state, and calls bridge.os.setLocked(true) when it + * locks. components/desktop/desktop-os-host.tsx says it's open again when the app starts + * behind the lock. Main then clears the tray, the jump list and delivered toasts, and strips + * anything that still comes through. Main keeps its own answer too (the shell's + * app-lock-gate.ts), which stands over what any page says. */ let check: (() => boolean) | null = null; diff --git a/lib/desktop/bridge.ts b/lib/desktop/bridge.ts index 922a5c1b5..33e36bd81 100644 --- a/lib/desktop/bridge.ts +++ b/lib/desktop/bridge.ts @@ -19,6 +19,11 @@ export function forgetDesktopServer(serverUrl: string, reason: 'failed' | 'signe getDesktopBridge()?.servers?.forget?.(serverUrl, reason); } +/** Nothing of an account stays on the OS's surfaces after a sign-out or a wipe. */ +export function clearDesktopSurfaces(): void { + getDesktopBridge()?.os?.clearSurfaces?.(); +} + /** True in any window of the desktop app, standalone or on an instance. */ export function isDesktopApp(): boolean { return getDesktopBridge() !== null; diff --git a/lib/desktop/desktop-telemetry.ts b/lib/desktop/desktop-telemetry.ts new file mode 100644 index 000000000..7eca1d05f --- /dev/null +++ b/lib/desktop/desktop-telemetry.ts @@ -0,0 +1,165 @@ +/** + * What the desktop app's telemetry and crash reports agree on across the webmail and + * the shell: when consent counts, what a heartbeat from the shell holds, and when one + * is due. + * + * No DOM, no Node, no imports: the desktop repo keeps a byte-for-byte copy in + * src/shared/desktop-telemetry.ts. Main can't import lib/telemetry, which needs the + * store and node:dns, and in instance mode no server runs inside the app. It holds no + * host name either: the shell has its own constant, and lib/telemetry/types.ts has this + * side's. + * + * The collector decides the shape (repos/dashboard/telemetry-collector/src/validate.ts). + * Schema 1 drops fields it doesn't know and schema 2 refuses the whole heartbeat over + * one, so mode and channel travel in `build`, the one free field it keeps. It stores + * `platform: 'desktop'` as `unknown` until it has been patched. + */ + +export type DesktopTelemetryMode = 'standalone' | 'instance'; +export type DesktopTelemetryChannel = 'stable' | 'beta'; +export type DesktopOsFamily = 'linux' | 'darwin' | 'windows' | 'unknown'; + +/** What main tells the embedded server, by message. Nothing in a page can write it. */ +export interface DesktopServerPrivacy { + telemetry: boolean; + crashReports: boolean; + /** The desktop app's version. The webmail's own is in `NEXT_PUBLIC_APP_VERSION`. */ + appVersion: string; + /** `desktop..`. */ + build: string; + /** Only a test build of the shell sets this. Nothing on disk can: see `telemetryEndpoint`. */ + endpoint?: string; +} + +/** + * How soon after a start a heartbeat that's due goes out. The server's own hour is right + * for a server: an app quit within an hour of every start would never send. Random, so + * the collector can't read when someone starts their computer from when it arrives. + */ +export const DESKTOP_CATCH_UP_MIN_MS = 30_000; +export const DESKTOP_CATCH_UP_MAX_MS = 600_000; + +export function desktopCatchUpMs(random: () => number = Math.random): number { + return DESKTOP_CATCH_UP_MIN_MS + Math.floor(random() * (DESKTOP_CATCH_UP_MAX_MS - DESKTOP_CATCH_UP_MIN_MS)); +} + +/** A failed send tries again this much later, not a day later. */ +export const DESKTOP_RETRY_MS = 60 * 60_000; +export const DESKTOP_HEARTBEAT_INTERVAL_MS = 24 * 60 * 60_000; + +/** The start of a main-process frame's path. Only the shell's own bundle gets it. */ +export const DESKTOP_FRAME_PREFIX = '/desktop/'; +/** On the version of everything a desktop app reports. The collector's version field allows it. */ +export const DESKTOP_VERSION_SUFFIX = '+desktop'; + +/** A policy that forces something off. A recommendation doesn't: the user's choice beats it. */ +export function forcedOffByPolicy(entry: { value: boolean; locked: boolean }): boolean { + return entry.locked && entry.value === false; +} + +/** + * Whether something opt-in is on. Only a yes counts: no answer is off, and a policy + * allowing it still leaves the question to the user. + */ +export function consentInForce(choice: boolean | null | undefined, policy: { value: boolean; locked: boolean }): boolean { + return choice === true && !forcedOffByPolicy(policy); +} + +export function desktopBuildTag(mode: DesktopTelemetryMode, channel: DesktopTelemetryChannel): string { + return `desktop.${mode}.${channel}`; +} + +/** Forced beats the user's choice, which beats what the version says. */ +export function desktopChannel( + policy: { value: DesktopTelemetryChannel; locked: boolean }, + choice: unknown, + appVersion: string, +): DesktopTelemetryChannel { + if (policy.locked) return policy.value; + if (choice === 'stable' || choice === 'beta') return choice; + return appVersion.includes('-') ? 'beta' : 'stable'; +} + +export function desktopOsFamily(platform: string): DesktopOsFamily { + if (platform === 'win32') return 'windows'; + if (platform === 'darwin') return 'darwin'; + if (platform === 'linux') return 'linux'; + return 'unknown'; +} + +/** How long until the next heartbeat, given when the last one went out. */ +export function heartbeatDelayMs(lastSentAt: number | null | undefined, now: number, catchUp: number = desktopCatchUpMs()): number { + if (!lastSentAt || lastSentAt > now) return catchUp; + const left = lastSentAt + DESKTOP_HEARTBEAT_INTERVAL_MS - now; + return left > 0 ? left : catchUp; +} + +export interface DesktopPingInput { + /** The shell's own UUID, never an instance's. */ + instanceId: string; + appVersion: string; + nodeVersion: string; + osFamily: DesktopOsFamily; + mode: DesktopTelemetryMode; + channel: DesktopTelemetryChannel; + now: number; +} + +/** + * The shell's own heartbeat, for when no server runs inside the app. Features and counts + * are the instance's to report, so these are all empty and nothing is counted twice. + */ +export function buildDesktopPing(input: DesktopPingInput) { + return { + schema: '1' as const, + instance_id: input.instanceId, + ts: new Date(input.now).toISOString(), + version: input.appVersion, + build: desktopBuildTag(input.mode, input.channel), + platform: 'desktop', + node_version: input.nodeVersion, + os_family: input.osFamily, + stalwart_version: null, + features: { calendar: false, contacts: false, files: false, extensions: false, oauth_enabled: false, smime_enabled: false }, + counts: { accounts: '0', accounts_active_7d: '0', extensions_installed: 0, themes_installed: 0 }, + uptime_days: 0, + }; +} + +export function desktopErrorCountBucket(count: number): string { + if (count <= 1) return '1'; + if (count <= 5) return '2-5'; + if (count <= 10) return '6-10'; + if (count <= 50) return '11-50'; + if (count <= 200) return '51-200'; + return '201+'; +} + +export interface DesktopMainError { + name: string; + /** 16 hex digits. */ + fingerprint: string; + version: string; + count: number; +} + +/** + * Main's errors when no server runs inside the app: the collector's /v1/errors takes + * this without a heartbeat, so it works with telemetry off. Name and count only. The + * collector refuses a whole batch over a frame outside the webmail's bundles. + */ +export function buildDesktopErrorBatch(instanceId: string, now: number, errors: readonly DesktopMainError[]) { + return { + schema: '1' as const, + instance_id: instanceId, + day: new Date(now).toISOString().slice(0, 10), + errors: errors.map((error) => ({ + name: error.name, + fingerprint: error.fingerprint, + frames: [], + version: error.version, + count: desktopErrorCountBucket(error.count), + days: 1, + })), + }; +} diff --git a/lib/desktop/file-drag.ts b/lib/desktop/file-drag.ts new file mode 100644 index 000000000..07084646c --- /dev/null +++ b/lib/desktop/file-drag.ts @@ -0,0 +1,67 @@ +/** + * Mail as real files from the page's side: a native drag or a copy to the clipboard. + * Later agents' calls for the taskbar's progress bar live here too. + */ + +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { setOwnDragActive } from '@/lib/desktop/window-files'; + +/** The page's CSP keeps fetch off blob: URLs, so a file travels as its Blob. */ +export interface DragFile { + name: string; + blob: Blob; +} + +const keptBlobs = new Map(); + +/** In the app, remembers what's behind an object URL made for dragging. */ +export function keepDragBlob(url: string, blob: Blob): string { + if (canDragNativeFiles()) keptBlobs.set(url, blob); + return url; +} + +export function keptDragBlob(url: string): Blob | undefined { + return keptBlobs.get(url); +} + +export function forgetDragBlob(url: string): void { + keptBlobs.delete(url); +} + +export function canDragNativeFiles(): boolean { + return !!getDesktopBridge()?.os?.startDrag; +} + +async function toBytes(file: DragFile): Promise<{ name: string; data: Uint8Array }> { + return { name: file.name, data: new Uint8Array(await file.blob.arrayBuffer()) }; +} + +/** + * Hands the file to main, which writes it to its private folder and starts the OS's + * drag. The mouse is still down, so this has to come straight from `dragstart`. + */ +export async function startNativeFileDrag(files: readonly DragFile[]): Promise { + const os = getDesktopBridge()?.os; + if (!os?.startDrag) return false; + setOwnDragActive(true); + try { + return await os.startDrag(await Promise.all(files.map(toBytes))); + } finally { + setOwnDragActive(false); + } +} + +/** Copy attachments as files, for pasting into Explorer or Finder. */ +export async function copyFilesToClipboard(files: readonly DragFile[]): Promise { + const os = getDesktopBridge()?.os; + if (!os?.copyFiles) return false; + return os.copyFiles(await Promise.all(files.map(toBytes))); +} + +/** + * The taskbar's progress bar, for an import, an export or a big send. 0 to 1, or null + * to take it away. Does nothing in a browser. + */ +export function setTaskbarProgress(progress: number | null, mode?: 'normal' | 'indeterminate' | 'paused' | 'error'): void { + getDesktopBridge()?.os?.setProgress(progress, mode); +} diff --git a/lib/desktop/keystore-grants.ts b/lib/desktop/keystore-grants.ts index 0a7ad76a4..2fc4cf7df 100644 --- a/lib/desktop/keystore-grants.ts +++ b/lib/desktop/keystore-grants.ts @@ -9,3 +9,11 @@ import { getDesktopBridge } from './bridge'; export function clearKeystoreGrants(): void { void Promise.resolve(getDesktopBridge()?.keystore?.clearGrants?.()).catch(() => {}); } + +/** + * Deletes the crash files on this computer. They can hold the mail of whoever was signed + * in when the app crashed, so the next account at this computer doesn't get to send them. + */ +export function clearDesktopCrashFiles(): void { + void Promise.resolve(getDesktopBridge()?.privacy?.deleteCrashes?.()).catch(() => {}); +} diff --git a/lib/desktop/mail-links.ts b/lib/desktop/mail-links.ts new file mode 100644 index 000000000..12416ef30 --- /dev/null +++ b/lib/desktop/mail-links.ts @@ -0,0 +1,44 @@ +/** + * Opening a message or folder from outside the mail page: a tray item, a toast, a jump + * list entry. The mail page applies a link the way it applies a permalink. If it isn't + * on screen, the link waits and the app goes there. + */ + +import type { MailDeepLink } from '@/lib/deep-links'; + +export interface MailLinkOptions { + replyText?: string; +} + +export type MailLinkOpener = (link: MailDeepLink, options?: MailLinkOptions) => void; + +let opener: MailLinkOpener | null = null; +let waiting: { link: MailDeepLink; options?: MailLinkOptions } | null = null; + +/** The mail page, while it's mounted. Takes over a link that was waiting for it. */ +export function setMailLinkOpener(next: MailLinkOpener): () => void { + opener = next; + if (waiting) { + const { link, options } = waiting; + waiting = null; + next(link, options); + } + return () => { + if (opener === next) opener = null; + }; +} + +export function openMailLink(link: MailDeepLink, options: MailLinkOptions | undefined, leaveFor: (path: '/') => void | Promise): void { + if (opener) { + opener(link, options); + return; + } + waiting = { link, options }; + void leaveFor('/'); +} + +/** For tests. */ +export function resetMailLinks(): void { + opener = null; + waiting = null; +} diff --git a/lib/desktop/mail-notifications.ts b/lib/desktop/mail-notifications.ts new file mode 100644 index 000000000..12cd12e5d --- /dev/null +++ b/lib/desktop/mail-notifications.ts @@ -0,0 +1,180 @@ +/** + * New mail as native notifications, with the same rules as the app's other ways of + * saying so. Mail notifications off or the app's Do not disturb: nothing. Mail that + * arrives together is told about once. Sender and subject stay off the notification + * when the app is locked, or when the organisation says so for that login + * (pushWithoutContent, the rule the push preview route follows). + * + * Main decides what the OS allows: pause, quiet hours, Do Not Disturb. It answers + * whether the page should play the sound the user picked, because this is the one + * place that sound is played from in the desktop app. + */ + +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import type { DesktopOsNotification } from '@/lib/desktop/types'; +import { isAppLocked } from '@/lib/desktop/app-lock-state'; +import { startNewMailWatch, type NewMail, type NewMailWatch } from '@/lib/desktop/new-mail'; +import { setStoreCreatedListener } from '@/lib/desktop/system-events'; +import { devicesForAccount } from '@/lib/device-policy'; +import { playNotificationSound } from '@/lib/notification-sound'; +import { isPopoutWindow } from '@/lib/popout/window-role'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +export const MAIL_BATCH_MS = 3000; + +export interface MailNotificationLabels { + newMail: string; + newMailCount: (count: number) => string; + noSubject: string; + archive: string | null; + markRead: string; + delete: string; + reply: string; + replyPlaceholder: string; +} + +/** What a toast's button hands back, possibly after the app restarted. */ +export interface MailNotificationContext { + emailId?: string; + threadId?: string; + accountId?: string; + slot?: number; + login?: string; +} + +/** True where this module plays the new-mail sound, so useNewMailAlerts doesn't as well. */ +export function desktopPlaysMailSound(): boolean { + return !!getDesktopBridge()?.os && !isPopoutWindow(); +} + +interface Sender { + entryId: string | null; + slot?: number; + login?: string; + label: string | null; +} + +async function senderInfo(client: IJMAPClient): Promise { + const auth = useAuthStore.getState(); + const accounts = useAccountStore.getState().accounts; + const entry = accounts.find((account) => auth.getClientForAccount(account.id) === client) + ?? (auth.client === client ? accounts.find((account) => account.id === auth.activeAccountId) : undefined); + if (!entry) return { entryId: null, label: null }; + return { + entryId: entry.id, + slot: entry.cookieSlot, + login: await loginFingerprint(entry.serverUrl, entry.username), + label: accounts.length > 1 ? (entry.label || entry.email || entry.username || null) : null, + }; +} + +/** Sender and subject stay off everything outside the app. Not knowing yet counts as yes. */ +export function contentHiddenFor(accountEntryId: string | null | undefined): boolean { + if (isAppLocked()) return true; + // Unknown counts as on, as on the server's route. + if (!accountEntryId || !usePolicyStore.getState().loaded) return true; + try { + return devicesForAccount(accountEntryId).pushWithoutContent; + } catch { + return true; + } +} + +function withoutContent(sender: Sender): boolean { + return contentHiddenFor(sender.entryId); +} + +function senderName(mail: NewMail): string { + const first = mail.email.from?.[0]; + return first?.name || first?.email || ''; +} + +export async function buildMailNotification(batch: readonly NewMail[], labels: MailNotificationLabels): Promise { + const newest = batch.reduce((a, b) => (new Date(b.email.receivedAt ?? 0).getTime() > new Date(a.email.receivedAt ?? 0).getTime() ? b : a)); + const sender = await senderInfo(newest.client); + const hidden = withoutContent(sender); + const context: MailNotificationContext = { accountId: newest.accountId, slot: sender.slot, login: sender.login }; + const base = { + id: `mail:${newest.accountId}/${newest.email.id}`, + group: `mail:${newest.accountId}`, + sound: true, + attention: true, + }; + const suffix = sender.label && !hidden ? ` (${sender.label})` : ''; + if (batch.length > 1) { + return { + ...base, + title: `${labels.newMailCount(batch.length)}${suffix}`, + body: hidden ? '' : `${senderName(newest)}: ${newest.email.subject || labels.noSubject}`, + // Nothing for one message to act on: a click opens the Inbox. + context: JSON.stringify(context), + }; + } + const full: MailNotificationContext = { ...context, emailId: newest.email.id, threadId: newest.email.threadId }; + if (hidden) { + return { ...base, title: labels.newMail, body: '', context: JSON.stringify(full) }; + } + return { + ...base, + title: `${senderName(newest) || labels.newMail}${suffix}`, + body: newest.email.subject || newest.email.preview || labels.noSubject, + context: JSON.stringify(full), + actions: [ + ...(labels.archive ? [{ id: 'archive', label: labels.archive }] : []), + { id: 'mark-read', label: labels.markRead }, + { id: 'delete', label: labels.delete }, + ], + reply: { label: labels.reply, placeholder: labels.replyPlaceholder }, + }; +} + +export interface MailNotifications extends NewMailWatch { + setLabels(labels: MailNotificationLabels): void; +} + +export function startMailNotifications(initial: MailNotificationLabels): MailNotifications { + let labels = initial; + let batch: NewMail[] = []; + let timer: ReturnType | null = null; + + const flush = () => { + timer = null; + const current = batch; + batch = []; + if (current.length === 0) return; + const settings = useSettingsStore.getState(); + if (!settings.emailNotificationsEnabled || settings.doNotDisturb) return; + void (async () => { + const notification = await buildMailNotification(current, labels); + const result = await getDesktopBridge()?.os?.notify({ ...notification, sound: settings.emailNotificationSound }); + if (result?.playSound) playNotificationSound(settings.notificationSoundChoice); + })().catch((error) => { + console.error('Failed to show a notification:', error); + }); + }; + + const watch = startNewMailWatch((mail) => { + batch.push(...mail); + timer ??= setTimeout(flush, MAIL_BATCH_MS); + }); + + const handle: MailNotifications = { + ...watch, + setLabels(next) { + labels = next; + }, + stop() { + watch.stop(); + if (timer) clearTimeout(timer); + timer = null; + setStoreCreatedListener(null); + }, + }; + setStoreCreatedListener((client, accountId, ids) => watch.noteCreated(client, accountId, ids)); + return handle; +} diff --git a/lib/desktop/new-mail.ts b/lib/desktop/new-mail.ts new file mode 100644 index 000000000..efe23173d --- /dev/null +++ b/lib/desktop/new-mail.ts @@ -0,0 +1,151 @@ +/** + * Which messages count as new mail for the desktop app's notifications, for every + * connected login, whichever page or folder is open. + * + * With the local store the source is its `created` event (noteCreatedEmails): the + * engine has already worked out the delta. Without one, each login's push change is + * resolved here with Email/changes on a baseline of its own, the way + * reportNewInboxMail does for plugins. Both can see the same message; it's only told + * once. + */ + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { isMailboxMuted } from '@/lib/notification-mutes'; +import { subscribePushStateChanges } from '@/lib/push-events'; +import { useSettingsStore } from '@/stores/settings-store'; + +export interface NewMail { + client: IJMAPClient; + /** The JMAP account the message is in. */ + accountId: string; + email: Email; +} + +const MAX_PAGES = 5; +const MAX_CHANGES = 200; +const SEEN_LIMIT = 500; +/** An import or a restore creates old mail: nobody is waiting to hear about that. */ +const NEWEST_AGE_MS = 24 * 60 * 60 * 1000; +/** Folders mail lands in by itself, when not only the Inbox notifies. */ +const NOT_ARRIVALS = new Set(['junk', 'trash', 'sent', 'drafts', 'archive']); + +function mailboxIdsOf(email: Email): string[] { + return Object.keys(email.mailboxIds ?? {}).filter((id) => email.mailboxIds[id]); +} + +/** The folder a new message counts for, or null if it stays silent. */ +export function notifyingMailbox(email: Email, accountId: string, ownAccountId: string, mailboxes: readonly Mailbox[], now = Date.now()): Mailbox | null { + if (email.keywords?.$seen || email.keywords?.$draft) return null; + const received = email.receivedAt ? new Date(email.receivedAt).getTime() : now; + if (now - received > NEWEST_AGE_MS) return null; + const { notificationMutes, pushNotifyInboxOnly } = useSettingsStore.getState(); + const here = new Set(mailboxIdsOf(email)); + const mine = mailboxes.filter((mailbox) => (mailbox.accountId ?? ownAccountId) === accountId && here.has(mailbox.originalId || mailbox.id)); + const counts = mine.filter((mailbox) => (pushNotifyInboxOnly ? mailbox.role === 'inbox' : !NOT_ARRIVALS.has(mailbox.role ?? ''))); + // Mail filed in a muted folder and an unmuted one still notifies. + return counts.find((mailbox) => !isMailboxMuted(notificationMutes, accountId, mailbox.originalId || mailbox.id)) ?? null; +} + +export interface NewMailWatch { + /** The local store's `created` event for this login's account. */ + noteCreated(client: IJMAPClient, accountId: string, ids: readonly string[]): void; + /** Takes a state to work from, so the first push after the start can tell what's new. */ + seed(client: IJMAPClient): void; + stop(): void; +} + +export function startNewMailWatch(onMail: (mail: NewMail[]) => void): NewMailWatch { + const baselines = new WeakMap>(); + const seen: string[] = []; + let queue: Promise = Promise.resolve(); + let stopped = false; + + const baselinesOf = (client: IJMAPClient) => { + let map = baselines.get(client); + if (!map) { + map = new Map(); + baselines.set(client, map); + } + return map; + }; + + const enqueue = (run: () => Promise) => { + const next = queue.then(run, run).catch((error) => { + console.error('Failed to check for new mail:', error); + }); + queue = next; + return next; + }; + + const report = async (client: IJMAPClient, accountId: string, ids: readonly string[]) => { + const fresh = ids.filter((id) => !seen.includes(`${accountId}/${id}`)); + if (fresh.length === 0 || stopped) return; + const [emails, mailboxes] = await Promise.all([client.getSomeEmails(fresh, accountId), client.getAllMailboxes()]); + // Only what came back counts as told. With the store on, a push can overtake it: the + // device doesn't have the message yet, and its created event has to be able to tell it. + for (const email of emails) seen.push(`${accountId}/${email.id}`); + seen.splice(0, Math.max(0, seen.length - SEEN_LIMIT)); + const own = client.getAccountId(); + const mail = emails + .filter((email) => notifyingMailbox(email, accountId, own, mailboxes)) + .map((email) => ({ client, accountId, email })); + if (mail.length > 0 && !stopped) onMail(mail); + }; + + const check = async (client: IJMAPClient, accountId: string, pushed: string) => { + const map = baselinesOf(client); + const since = map.get(accountId); + if (!since || !client.getEmailChanges) { + map.set(accountId, pushed); + return; + } + if (since === pushed) return; + const created: string[] = []; + let state = since; + for (let page = 0; page < MAX_PAGES; page += 1) { + const delta = await client.getEmailChanges(state, accountId, MAX_CHANGES); + if (!delta) { + // Too old for the server: start again from here. + map.set(accountId, pushed); + return; + } + created.push(...delta.created); + state = delta.newState; + if (!delta.hasMoreChanges) break; + } + map.set(accountId, state); + await report(client, accountId, created); + }; + + const stopPush = subscribePushStateChanges((change, client) => { + for (const [accountId, types] of Object.entries(change.changed ?? {})) { + const pushed = types?.Email; + if (pushed) void enqueue(() => check(client, accountId, pushed)); + } + }); + + return { + noteCreated(client, accountId, ids) { + void enqueue(() => report(client, accountId, ids)); + }, + seed(client) { + void enqueue(async () => { + const map = baselinesOf(client); + const own = client.getAccountId(); + const mailboxes = await client.getAllMailboxes(); + const inboxes = mailboxes.filter((mailbox) => mailbox.role === 'inbox'); + await Promise.all(inboxes.map(async (inbox) => { + const accountId = inbox.accountId ?? own; + if (map.has(accountId)) return; + const { state } = await client.getEmails(inbox.originalId || inbox.id, accountId, 1); + if (state && !map.has(accountId)) map.set(accountId, state); + })); + }); + }, + stop() { + stopped = true; + stopPush(); + }, + }; +} diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts new file mode 100644 index 000000000..ede5e140b --- /dev/null +++ b/lib/desktop/os-commands.ts @@ -0,0 +1,192 @@ +/** + * What the OS asks of the app, carried out with what the app already has: the launch + * handoff for files and new messages, the protocol handlers for mailto: and webcal:, + * the notification actions for a toast's buttons. A bridge command comes from the + * user's own OS, so it skips the question a web share gets (decision 17). + */ + +import { isAppLocked } from '@/lib/desktop/app-lock-state'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import type { DesktopOsCommand } from '@/lib/desktop/types'; +import { openMailLink } from '@/lib/desktop/mail-links'; +import { handleReminderCommand } from '@/lib/desktop/reminder-notifications'; +import type { MailNotificationContext } from '@/lib/desktop/mail-notifications'; +import { handOffDraft, handOffFiles, handOffNewMessage, kindForFile } from '@/lib/launch-handoff'; +import { parseMailto } from '@/lib/protocol-handlers/mailto'; +import { notifyPendingMailto, notifyPendingWebcal, savePendingMailto, savePendingWebcal } from '@/lib/protocol-handlers/session'; +import { parseWebcal } from '@/lib/protocol-handlers/webcal'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { runNotificationAction, type NotificationActionRequest, type NotificationActionStatus } from '@/lib/pwa-notify/page-actions'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; + +export interface OsCommandEnv { + /** Goes to a page, saving an open composer first. */ + leaveFor: (path: '/' | '/calendar' | '/contacts') => void | Promise; + /** A route change that isn't leaving anything behind. */ + push: (path: string) => void; + /** Says something couldn't be opened. */ + notifyFailed: (names: readonly string[]) => void; + /** A typed reply that has nowhere to go: its message's login is gone from here. */ + notifyReplyLost: () => void; +} + +/** How long a toast's button waits for a cold start to sign in. */ +const SIGN_IN_WAIT_MS = 30_000; + +function parseContext(raw: string | undefined): MailNotificationContext { + if (!raw) return {}; + try { + const parsed: unknown = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed as MailNotificationContext : {}; + } catch { + return {}; + } +} + +function whenSignedIn(): Promise { + return new Promise((resolve) => { + if (useAuthStore.getState().isAuthenticated) { + resolve(true); + return; + } + const finish = (value: boolean) => { + clearTimeout(timer); + stop(); + resolve(value); + }; + const timer = setTimeout(() => finish(false), SIGN_IN_WAIT_MS); + const stop = useAuthStore.subscribe((state) => { + if (state.isAuthenticated) finish(true); + }); + }); +} + +/** A slot goes to whoever signs in next: a day-old toast has to still mean this login. */ +async function stillThatLogin(context: MailNotificationContext): Promise { + if (context.slot === undefined || !context.login) return false; + const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === context.slot); + return !!entry && (await loginFingerprint(entry.serverUrl, entry.username)) === context.login; +} + +function showWindow(): void { + getDesktopBridge()?.windows?.focus?.(); +} + +async function openFromNotification(context: MailNotificationContext, env: OsCommandEnv, reply?: { text: string }): Promise { + showWindow(); + await whenSignedIn(); + const known = await stillThatLogin(context); + if (known && context.emailId) { + openMailLink({ kind: 'message', id: context.emailId, slot: context.slot, ownerAccountId: context.accountId, ...(reply ? { reply: true } : {}) }, reply ? { replyText: reply.text } : undefined, env.leaveFor); + return; + } + if (reply?.text) env.notifyReplyLost(); + openMailLink({ kind: 'folder', ref: 'inbox', ...(known ? { slot: context.slot } : {}) }, undefined, env.leaveFor); +} + +function runAction(request: NotificationActionRequest): Promise { + return new Promise((resolve) => { + void runNotificationAction(request, (status) => { + if (status !== 'accepted') resolve(status); + }); + }); +} + +async function handleNotification(action: string, rawContext: string, reply: string | undefined, env: OsCommandEnv): Promise { + // An old toast's Delete doesn't act behind the lock, and its message doesn't open over it. + if (isAppLocked()) { + showWindow(); + return; + } + if (await handleReminderCommand(action, rawContext, env)) return; + const context = parseContext(rawContext); + if (action === 'reply') { + await openFromNotification(context, env, { text: reply ?? '' }); + return; + } + if ((action === 'archive' || action === 'mark-read' || action === 'delete') && context.emailId && context.accountId) { + if (await whenSignedIn()) { + const status = await runAction({ + action, + emailId: context.emailId, + accountId: context.accountId, + threadId: context.threadId, + slot: context.slot, + login: context.login, + at: Date.now(), + }); + if (status === 'done') return; + } + } + // Clicked, or the button couldn't be carried out here: the message opens. + await openFromNotification(context, env); +} + +function handleFiles(command: Extract, env: OsCommandEnv): void { + const made = command.files.map(({ name, data }) => new File([new Uint8Array(data)], name)); + const failed = [...command.failed]; + if (command.reason === 'attach') { + if (made.length > 0) handOffDraft({ files: made }); + } else { + for (const file of made) { + const kind = kindForFile(file); + if (kind) handOffFiles([file], kind); + else failed.push(file.name); + } + } + if (failed.length > 0) env.notifyFailed(failed); +} + +export function handleOsCommand(command: DesktopOsCommand, env: OsCommandEnv): void | Promise { + switch (command.type) { + case 'new-message': + handOffNewMessage(); + return; + case 'navigate': + if (command.target === 'inbox') openMailLink({ kind: 'folder', ref: 'inbox' }, undefined, env.leaveFor); + else void env.leaveFor(`/${command.target}`); + return; + case 'open-message': { + const context = parseContext(command.context); + if (context.slot !== undefined) return openFromNotification({ ...context, emailId: command.emailId }, env); + openMailLink({ kind: 'message', id: command.emailId, ownerAccountId: command.accountId }, undefined, env.leaveFor); + return; + } + case 'open-mailbox': + openMailLink({ kind: 'folder', ref: command.mailboxId }, undefined, env.leaveFor); + return; + case 'mailto': { + const parsed = parseMailto(command.url); + if (!parsed) return; + savePendingMailto(parsed); + notifyPendingMailto(); + env.push('/'); + return; + } + case 'webcal': { + const parsed = parseWebcal(command.url); + if (!parsed) return; + savePendingWebcal(parsed); + notifyPendingWebcal(); + env.push('/calendar'); + return; + } + case 'files': + handleFiles(command, env); + return; + case 'notification': + return handleNotification(command.action, command.context, command.reply, env); + } +} + +/** Listens for as long as the page lives. Commands wait in main until this runs. */ +export function startOsCommands(env: OsCommandEnv): () => void { + const os = getDesktopBridge()?.os; + if (!os) return () => {}; + return os.onCommand((command) => { + void Promise.resolve(handleOsCommand(command, env)).catch((error) => { + console.error('An OS command failed:', error); + }); + }); +} diff --git a/lib/desktop/os-summary.ts b/lib/desktop/os-summary.ts new file mode 100644 index 000000000..599dd942a --- /dev/null +++ b/lib/desktop/os-summary.ts @@ -0,0 +1,138 @@ +/** + * What the tray and the jump list show: recent unread mail, the newest conversations, + * the next calendar event. Read with the same client calls as the rest of the app, so + * the local store answers when it has them. No content while locked or where the + * organisation keeps it off notifications. + */ + +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { contentHiddenFor } from '@/lib/desktop/mail-notifications'; +import type { DesktopOsSummary, DesktopOsTrayMessage } from '@/lib/desktop/types'; +import type { CalendarEvent, Email } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useCalendarStore } from '@/stores/calendar-store'; +import { useEmailStore } from '@/stores/email-store'; +import { usePolicyStore } from '@/stores/policy-store'; + +const ROWS = 5; +const REFRESH_DELAY_MS = 2000; +const REFRESH_EVERY_MS = 5 * 60 * 1000; +const EVENT_WINDOW_MS = 24 * 60 * 60 * 1000; + +export const EMPTY_SUMMARY: DesktopOsSummary = { unread: [], recent: [], nextEvent: null }; + +export function eventStart(event: CalendarEvent): number { + return new Date(event.utcStart ?? event.start).getTime(); +} + +/** The soonest event still ahead, leaving out cancelled ones. */ +export function nextEventOf(events: readonly CalendarEvent[], now: number): CalendarEvent | null { + const ahead = events + .filter((event) => event.status !== 'cancelled' && !event.showWithoutTime && eventStart(event) >= now) + .sort((a, b) => eventStart(a) - eventStart(b)); + return ahead[0] ?? null; +} + +export function formatEventTime(start: number, now: number, locale: string | undefined): string { + const date = new Date(start); + const sameDay = date.toDateString() === new Date(now).toDateString(); + const options: Intl.DateTimeFormatOptions = sameDay + ? { hour: 'numeric', minute: '2-digit' } + : { weekday: 'short', hour: 'numeric', minute: '2-digit' }; + return new Intl.DateTimeFormat(locale, options).format(date); +} + +export async function readSummary(locale: string | undefined, now = Date.now()): Promise { + const auth = useAuthStore.getState(); + const client: IJMAPClient | null = auth.client; + const entry = useAccountStore.getState().accounts.find((account) => account.id === auth.activeAccountId); + if (!auth.isAuthenticated || !client || !entry || contentHiddenFor(entry.id)) return EMPTY_SUMMARY; + + const inbox = useEmailStore.getState().mailboxes.find((mailbox) => mailbox.role === 'inbox' && !mailbox.isShared); + let unread: DesktopOsTrayMessage[] = []; + let recent: DesktopOsTrayMessage[] = []; + if (inbox) { + const accountId = client.getAccountId(); + const login = await loginFingerprint(entry.serverUrl, entry.username); + const row = (email: Email): DesktopOsTrayMessage => ({ + emailId: email.id, + accountId, + sender: email.from?.[0]?.name || email.from?.[0]?.email || '', + subject: email.subject ?? '', + context: JSON.stringify({ emailId: email.id, threadId: email.threadId, accountId, slot: entry.cookieSlot, login }), + }); + const [unreadList, recentList] = await Promise.all([ + client.getEmails(inbox.originalId || inbox.id, undefined, ROWS, 0, undefined, false, { notKeyword: '$seen' }), + client.getEmails(inbox.originalId || inbox.id, undefined, ROWS), + ]); + unread = unreadList.emails.map(row); + recent = recentList.emails.map(row); + } + + let nextEvent: DesktopOsSummary['nextEvent'] = null; + if (useCalendarStore.getState().supportsCalendar) { + const events = await client.queryCalendarEvents({ + after: new Date(now).toISOString(), + before: new Date(now + EVENT_WINDOW_MS).toISOString(), + }).catch(() => [] as CalendarEvent[]); + const next = nextEventOf(events, now); + if (next) nextEvent = { title: next.title ?? '', when: formatEventTime(eventStart(next), now, locale) }; + } + return { unread, recent, nextEvent }; +} + +/** Keeps main's copy current: when the unread count moves, and now and then for the next event. */ +export function startOsSummary(locale: () => string | undefined): { refresh: () => void; stop: () => void } { + const os = getDesktopBridge()?.os; + if (!os) return { refresh: () => {}, stop: () => {} }; + let timer: ReturnType | null = null; + /** A read under way when the account changed answers for the old one, and is dropped. */ + let generation = 0; + + const run = () => { + timer = null; + const mine = ++generation; + void readSummary(locale()).then((summary) => { + if (mine === generation) os.setSummary(summary); + }, (error) => { + console.warn('Could not read the tray summary:', error); + }); + }; + const refresh = () => { + timer ??= setTimeout(run, REFRESH_DELAY_MS); + }; + const inboxUnread = () => useEmailStore.getState().mailboxes.find((mailbox) => mailbox.role === 'inbox' && !mailbox.isShared)?.unreadEmails ?? -1; + let lastUnread = inboxUnread(); + const stopEmail = useEmailStore.subscribe(() => { + const current = inboxUnread(); + if (current === lastUnread) return; + lastUnread = current; + refresh(); + }); + const stopAuth = useAuthStore.subscribe((state, previous) => { + if (state.activeAccountId === previous.activeAccountId && state.isAuthenticated === previous.isAuthenticated) return; + generation += 1; + if (!state.isAuthenticated) os.setSummary(EMPTY_SUMMARY); + refresh(); + }); + // An organisation turning sender and subject off goes for what's already showing, too. + const stopPolicy = usePolicyStore.subscribe((state, previous) => { + if (state.loaded !== previous.loaded || state.policy !== previous.policy) refresh(); + }); + const interval = setInterval(refresh, REFRESH_EVERY_MS); + refresh(); + return { + refresh, + stop() { + generation += 1; + stopEmail(); + stopAuth(); + stopPolicy(); + clearInterval(interval); + if (timer) clearTimeout(timer); + }, + }; +} diff --git a/lib/desktop/policy-schema.ts b/lib/desktop/policy-schema.ts index 126eaeb66..ee00aec1f 100644 --- a/lib/desktop/policy-schema.ts +++ b/lib/desktop/policy-schema.ts @@ -823,8 +823,8 @@ export const POLICY_CONSUMERS: Record = { UpdateChannel: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' }, AppLockRequired: { repo: 'webmail', file: 'lib/app-lock/policy.ts' }, AppLockTimeoutMinutes: { repo: 'webmail', file: 'lib/app-lock/policy.ts' }, - TelemetryAllowed: { pendingConsumer: 'step 7, telemetry consent' }, - CrashReportsAllowed: { pendingConsumer: 'step 7, telemetry consent' }, + TelemetryAllowed: { repo: 'shell', file: 'src/main/features/privacy.ts' }, + CrashReportsAllowed: { repo: 'shell', file: 'src/main/features/privacy.ts' }, QuickComposeShortcut: { pendingConsumer: 'step 7, quick compose' }, AutoSelectCertificateForUrls: { pendingConsumer: 'step 7, client certificates' }, KeystoreAllowedOrigins: { repo: 'shell', file: 'src/main/features/policies.ts' }, diff --git a/lib/desktop/power.ts b/lib/desktop/power.ts new file mode 100644 index 000000000..1e36e52ae --- /dev/null +++ b/lib/desktop/power.ts @@ -0,0 +1,58 @@ +/** + * Waking up without stale state. After sleep, an unlock or a network change, the app + * does what it does when a tab comes back into view: the JMAP client checks for state + * changes and replaces a dead stream, the identities and policy refresh. Those are the + * handlers already listening, so this only gives them the word. The hidden main window + * of the desktop app never goes out of view, so without this they wouldn't hear it. + * The local store hangs on the same two: `online` is setOnline(true) and a flush, + * `visibilitychange` is a sync per account. It takes itself offline for a sleep through + * addSleepHandler, in lib/desktop/system-events.ts. + */ + +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { runSleepHandlers, runWakeHandlers } from '@/lib/desktop/system-events'; +import { usePolicyStore } from '@/stores/policy-store'; + +/** A resume, an unlock and a network change come together, and each would mean a round of requests. */ +const WAKE_REPEAT_MS = 30_000; + +function wake(): void { + document.dispatchEvent(new Event('visibilitychange')); + // Woken up with no network: the browser's own event comes when there is one. + if (navigator.onLine !== false) window.dispatchEvent(new Event('online')); + const policy = usePolicyStore.getState(); + if (policy.loaded && !policy.fetching) void policy.fetchPolicy(); + runWakeHandlers(); +} + +export function startPowerWatch(): () => void { + const bridge = getDesktopBridge()?.os; + if (!bridge) return () => {}; + let lastWake = 0; + const wakeOnce = () => { + const now = Date.now(); + if (now - lastWake < WAKE_REPEAT_MS) return; + lastWake = now; + wake(); + }; + const stopPower = bridge.onPower((event) => { + if (event === 'resume' || event === 'unlock-screen') wakeOnce(); + else if (event === 'suspend') runSleepHandlers(); + }); + // Wi-Fi to cable is a new network with no offline moment in between. This event also fires + // for every shift in its speed estimate, which isn't news. Where Chromium doesn't report the + // type, that leaves the stream's own ping check and the online event. + const connection = (navigator as unknown as { connection?: EventTarget & { type?: string } }).connection; + let lastType = connection?.type; + const onConnection = () => { + const type = connection?.type; + if (!type || type === lastType) return; + lastType = type; + wakeOnce(); + }; + connection?.addEventListener?.('change', onConnection); + return () => { + stopPower(); + connection?.removeEventListener?.('change', onConnection); + }; +} diff --git a/lib/desktop/reminder-notifications.ts b/lib/desktop/reminder-notifications.ts new file mode 100644 index 000000000..f2840e58f --- /dev/null +++ b/lib/desktop/reminder-notifications.ts @@ -0,0 +1,243 @@ +/** + * Calendar reminders as native notifications, and what their buttons do. + * + * A toast may be clicked a day later and after a restart, so what it hands back says + * which login and which reminder and no more: the rest comes from the store. Main keeps + * pause, quiet hours and the OS's Do Not Disturb. Nothing about the event goes to main at + * all while the app is locked, or when the organisation says so for that login + * (pushWithoutContent, the rule new mail follows). + */ + +import { announceAcknowledged } from '@/lib/calendar-reminders/channel'; +import { pokeReminders, useMissedReminders } from '@/lib/calendar-reminders/state'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { isAppLocked } from '@/lib/desktop/app-lock-state'; +import type { DesktopOsNotification, DesktopOsNotifyResult } from '@/lib/desktop/types'; +import { contentHiddenFor } from '@/lib/desktop/mail-notifications'; +import { joinLinkFrom } from '@/lib/event-links'; +import { openExternalLink, resolveExternalLink } from '@/lib/plugin-hook-result'; +import { loginFingerprint } from '@/lib/pwa-notify/login-fingerprint'; +import { getStore } from '@/lib/store/client/api'; +import type { MissedBatch, ReminderAlert } from '@/lib/store/reminders/ledger'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; + +export const SNOOZE_MINUTES = [5, 10, 15] as const; +const SNOOZE_PREFIX = 'snooze-'; +const JOIN = 'join'; +const OPEN = 'open'; +/** How many titles a summary names. */ +const SUMMARY_TITLES = 3; + +export interface ReminderLabels { + /** Says nothing about the event. */ + generic: string; + /** "In 10 min · Personal". */ + when: (alert: ReminderAlert) => string; + untitled: string; + snooze: (minutes: number) => string; + /** Five buttons in a row on a Windows toast: three "Snooze..." would all cut off. */ + snoozeShort: (minutes: number) => string; + open: string; + join: string; + /** Join a link to no service this app knows: says where it goes. */ + joinHost: (host: string) => string; + missedTitle: (count: number) => string; + missedMore: (count: number) => string; +} + +export interface ReminderToastContext { + /** The reminder. */ + r?: string; + /** The summary. */ + rb?: number; + /** No store to ask: the click only goes to the calendar. */ + cal?: boolean; + slot?: number; + login?: string; +} + +async function loginOf(account: string): Promise> { + const entry = useAccountStore.getState().accounts.find((candidate) => candidate.id === account); + if (!entry) return {}; + return { slot: entry.cookieSlot, login: await loginFingerprint(entry.serverUrl, entry.username) }; +} + +export function joinLabel(alert: Pick, labels: Pick): string { + return alert.joinHost ? labels.joinHost(alert.joinHost) : labels.join; +} + +function reminderId(alert: ReminderAlert): string { + // Main drops an id it saw in the last few minutes, and a snooze can be shorter. + return `reminder:${alert.key}${alert.snoozedUntil ? `:${alert.snoozedUntil}` : ''}`; +} + +export async function buildReminderNotification(account: string, alert: ReminderAlert, labels: ReminderLabels, sound: boolean): Promise { + const base = { group: 'reminders', kind: 'reminder' as const, sound }; + // Not even the uid. Two at the same moment are one "you have a reminder". + if (isAppLocked()) return { ...base, id: `reminder-locked:${alert.fireTimeMs}`, title: labels.generic, body: '', context: '' }; + const context: ReminderToastContext = { r: alert.key, ...(await loginOf(account)) }; + const short = getDesktopBridge()?.platform === 'win32'; + const actions = [ + ...(alert.joinUrl ? [{ id: JOIN, label: joinLabel(alert, labels) }] : []), + { id: OPEN, label: labels.open }, + ...SNOOZE_MINUTES.map((minutes) => ({ id: `${SNOOZE_PREFIX}${minutes}`, label: short ? labels.snoozeShort(minutes) : labels.snooze(minutes) })), + ]; + const hidden = contentHiddenFor(account); + return { + ...base, + id: reminderId(alert), + title: hidden ? labels.generic : alert.title || labels.untitled, + body: hidden ? '' : labels.when(alert), + context: JSON.stringify(context), + actions, + }; +} + +/** The first few titles of a summary, for a place too small for all of them. */ +export function missedBody(alerts: readonly ReminderAlert[], labels: Pick): string { + const named = alerts.slice(0, SUMMARY_TITLES).map((alert) => alert.title || labels.untitled).join(', '); + const more = alerts.length - SUMMARY_TITLES; + return more > 0 ? `${named}, ${labels.missedMore(more)}` : named; +} + +export async function buildMissedNotification(account: string, batch: MissedBatch, labels: ReminderLabels): Promise { + const context: ReminderToastContext = { rb: batch.batch, ...(await loginOf(account)) }; + return { + id: `reminder-missed:${batch.batch}`, + group: 'reminders', + kind: 'reminder', + title: labels.missedTitle(batch.alerts.length), + body: contentHiddenFor(account) ? '' : missedBody(batch.alerts, labels), + context: JSON.stringify(context), + }; +} + +export interface PlainReminder { + key: string; + fireTimeMs: number; + title: string; + body: string; + sound: boolean; +} + +/** + * Without a store: says so, with nothing to snooze. A click goes to the calendar. + * Resolves to main's answer, which also says whether a sound is in order. + */ +export function notifyWithoutStore(reminder: PlainReminder, labels: { generic: string }, account: string | null): Promise { + const hidden = contentHiddenFor(account); + const request = getDesktopBridge()?.os?.notify({ + // The key holds the event's id, and nothing about the event should get out now. + id: hidden ? `reminder-hidden:${reminder.fireTimeMs}` : `reminder:${reminder.key}`, + group: 'reminders', + kind: 'reminder', + title: hidden ? labels.generic : reminder.title, + body: hidden ? '' : reminder.body, + context: JSON.stringify({ cal: true } satisfies ReminderToastContext), + sound: reminder.sound, + }); + return (request ?? Promise.resolve(undefined)).catch(() => undefined); +} + +async function accountOf(context: ReminderToastContext): Promise { + if (context.slot === undefined || !context.login) return null; + const entry = useAccountStore.getState().accounts.find((account) => account.cookieSlot === context.slot); + if (!entry || (await loginFingerprint(entry.serverUrl, entry.username)) !== context.login) return null; + return entry.id; +} + +/** Goes to the event when it's in the account on screen, otherwise to the calendar. */ +export function calendarPathFor(account: string, alert: ReminderAlert | null): string | null { + if (!alert || alert.kind !== 'event' || useAuthStore.getState().activeAccountId !== account) return null; + const query = alert.calendarId ? `?calendar=${encodeURIComponent(alert.calendarId)}` : ''; + return `/calendar/event/${encodeURIComponent(alert.baseEventId)}${query}`; +} + +/** + * Opens a meeting the way every link in the app opens, past any link check a plugin has. + * Resolves true when it opened. Only web links get this far, whatever the store holds. + */ +export async function joinMeeting(url: string): Promise { + const link = joinLinkFrom(url); + if (!link) return false; + const shell = getDesktopBridge()?.shell; + if (!shell) return openExternalLink({ href: link.url }); + const target = await resolveExternalLink({ href: link.url }); + if (!target || !joinLinkFrom(target)) return false; + shell.openExternal(target); + return true; +} + +export function dismissReminders(account: string, keys: string[]): void { + announceAcknowledged(keys); + void getStore().area(account, 'reminders.dismiss', { keys }).catch(() => {}); +} + +export async function snoozeReminder(account: string, key: string, minutes: number): Promise { + announceAcknowledged([key]); + await getStore().area(account, 'reminders.snooze', { key, minutes }); + pokeReminders(); +} + +export interface ReminderCommandEnv { + leaveFor: (path: '/calendar') => void | Promise; + push: (path: string) => void; +} + +function parse(raw: string): ReminderToastContext | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as ReminderToastContext; + return parsed && typeof parsed === 'object' && (parsed.r !== undefined || parsed.rb !== undefined || parsed.cal) ? parsed : null; + } catch { + return null; + } +} + +function snoozeMinutes(action: string): number | null { + if (!action.startsWith(SNOOZE_PREFIX)) return null; + const minutes = Number(action.slice(SNOOZE_PREFIX.length)); + return (SNOOZE_MINUTES as readonly number[]).includes(minutes) ? minutes : null; +} + +/** Whether this click was a reminder's. Buttons that only do something don't bring the window up. */ +export async function handleReminderCommand(action: string, rawContext: string, env: ReminderCommandEnv): Promise { + const context = parse(rawContext); + if (!context) return false; + const account = await accountOf(context); + const show = () => getDesktopBridge()?.windows?.focus?.(); + + if (context.rb !== undefined) { + show(); + const alerts = account ? await getStore().area(account, 'reminders.batch', { batch: context.rb }).catch(() => []) : []; + if (account && alerts.length > 0) useMissedReminders.getState().show(account, alerts); + else await env.leaveFor('/calendar'); + return true; + } + + const key = context.r; + const minutes = snoozeMinutes(action); + if (minutes !== null && account && key) { + await snoozeReminder(account, key, minutes); + return true; + } + + const alert = account && key ? await getStore().area(account, 'reminders.get', { key }).catch(() => null) : null; + if (action === JOIN && alert?.joinUrl) { + if (await joinMeeting(alert.joinUrl)) { + dismissReminders(account!, [alert.key]); + } else { + show(); + } + return true; + } + + // Open, a click on the toast, or a button that can't be carried out any more. + show(); + await env.leaveFor('/calendar'); + const path = account ? calendarPathFor(account, alert) : null; + if (path) env.push(path); + if (account && alert) dismissReminders(account, [alert.key]); + return true; +} diff --git a/lib/desktop/server-links.ts b/lib/desktop/server-links.ts new file mode 100644 index 000000000..25257222b --- /dev/null +++ b/lib/desktop/server-links.ts @@ -0,0 +1,33 @@ +/** + * The two things main hands the embedded server while it runs. The server's host + * (src/server/host.ts in the desktop repo) has no way into Next's bundles but globalThis, + * so it calls what this leaves there. instrumentation.node.ts installs them, only in the + * desktop build. + */ + +import { captureDesktopMainError, type DesktopMainErrorInput } from '@/lib/error-reporting/server'; +import { reschedule } from '@/lib/telemetry/sender'; + +const PRIVACY_CHANGED = Symbol.for('bulwark.desktop.privacyChanged'); +const MAIN_ERRORS = Symbol.for('bulwark.desktop.mainErrors'); +const MAIN_ERRORS_READY = Symbol.for('bulwark.desktop.mainErrorsReady'); + +type Holder = Record; + +function drainMainErrors(holder: Holder): void { + const queue = holder[MAIN_ERRORS]; + if (!Array.isArray(queue)) return; + for (const error of queue.splice(0)) void captureDesktopMainError(error as DesktopMainErrorInput); +} + +export function installDesktopServerLinks(): void { + const holder = globalThis as unknown as Holder; + // Turning telemetry on starts the heartbeat now, without a restart. Off needs no + // more than the value: sendOnce reads it before every send. + holder[PRIVACY_CHANGED] = () => { + void reschedule(); + }; + // Errors from before this ran are waiting in the queue. + holder[MAIN_ERRORS_READY] = () => drainMainErrors(holder); + drainMainErrors(holder); +} diff --git a/lib/desktop/server-privacy.ts b/lib/desktop/server-privacy.ts new file mode 100644 index 000000000..df79316bb --- /dev/null +++ b/lib/desktop/server-privacy.ts @@ -0,0 +1,19 @@ +/** + * The desktop user's telemetry and crash report consent, as the embedded server sees it. + * + * The Electron main process sends this at start and on every change (src/server/host.ts + * in the desktop repo sets it on globalThis, which all of Next's bundles share, like the + * list of trusted servers). Nothing in the renderer or the environment can write it. + * + * Only the desktop build reads it. No value means nobody has said yes: everything off. + */ + +import { IS_DESKTOP_BUILD } from './flag'; +import type { DesktopServerPrivacy } from './desktop-telemetry'; + +const PRIVACY_KEY = Symbol.for('bulwark.desktop.privacy'); + +export function desktopServerPrivacy(): DesktopServerPrivacy | null { + if (!IS_DESKTOP_BUILD) return null; + return (globalThis as unknown as Record)[PRIVACY_KEY] ?? null; +} diff --git a/lib/desktop/system-events.ts b/lib/desktop/system-events.ts new file mode 100644 index 000000000..f1b258077 --- /dev/null +++ b/lib/desktop/system-events.ts @@ -0,0 +1,56 @@ +/** + * What the local store and the OS integration say to each other, with nothing imported + * either way: lib/offline runs in every browser, and the notification code pulls in half + * the app's stores. + */ + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +type Handler = () => void; +type CreatedListener = (client: IJMAPClient, accountId: string, ids: readonly string[]) => void; + +const wakeHandlers = new Set(); +const sleepHandlers = new Set(); +let createdListener: CreatedListener | null = null; + +export function addWakeHandler(handler: Handler): () => void { + wakeHandlers.add(handler); + return () => { + wakeHandlers.delete(handler); + }; +} + +export function addSleepHandler(handler: Handler): () => void { + sleepHandlers.add(handler); + return () => { + sleepHandlers.delete(handler); + }; +} + +function runAll(handlers: Set): void { + for (const handler of [...handlers]) { + try { + handler(); + } catch (error) { + console.error('A wake or sleep handler failed:', error); + } + } +} + +export function runWakeHandlers(): void { + runAll(wakeHandlers); +} + +export function runSleepHandlers(): void { + runAll(sleepHandlers); +} + +/** The notification path, while it runs. */ +export function setStoreCreatedListener(listener: CreatedListener | null): void { + createdListener = listener; +} + +/** The store's `created` event for mail. Does nothing in a browser or a pop-out. */ +export function reportStoreCreatedMail(client: IJMAPClient, accountId: string, ids: readonly string[]): void { + createdListener?.(client, accountId, ids); +} diff --git a/lib/desktop/taskbar-progress.ts b/lib/desktop/taskbar-progress.ts new file mode 100644 index 000000000..69098f37b --- /dev/null +++ b/lib/desktop/taskbar-progress.ts @@ -0,0 +1,13 @@ +import { getDesktopBridge } from './bridge'; + +type ProgressMode = 'normal' | 'indeterminate' | 'paused' | 'error'; + +/** + * The taskbar's progress bar: 0 to 1, or null to take it away. Does nothing in a + * browser, or in a shell with no `os.setProgress`, which arrives with + * feat/os-integration. When that merges, its setTaskbarProgress can replace this. + */ +export function showTaskbarProgress(progress: number | null, mode?: ProgressMode): void { + const bridge = getDesktopBridge() as { os?: { setProgress?: (progress: number | null, mode?: ProgressMode) => void } } | null; + bridge?.os?.setProgress?.(progress, mode); +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 65bdf21e4..98bf54fe6 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -315,6 +315,166 @@ export interface DesktopAppLockBridge { onLockRequest: (listener: () => void) => () => void; } +export interface DesktopOsSettings { + /** Closing the main window leaves the app running in the tray. */ + closeToTray: boolean; + startAtLogin: boolean; + /** At login: no window, only the tray. */ + startMinimized: boolean; + /** Taskbar flash or dock bounce on new mail. */ + attentionOnNewMail: boolean; + quietHoursEnabled: boolean; + /** HH:MM, local time. */ + quietHoursStart: string; + quietHoursEnd: string; + /** "Show Bulwark", an Electron accelerator. Null: off. */ + showShortcut: string | null; +} + +export interface DesktopOsStatus { + settings: DesktopOsSettings; + pausedUntil: number | null; + /** The shortcut in settings is really held. Another app can have it. */ + shortcutActive: boolean; +} + +export interface DesktopOsFile { + name: string; + data: Uint8Array; +} + +/** What the OS asks the app to do: a tray item, a jump list entry, a toast, a double-clicked file. */ +export type DesktopOsCommand = + | { type: 'new-message' } + | { type: 'navigate'; target: 'inbox' | 'calendar' | 'contacts' } + | { type: 'open-message'; emailId: string; accountId?: string; context?: string } + | { type: 'open-mailbox'; mailboxId: string } + | { type: 'mailto'; url: string } + | { type: 'webcal'; url: string } + | { type: 'files'; reason: 'open' | 'attach'; files: DesktopOsFile[]; failed: string[] } + | { type: 'notification'; action: string; context: string; reply?: string }; + +export interface DesktopOsNotification { + /** Tag of the toast. Showing an id again within a few minutes does nothing. */ + id: string; + group?: string; + title: string; + body: string; + /** Handed back with the action. Keep it short: Windows keeps it in the toast. */ + context: string; + actions?: { id: string; label: string }[]; + reply?: { label: string; placeholder: string }; + /** The user wants a sound with this. */ + sound?: boolean; + /** New mail: flash the taskbar or bounce the dock if that's on. */ + attention?: boolean; + /** What a locked app says instead: new mail unless this says otherwise. */ + kind?: 'mail' | 'reminder'; +} + +export interface DesktopOsNotifyResult { + shown: boolean; + reason?: 'duplicate' | 'paused' | 'quiet-hours' | 'focused' | 'unsupported' | 'invalid' | 'failed'; + /** The page should play the sound the user picked. */ + playSound: boolean; +} + +export interface DesktopOsTrayMessage { + emailId: string; + accountId: string; + sender: string; + subject: string; + context?: string; +} + +export interface DesktopOsSummary { + unread: DesktopOsTrayMessage[]; + /** Newest conversations, whether read or not, for the jump list. */ + recent: DesktopOsTrayMessage[]; + nextEvent: { title: string; when: string } | null; +} + +export type DesktopOsPowerEvent = 'suspend' | 'resume' | 'lock-screen' | 'unlock-screen'; + +export interface DesktopOsDefaultAppStatus { + /** Offered to the OS as a mail app. */ + registered: boolean; + mailto: boolean; + webcal: boolean; +} + +export interface DesktopOsBridge { + /** + * What the OS asks for. Main holds these until the main window's page subscribes, + * so a cold start loses nothing. + */ + onCommand: (listener: (command: DesktopOsCommand) => void) => () => void; + getStatus: () => Promise; + setSettings: (patch: Partial) => Promise; + /** Pause notifications until this time, or null to end the pause. */ + setPaused: (until: number | null) => void; + onStatusChange: (listener: (status: DesktopOsStatus) => void) => () => void; + /** The unread count: overlay on Windows, setBadgeCount on macOS and Linux, the tray's dot. */ + setBadge: (count: number) => void; + /** What the tray and the jump list show. */ + setSummary: (summary: DesktopOsSummary) => void; + /** The taskbar's progress bar: lib/desktop/file-drag.ts setTaskbarProgress. */ + setProgress: (progress: number | null, mode?: 'normal' | 'indeterminate' | 'paused' | 'error') => void; + /** + * App lock's state. Main counts as locked until the main window's page says otherwise, + * and clears the tray, jump list and toasts and strips what comes through. + */ + setLocked: (locked: boolean) => void; + /** Sign-out or a wipe: delivered toasts, badge, tray and jump list go, and temporary files. */ + clearSurfaces: () => void; + notify: (notification: DesktopOsNotification) => Promise; + onPower: (listener: (event: DesktopOsPowerEvent) => void) => () => void; + /** Writes the files to a private folder and starts a drag. Only with the mouse held. */ + startDrag: (files: DesktopOsFile[]) => Promise; + /** The same, onto the clipboard as files. */ + copyFiles: (files: DesktopOsFile[]) => Promise; + defaultAppStatus: () => Promise; + /** Offers the app to the OS and opens where the user chooses. */ + openDefaultAppSettings: () => Promise; +} + +export interface DesktopPrivacyState { + /** `forcedOff`: a policy says no, so `on` is false and the user can't change it. */ + telemetry: { on: boolean; forcedOff: boolean }; + /** `uploadUrl` is where "Send crash report" goes. Empty: nowhere, and the action says so. */ + crashReports: { on: boolean; forcedOff: boolean; uploadUrl: string }; +} + +export interface DesktopCrashFile { + /** Opaque: the only values `sendCrash` accepts. */ + id: string; + /** Milliseconds since 1970. */ + createdAt: number; + bytes: number; +} + +export type DesktopCrashSendResult = + | { ok: true } + | { ok: false; reason: 'no-consent' | 'no-url' | 'not-found' | 'cancelled' | 'failed' }; + +/** + * Telemetry and crash reports, each with its own consent, both off until the user says + * yes. A same-origin plugin can reach this, so saying yes and sending a crash file both + * ask in a native dialog that names where it goes. + */ +export interface DesktopPrivacyBridge { + get: () => Promise; + setTelemetry: (on: boolean) => Promise; + setCrashReports: (on: boolean) => Promise; + /** https, or http on this computer. Empty clears it. */ + setCrashUploadUrl: (url: string) => Promise<{ ok: boolean; state: DesktopPrivacyState }>; + listCrashes: () => Promise; + sendCrash: (id: string) => Promise; + deleteCrashes: () => Promise; + /** Also fires when a policy changes. Returns the unsubscribe. */ + onChange: (listener: (state: DesktopPrivacyState) => void) => () => void; +} + export interface BulwarkDesktopBridge { version: number; platform: 'win32' | 'darwin' | 'linux'; @@ -326,12 +486,14 @@ export interface BulwarkDesktopBridge { dialogs?: DesktopDialogsBridge; clipboard?: DesktopClipboardBridge; lifecycle?: DesktopLifecycleBridge; + os?: DesktopOsBridge; policy?: DesktopPolicyBridge; servers?: DesktopServersBridge; auth?: DesktopAuthBridge; plugins?: DesktopPluginsBridge; keystore?: DesktopKeystoreBridge; appLock?: DesktopAppLockBridge; + privacy?: DesktopPrivacyBridge; } declare global { diff --git a/lib/desktop/window-files.ts b/lib/desktop/window-files.ts new file mode 100644 index 000000000..63a67630b --- /dev/null +++ b/lib/desktop/window-files.ts @@ -0,0 +1,95 @@ +/** + * Files dropped on a window or pasted from the clipboard. Whatever already handles a + * drop keeps doing so, which it shows by preventDefault: the composer, the file page, + * calendar import. The rest becomes an attachment on a new draft, or on the composer + * that's open. + */ + +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { handOffDraft } from '@/lib/launch-handoff'; + +interface ComposerTarget { + take: (files: File[]) => unknown; + /** The Pro shell keeps composers mounted on tabs out of sight. */ + visible: () => boolean; +} + +const composerTargets: ComposerTarget[] = []; +/** Our own native drag passing over our own window isn't a new file. */ +let ownDrag = false; + +/** A composer on screen takes files itself. No effect outside the desktop app: only this file reads it. */ +export function setComposerFileTarget(take: ComposerTarget['take'], visible: ComposerTarget['visible']): () => void { + const target = { take, visible }; + composerTargets.push(target); + return () => { + const at = composerTargets.indexOf(target); + if (at !== -1) composerTargets.splice(at, 1); + }; +} + +function visibleComposer(): ComposerTarget | undefined { + return [...composerTargets].reverse().find((target) => target.visible()); +} + +export function setOwnDragActive(active: boolean): void { + ownDrag = active; +} + +function take(files: File[]): void { + const composer = visibleComposer(); + if (composer) composer.take(files); + else handOffDraft({ files }); +} + +function isEditable(target: EventTarget | null): boolean { + const element = target as Partial & { closest?: (selector: string) => Element | null } | null; + return !!element?.closest?.('input, textarea, select, [contenteditable=""], [contenteditable="true"]'); +} + +/** + * Pasting a file is only meant where text isn't. A spreadsheet's cells also come as a + * picture of them, and a field that takes text wants the text. The one editable place a + * bare file belongs in is the composer it's being pasted into. + */ +function pasteIsForUs(event: ClipboardEvent): boolean { + const types = [...(event.clipboardData?.types ?? [])]; + if (types.includes('text/plain') || types.includes('text/html')) return false; + if (!isEditable(event.target)) return true; + const inComposer = !!(event.target as Partial | null)?.closest?.('[data-testid="email-composer"]'); + return inComposer && !!visibleComposer(); +} + +function hasFiles(event: DragEvent): boolean { + return !!event.dataTransfer && [...event.dataTransfer.types].includes('Files'); +} + +export function startWindowFiles(): () => void { + if (!getDesktopBridge()?.os) return () => {}; + const onDragOver = (event: DragEvent) => { + if (event.defaultPrevented || ownDrag || !hasFiles(event)) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; + }; + const onDrop = (event: DragEvent) => { + if (event.defaultPrevented || ownDrag || !hasFiles(event)) return; + event.preventDefault(); + const files = Array.from(event.dataTransfer?.files ?? []); + if (files.length > 0) take(files); + }; + const onPaste = (event: ClipboardEvent) => { + if (event.defaultPrevented) return; + const files = Array.from(event.clipboardData?.files ?? []); + if (files.length === 0 || !pasteIsForUs(event)) return; + event.preventDefault(); + take(files); + }; + window.addEventListener('dragover', onDragOver); + window.addEventListener('drop', onDrop); + window.addEventListener('paste', onPaste); + return () => { + window.removeEventListener('dragover', onDragOver); + window.removeEventListener('drop', onDrop); + window.removeEventListener('paste', onPaste); + }; +} diff --git a/lib/devices/registry.ts b/lib/devices/registry.ts index c69d95813..a826ddd23 100644 --- a/lib/devices/registry.ts +++ b/lib/devices/registry.ts @@ -19,6 +19,12 @@ import { settingsId } from '@/lib/settings-sync'; import { getStore, type CollectionOptions } from '@/lib/shared-store'; import type { DeviceView, WipeReason } from './types'; +/** + * A new row is written to the state dir and stays half a year, for anyone with a + * session. Enough for an office turning this on in one morning: a device turned + * away tries again in a quarter of an hour. + */ +export const REGISTER_PER_HOUR = 60; export const DEVICE_ID_RE = /^[0-9a-f]{64}\/[0-9a-f]{32}$/; export const DEVICE_SECRET_RE = /^[0-9a-f]{64}$/; const USER_KEY_RE = /^[0-9a-f]{64}$/; diff --git a/lib/devices/types.ts b/lib/devices/types.ts index 367b6bef7..f4e5facf7 100644 --- a/lib/devices/types.ts +++ b/lib/devices/types.ts @@ -3,8 +3,12 @@ * admin and what a device says about its own wipe. No secrets, no store. */ -/** Who asked for a wipe. `rejected`: the server refused the stored login for good. */ -export const WIPE_REASONS = ['admin', 'user', 'rejected'] as const; +/** + * Who asked for a wipe. `rejected`: the server refused the stored login, and + * `session`: the idle or session limit ran out, both where the organisation asks + * for deletion at once. + */ +export const WIPE_REASONS = ['admin', 'user', 'rejected', 'session'] as const; export type WipeReason = (typeof WIPE_REASONS)[number]; export interface DeviceView { diff --git a/lib/devices/wipe-state.ts b/lib/devices/wipe-state.ts index 2b8d9b749..5e4e97f82 100644 --- a/lib/devices/wipe-state.ts +++ b/lib/devices/wipe-state.ts @@ -4,6 +4,7 @@ * is a count, a row id and the secret for that one row. */ +import type { ForcedSignOutKind } from '@/lib/offline/credentials-rejected'; import { EVICTED_KEEP_MS, readEvicted } from '@/lib/offline/state'; import type { WipeReason } from './types'; @@ -107,7 +108,7 @@ export function consumeWipeNote(): WipeNote | null { return note.unknown ? { unsent: note.unsent, unknown: true } : { unsent: note.unsent }; } -// ---- A refused login: what waits for the new password. A count, never subjects ---- +// ---- A login ended with nobody asking: what waits for the next sign-in. A count, never subjects ---- export interface RefusedLogin { at: number; @@ -115,6 +116,23 @@ export interface RefusedLogin { unsent: number | null; /** Not one bad answer at a start: what a deletion the organisation asked for needs. */ certain: boolean; + /** Absent: `refused`. */ + kind?: ForcedSignOutKind; + /** The count has been asked for, whatever came back. */ + counted?: boolean; + /** Accounts signed in beside it at the time: they may see its name, and nobody else does. */ + seenBy?: string[]; + label?: string; + username?: string; + toasted?: boolean; +} + +export interface RefusedLoginMark { + certain?: boolean; + kind?: ForcedSignOutKind; + seenBy?: string[]; + label?: string; + username?: string; } export function readRefusedLogins(): Record { @@ -134,14 +152,24 @@ function writeRefused(refused: Record): void { for (const listener of [...refusedListeners]) listener(); } -export function markLoginRefused(account: string, certain = true, now = Date.now()): void { +export function markLoginRefused(account: string, mark: RefusedLoginMark = {}, now = Date.now()): void { const before = readRefusedLogins(); - writeRefused({ ...before, [account]: { at: before[account]?.at ?? now, unsent: null, certain: certain || before[account]?.certain === true } }); + const { certain = true, kind = 'refused', seenBy, label, username } = mark; + writeRefused({ + ...before, + [account]: { + at: before[account]?.at ?? now, + unsent: null, + certain: certain || before[account]?.certain === true, + kind, + ...(seenBy?.length ? { seenBy, label, username } : {}), + }, + }); } export function setRefusedCount(account: string, unsent: number | null): void { const before = readRefusedLogins(); - if (before[account]) writeRefused({ ...before, [account]: { ...before[account], unsent } }); + if (before[account]) writeRefused({ ...before, [account]: { ...before[account], unsent, counted: true } }); } export function dropRefusedLogin(account: string): void { @@ -155,6 +183,7 @@ export interface RefusedWait { unsent: number | null; /** When the data goes if nobody signs in. */ until: number; + kind: ForcedSignOutKind; } /** What the sign-in page says. Numbers only: whoever reads it may not be the person it's about. */ @@ -162,7 +191,51 @@ export function refusedWaits(): RefusedWait[] { const evicted = readEvicted(); return Object.entries(readRefusedLogins()) .filter(([account, wait]) => account in evicted && wait.unsent !== 0) - .map(([account, wait]) => ({ unsent: wait.unsent, until: evicted[account] + EVICTED_KEEP_MS })); + .map(([account, wait]) => ({ unsent: wait.unsent, until: evicted[account] + EVICTED_KEEP_MS, kind: wait.kind ?? 'refused' })); +} + +export interface SiblingWait extends RefusedWait { + account: string; + label: string; + username: string; +} + +function asSiblingWait(account: string, wait: RefusedLogin, evictedAt: number): SiblingWait { + return { + account, + label: wait.label ?? account, + username: wait.username ?? '', + unsent: wait.unsent, + until: evictedAt + EVICTED_KEEP_MS, + kind: wait.kind ?? 'refused', + }; +} + +/** For the account switcher: accounts that left while `active` was signed in beside them. Nobody else sees their names. */ +export function siblingWaits(active: string | null | undefined): SiblingWait[] { + if (!active) return []; + const evicted = readEvicted(); + return Object.entries(readRefusedLogins()) + .filter(([account, wait]) => account in evicted && wait.seenBy?.includes(active)) + .map(([account, wait]) => asSiblingWait(account, wait, evicted[account])); +} + +/** Once each: what the toast says when such an account leaves. Waits for the count, so it can say it. */ +export function takeSiblingToasts(active: string | null | undefined): SiblingWait[] { + if (!active) return []; + const evicted = readEvicted(); + const refused = readRefusedLogins(); + const due = Object.entries(refused) + .filter(([account, wait]) => account in evicted && wait.seenBy?.includes(active) && wait.counted && !wait.toasted); + if (due.length === 0) return []; + writeRefused({ ...refused, ...Object.fromEntries(due.map(([account, wait]) => [account, { ...wait, toasted: true }])) }); + return due.map(([account, wait]) => asSiblingWait(account, wait, evicted[account])); +} + +/** A date for these notes, in the page's language. */ +export function waitDate(until: number): string { + const language = typeof document === 'undefined' ? undefined : document.documentElement.lang || undefined; + return new Date(until).toLocaleDateString(language, { dateStyle: 'medium' }); } // ---- Plugin databases a running frame kept open: tried again at the next load ---- diff --git a/lib/error-reporting/__tests__/desktop-main-errors.test.ts b/lib/error-reporting/__tests__/desktop-main-errors.test.ts new file mode 100644 index 000000000..af36d0763 --- /dev/null +++ b/lib/error-reporting/__tests__/desktop-main-errors.test.ts @@ -0,0 +1,86 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Main's errors on their way through the embedded server: handleServerReport counts them +// them. The webmail scrubs them, so a message off the disk or +// out of a mail can't get through. + +const flag = vi.hoisted(() => ({ IS_DESKTOP_BUILD: true, dsn: '' })); +vi.mock('@/lib/desktop/flag', () => ({ + get IS_DESKTOP_BUILD() { + return flag.IS_DESKTOP_BUILD; + }, +})); +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: async () => {}, + get: (key: string, fallback: unknown) => (key === 'errorReportingDsn' ? flag.dsn : fallback), + }, +})); +vi.mock('@/lib/metrics', () => ({ metricsEnabled: () => false })); +const consent = vi.hoisted(() => ({ value: 'instant' as 'instant' | 'off' })); +vi.mock('@/lib/telemetry/state', () => ({ + effectiveErrorsConsent: async () => ({ consent: consent.value, source: 'env' }), + telemetryDocs: () => ({ update: async () => {}, get: async () => null }), +})); + +import { readErrors, resetAggregateForTests, utcDay } from '../aggregate'; +import { captureDesktopMainError } from '../server'; + +const MAIN = { + name: 'TypeError', + message: "ENOENT: open 'C:\\Users\\linus\\AppData\\Roaming\\Bulwark\\inbox.json' for ceo.private@acme-corp.example", + stack: 'TypeError: secret\n at sendPing (/desktop/main.mjs:120:7)\n at file:///C:/Users/linus/elsewhere/other.js:1:1', + mechanism: 'uncaughtexception', +}; + +let fetchSpy: ReturnType; + +beforeEach(() => { + resetAggregateForTests(); + // The server's limiter sends one per fingerprint per hour. + delete (globalThis as unknown as Record)[Symbol.for('bulwark.error-reporting.server')]; + consent.value = 'instant'; + flag.IS_DESKTOP_BUILD = true; + // The frames' rule is compiled into this build only. + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + flag.dsn = ''; + fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); +}); + +async function counted() { + const day = (await readErrors())[utcDay()] ?? {}; + return Object.values(day); +} + +describe('an error from the main process', () => { + it('is counted with its frames kept and the disk gone', async () => { + await captureDesktopMainError(MAIN); + const [entry] = await counted(); + expect(entry.name).toBe('TypeError'); + expect(entry.frames[0]).toMatchObject({ path: '/desktop/main.mjs', line: 120, fn: 'sendPing' }); + expect(entry.frames[1].path).toBe('[other]'); + expect(JSON.stringify(entry)).not.toContain('linus'); + expect(JSON.stringify(entry)).not.toContain('acme-corp'); + }); + + it('goes nowhere without consent', async () => { + consent.value = 'off'; + flag.dsn = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3'; + await captureDesktopMainError(MAIN); + expect(await counted()).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('never throws, whatever it is given', async () => { + await expect(captureDesktopMainError({ name: 5, message: {}, stack: 7 })).resolves.toBeUndefined(); + const [entry] = await counted(); + expect(entry.name).toBe('Error'); + }); +}); diff --git a/lib/error-reporting/__tests__/desktop-reports.test.ts b/lib/error-reporting/__tests__/desktop-reports.test.ts new file mode 100644 index 000000000..fe46d68f9 --- /dev/null +++ b/lib/error-reporting/__tests__/desktop-reports.test.ts @@ -0,0 +1,235 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + captureException, configureErrorReporting, reportFromError, resetErrorReportingForTests, +} from '../client'; +import { appVersion } from '../envelope'; +import { scrubFramePath, scrubText } from '../scrub'; +import { parseStack, sanitizeFrames } from '../stack'; + +// The desktop app's errors. Main's frames are the shell's own files as /desktop/...: they +// have to survive the check that drops reports with no frame of ours, and nothing about the +// user's disk may get through with them. + +const DSN = 'https://6f0b9c79ed3a4315b76595bc2536ac40@bugsink.example.org/3'; +const SETTINGS = { dsn: DSN, sampleRate: 1, environment: 'production', collect: false }; + +const MAIN_STACK = [ + "TypeError: Cannot read properties of undefined (reading 'x')", + ' at sendPing (/desktop/main.mjs:120:7)', + ' at async /desktop/main.mjs:90:3', + ' at processTicksAndRejections (node:internal/process/task_queues:105:5)', +].join('\n'); + +const EXTENSION_STACK = 'TypeError: nope\n at x (chrome-extension://abc/content.js:1:2)\n at y (moz-extension://def/inject.js:3:4)'; + +function withStack(stack: string): Error { + const error = new TypeError('nope'); + error.stack = stack; + return error; +} + +function setBridge(bridge: unknown): void { + (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = bridge; +} + +const forcedOff = { value: false, locked: true, source: 'machine' }; + +describe('main-process frames', () => { + beforeEach(() => { + // The rule is compiled into the desktop build only. + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('keeps a report whose frames are the shell\'s', () => { + const report = reportFromError(withStack(MAIN_STACK), 'desktop-main', false); + expect(report).not.toBeNull(); + expect(report!.frames[0]).toMatchObject({ path: '/desktop/main.mjs', line: 120, column: 7, fn: 'sendPing' }); + expect(report!.frames[1].path).toBe('/desktop/main.mjs'); + expect(report!.frames[2].path).toBe('node:internal/process/task_queues'); + }); + + it('keeps one where every frame is the shell\'s', () => { + const only = 'TypeError: nope\n at a (/desktop/main.mjs:1:2)\n at b (/desktop/main.mjs:3:4)'; + expect(reportFromError(withStack(only), 'desktop-main', false)?.frames).toHaveLength(2); + }); + + it('still drops a report from a browser extension', () => { + expect(reportFromError(withStack(EXTENSION_STACK), 'onerror', false)).toBeNull(); + }); + + it('survives being posted and scrubbed again', () => { + const frames = parseStack(MAIN_STACK); + expect(sanitizeFrames(frames).map((frame) => frame.path)).toEqual(frames.map((frame) => frame.path)); + }); + + it.each([ + 'C:\\Users\\linus\\AppData\\Local\\Programs\\Bulwark\\resources\\app.asar\\dist\\main.mjs', + 'file:///C:/Users/linus/AppData/Local/Programs/Bulwark/resources/app.asar/dist/main.mjs', + '/home/linus/.local/bin/bulwark/resources/app.asar/dist/main.mjs', + '/desktop/../../Users/linus/secret.js', + '/desktop/C:\\Users\\linus\\x.js', + '/desktop/linus smith/x.js', + 'https://evil.example/desktop/x.js', + ])('gives nothing away for %s', (input) => { + expect(scrubFramePath(input)).toBe('[other]'); + }); +}); + +describe('a server install', () => { + it('sees no difference: a /desktop/ path is just another path', () => { + expect(scrubFramePath('/desktop/main.mjs')).toBe('[other]'); + expect(parseStack('TypeError: x\n at async /desktop/main.mjs:90:3')[0].path).toBe('[other]'); + expect(reportFromError(withStack('TypeError: x\n at a (/desktop/main.mjs:1:2)'), 'onerror', false)).toBeNull(); + }); + + it('keeps reading an anonymous async frame as it did', () => { + // scrubFunctionName and the marker search see the same text as before this existed. + const [frame] = parseStack('TypeError: x\n at async https://mail.example.org/_next/static/chunks/1.js:2:30'); + expect(frame.path).toBe('/_next/static/chunks/1.js'); + expect(parseStack('TypeError: x\n at async node:internal/x:1:2')[0].path).toBe('[other]'); + }); +}); + +describe('what a report says', () => { + it.each([ + ["ENOENT: no such file or directory, open 'C:\\Users\\linus\\AppData\\Roaming\\Bulwark\\crash.dmp'", ['linus', 'AppData', 'crash']], + ['EPERM: operation not permitted, mkdir C:\\Users\\linus\\mail', ['linus']], + ['failed to read /home/linus/.config/Bulwark/state', ['linus', '.config']], + ['Subject Merger plans for Acme failed from ceo.private@acme-corp.example', ['Merger', 'Acme', 'ceo.private', 'acme-corp']], + ['request failed Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.abcdef sealed', ['eyJ', 'abcdef']], + ['token 0123456789abcdef0123456789abcdef0123 refused', ['0123456789']], + ])('%s', (input, gone) => { + const out = scrubText(input); + for (const word of gone) expect(out).not.toContain(word); + }); +}); + +describe('tagged as the desktop app', () => { + let fetchSpy: ReturnType; + + const eventOf = (init: RequestInit) => JSON.parse(String(init.body).trim().split('\n')[2]) as { tags: Record }; + + beforeEach(() => { + resetErrorReportingForTests(); + sessionStorage.clear(); + fetchSpy = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + setBridge(undefined); + }); + + const bundleError = () => withStack('TypeError: nope\n at a (https://mail.example.org/_next/static/chunks/1.js:2:30)'); + + it('in the app, on the event that goes to a DSN', () => { + setBridge({ version: 1, platform: 'win32' }); + configureErrorReporting(SETTINGS); + captureException(bundleError()); + expect(eventOf(fetchSpy.mock.calls[0][1]).tags).toMatchObject({ runtime: 'browser', platform: 'desktop' }); + }); + + it('not in a browser', () => { + configureErrorReporting(SETTINGS); + captureException(bundleError()); + expect(eventOf(fetchSpy.mock.calls[0][1]).tags.platform).toBeUndefined(); + }); + + it('a page in the app sends nothing with crash reports forced off, even to an instance that asks', () => { + setBridge({ version: 1, platform: 'win32', policy: { get: () => ({ CrashReportsAllowed: forcedOff }), onChange: () => () => {} } }); + configureErrorReporting({ ...SETTINGS, collect: true }); + captureException(bundleError()); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('a policy that only recommends leaves the instance\'s own settings in charge', () => { + setBridge({ version: 1, platform: 'win32', policy: { get: () => ({ CrashReportsAllowed: { value: false, locked: false, source: 'recommended' } }), onChange: () => () => {} } }); + configureErrorReporting(SETTINGS); + captureException(bundleError()); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + describe('consent taken back with a page open', () => { + function privacyBridge(on: boolean) { + let listener: ((state: unknown) => void) | undefined; + const say = (next: boolean, forcedOff = false) => listener?.({ telemetry: { on: false, forcedOff: false }, crashReports: { on: next, forcedOff, uploadUrl: '' } }); + setBridge({ + version: 1, + platform: 'win32', + mode: 'instance', + privacy: { + get: async () => ({ telemetry: { on: false, forcedOff: false }, crashReports: { on, forcedOff: false, uploadUrl: '' } }), + onChange: (next: (state: unknown) => void) => { + listener = next; + return () => {}; + }, + }, + }); + return say; + } + const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + + it('stops an open page sending to a DSN', async () => { + const say = privacyBridge(true); + configureErrorReporting(SETTINGS); + await settle(); + captureException(bundleError()); + expect(fetchSpy).toHaveBeenCalledTimes(1); + say(false); + captureException(withStack('TypeError: other\n at b (https://mail.example.org/_next/static/chunks/2.js:2:30)')); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('and a config arriving afterwards doesn\'t bring it back', async () => { + const say = privacyBridge(true); + configureErrorReporting(SETTINGS); + await settle(); + say(false); + configureErrorReporting(SETTINGS); + captureException(bundleError()); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('a change to something else, with crash reports never on, leaves an instance\'s own settings alone', async () => { + const say = privacyBridge(false); + configureErrorReporting(SETTINGS); + await settle(); + say(false); + captureException(bundleError()); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('a policy forcing it off stops it, however it stood', async () => { + const say = privacyBridge(false); + configureErrorReporting(SETTINGS); + await settle(); + say(false, true); + captureException(bundleError()); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('saying yes again lifts it', async () => { + const say = privacyBridge(true); + configureErrorReporting(SETTINGS); + await settle(); + say(false); + say(true); + configureErrorReporting(SETTINGS); + captureException(bundleError()); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); + + it('in the version, in the desktop build only: the collector has no room for a tag', () => { + vi.stubEnv('NEXT_PUBLIC_APP_VERSION', '1.20.0'); + expect(appVersion()).toBe('1.20.0'); + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + expect(appVersion()).toBe('1.20.0+desktop'); + expect(appVersion()).toMatch(/^\d+\.\d+\.\d+(?:[-+][\w.-]{1,40})?$/); + }); +}); diff --git a/lib/error-reporting/client.ts b/lib/error-reporting/client.ts index 2c842fae3..a11237a08 100644 --- a/lib/error-reporting/client.ts +++ b/lib/error-reporting/client.ts @@ -1,4 +1,5 @@ import { apiFetch, getPathPrefix } from '@/lib/browser-navigation'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; import { scrubRoutePath } from '@/lib/route-groups'; import { debugIdsByPath } from './debug-ids'; import { @@ -41,6 +42,10 @@ interface ClientState { breadcrumbs: Breadcrumb[]; limiter: ReportLimiter | null; installed: boolean; + /** In the desktop app: the shell's privacy changes are being listened to. */ + watchingConsent: boolean; + /** The user took crash reports back, or a policy did. A later config doesn't undo that. */ + withdrawn: boolean; } // On globalThis: the error boundary, the JMAP client and the root component @@ -48,7 +53,7 @@ interface ClientState { const KEY = Symbol.for('bulwark.error-reporting.client'); function state(): ClientState { const g = globalThis as typeof globalThis & { [KEY]?: ClientState }; - return (g[KEY] ??= { settings: undefined, pending: [], breadcrumbs: [], limiter: null, installed: false }); + return (g[KEY] ??= { settings: undefined, pending: [], breadcrumbs: [], limiter: null, installed: false, watchingConsent: false, withdrawn: false }); } function sessionStore(): Storage | null { @@ -73,10 +78,36 @@ function currentRoute(): string | undefined { return scrubRoutePath(location.pathname, basePath()); } +/** + * Consent taken back while a page is open, in the desktop app. A change to anything else + * with crash reports never on leaves an instance's own settings alone: its admin chose + * those, and the settings page says so. + */ +function watchDesktopConsent(): void { + const s = state(); + const bridge = getDesktopBridge(); + if (s.watchingConsent || !bridge?.privacy?.onChange) return; + s.watchingConsent = true; + let on: boolean | null = null; + void Promise.resolve(bridge.privacy.get?.()).then((current) => { + on ??= current?.crashReports.on ?? null; + }).catch(() => {}); + bridge.privacy.onChange((next) => { + const tookBack = on === true && !next.crashReports.on; + on = next.crashReports.on; + if (on) s.withdrawn = false; + if (tookBack || next.crashReports.forcedOff) { + s.withdrawn = true; + configureErrorReporting(null); + } + }); +} + /** Apply the settings from /api/config (or Lite's policy.json); null switches reporting off. */ export function configureErrorReporting(settings: ClientSettings | null): void { const s = state(); - const on = settings && (parseDsn(settings.dsn) || settings.collect) ? settings : null; + watchDesktopConsent(); + const on = !s.withdrawn && settings && (parseDsn(settings.dsn) || settings.collect) ? settings : null; s.settings = on; const queued = s.pending; s.pending = []; @@ -176,10 +207,19 @@ export function captureReport(report: ErrorReport): void { deliver(withCrumbs); } +/** + * `CrashReportsAllowed` forced off, on any page of the desktop app. An instance's own + * settings decide the rest there: its admin chose what its pages report to it. + */ +function forbiddenByDevicePolicy(): boolean { + const entry = getDesktopBridge()?.policy?.get?.().CrashReportsAllowed; + return !!entry && entry.locked && entry.value === false; +} + function deliver(report: ErrorReport): void { const s = state(); const settings = s.settings; - if (!settings) return; + if (!settings || forbiddenByDevicePolicy()) return; const version = appVersion(); const print = fingerprint(report.name, report.frames, version, report.code); if (!limiter().allow(print)) return; @@ -194,7 +234,12 @@ function deliver(report: ErrorReport): void { platform: 'javascript', fingerprint: print, debugIds: debugIdsByPath(), - tags: { runtime: 'browser', ...(process.env.NEXT_PUBLIC_BULWARK_LITE === '1' ? { lite: 'yes' } : {}) }, + tags: { + runtime: 'browser', + ...(process.env.NEXT_PUBLIC_BULWARK_LITE === '1' ? { lite: 'yes' } : {}), + // An instance's build knows nothing of the app it's in, so the bridge says. + ...(getDesktopBridge() ? { platform: 'desktop' } : {}), + }, }); send(envelopeUrl(dsn), buildEnvelope(event), { // text/plain keeps it a simple request: no preflight, no custom header. diff --git a/lib/error-reporting/envelope.ts b/lib/error-reporting/envelope.ts index aaad77e1b..8cf11db17 100644 --- a/lib/error-reporting/envelope.ts +++ b/lib/error-reporting/envelope.ts @@ -1,3 +1,4 @@ +import { DESKTOP_VERSION_SUFFIX } from '@/lib/desktop/desktop-telemetry'; import type { Frame } from './stack'; /** @@ -144,6 +145,11 @@ export function releaseName(): string { return commit && commit !== 'unknown' ? `${version}+${commit}` : version; } +/** + * In the desktop build with a suffix, which also reaches the collector: its error batches + * have no room for a tag, and its version field allows this. + */ export function appVersion(): string { - return process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'; + const version = process.env.NEXT_PUBLIC_APP_VERSION || '0.0.0'; + return process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' ? `${version}${DESKTOP_VERSION_SUFFIX}` : version; } diff --git a/lib/error-reporting/scrub.ts b/lib/error-reporting/scrub.ts index 9c24601e4..2fecb9627 100644 --- a/lib/error-reporting/scrub.ts +++ b/lib/error-reporting/scrub.ts @@ -14,6 +14,8 @@ * Documented at https://bulwarkmail.org/docs/deployment/monitoring#what-is-removed */ +import { DESKTOP_FRAME_PREFIX } from '@/lib/desktop/desktop-telemetry'; + export const MAX_MESSAGE_LENGTH = 200; const REDACTED = '…'; @@ -130,12 +132,18 @@ export function scrubFunctionName(input: unknown): string | undefined { * The path of a stack frame's file, without origin, base path, query or * fragment: `/_next/static/chunks/x.js` for the browser bundle, * `/.next/server/...` for the server's, `node:...` for Node's own modules, - * `node_modules/...` for dependencies; `[other]` for anything else + * `node_modules/...` for dependencies, in the desktop build `/desktop/...` for its main + * process, which the shell rewrites its own files to, so a path on disk never gets + * here; `[other]` for anything else * (extensions, plugin code, evaluated code). */ export function scrubFramePath(input: unknown): string { if (typeof input !== 'string' || input === '') return '[other]'; const path = input.split(/[?#]/, 1)[0].replace(/\\/g, '/'); + // Spelled out, so a server build compiles this away and reads paths as it always did. + if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && path.startsWith(DESKTOP_FRAME_PREFIX)) { + return /^[\w./@+~-]+$/.test(path) && !path.includes('..') ? path : '[other]'; + } for (const marker of ['/_next/static/', '/.next/server/', '/.next/static/']) { const at = path.indexOf(marker); if (at >= 0) { diff --git a/lib/error-reporting/server.ts b/lib/error-reporting/server.ts index d94e728c3..4af8a3a11 100644 --- a/lib/error-reporting/server.ts +++ b/lib/error-reporting/server.ts @@ -95,6 +95,31 @@ export function captureRequestError(error: unknown, routePattern?: string): Prom } } +export interface DesktopMainErrorInput { + name?: unknown; + message?: unknown; + stack?: unknown; + mechanism?: unknown; +} + +/** + * An error in the desktop app's main process, handed over by the shell. Its frames are + * the shell's own files as `/desktop/...`. All of it is scrubbed here again. + */ +export function captureDesktopMainError(error: DesktopMainErrorInput): Promise { + try { + return handleServerReport({ + name: scrubName(error.name), + message: scrubText(error.message), + frames: parseStack(error.stack), + handled: false, + mechanism: error.mechanism === 'unhandledrejection' ? 'desktop-main-rejection' : 'desktop-main', + }); + } catch { + return Promise.resolve(); + } +} + /** * logger.error: a route that caught an error and logged it. The frames are * the logging call's, minus the logger's own `skip` frames. diff --git a/lib/error-reporting/settings.ts b/lib/error-reporting/settings.ts index 0e4097e32..a39196bb9 100644 --- a/lib/error-reporting/settings.ts +++ b/lib/error-reporting/settings.ts @@ -2,6 +2,7 @@ import { configManager } from '@/lib/admin/config-manager'; import { parseDsn } from './envelope'; import { metricsEnabled } from '@/lib/metrics'; import { effectiveErrorsConsent } from '@/lib/telemetry/state'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; import type { ClientSettings } from './client'; /** @@ -32,7 +33,8 @@ export function parseEnvironment(value: unknown): string { export function errorReportingSettings(): ErrorReportingSettings { const raw = configManager.get('errorReportingDsn', ''); return { - dsn: parseDsn(raw) ? raw.trim() : '', + // A desktop user has no admin, so a config file can't name somewhere to send errors. + dsn: !IS_DESKTOP_BUILD && parseDsn(raw) ? raw.trim() : '', sampleRate: parseSampleRate(configManager.get('errorReportingSampleRate', '1')), environment: parseEnvironment(configManager.get('errorReportingEnvironment', 'production')), }; @@ -45,6 +47,8 @@ export function errorReportingSettings(): ErrorReportingSettings { */ export async function shouldCollectErrors(): Promise { await configManager.ensureLoaded(); + // The desktop user's own yes, and nothing else. + if (IS_DESKTOP_BUILD) return (await effectiveErrorsConsent()).consent !== 'off'; if (errorReportingSettings().dsn || metricsEnabled()) return true; return (await effectiveErrorsConsent()).consent !== 'off'; } diff --git a/lib/error-reporting/stack.ts b/lib/error-reporting/stack.ts index 6a6b83f00..60765fc2b 100644 --- a/lib/error-reporting/stack.ts +++ b/lib/error-reporting/stack.ts @@ -33,7 +33,10 @@ export function parseStack(stack: unknown): Frame[] { for (const line of stack.split('\n')) { const match = V8.exec(line) ?? GECKO.exec(line); if (!match) continue; - const [, fn, file, lineNo, column] = match; + const [, fn, rawFile, lineNo, column] = match; + // "at async file:1:2": an anonymous async frame, as main's stacks have them. Spelled + // out, so a server build compiles this away. + const file = process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' ? rawFile.replace(/^async /, '') : rawFile; // "at fn (native)" and eval chains carry no file worth keeping. frames.push({ path: scrubFramePath(file), diff --git a/lib/event-links.ts b/lib/event-links.ts index 8c350c37d..9c4c1f867 100644 --- a/lib/event-links.ts +++ b/lib/event-links.ts @@ -115,6 +115,34 @@ export function findMeetingLink(event: EventPlaces): MeetingLink | null { return null; } +export interface JoinLink { + url: string; + /** Set for a link to no service this app knows: say where it goes before anyone clicks. */ + host: string | null; +} + +/** + * The address to offer as Join: web links only, with no user name in them, since what + * comes before an @ only looks like the host. An app scheme can start anything on the + * machine (ms-msdt:, search-ms:, smb:), and this comes from whoever sent the invitation. + */ +export function joinLinkFrom(uri: string): JoinLink | null { + let parsed: URL; + try { + parsed = new URL(uri.trim()); + } catch { + return null; + } + if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || parsed.username || parsed.password || !parsed.hostname) return null; + return { url: parsed.href, host: meetingProviderOf(parsed.href) ? null : parsed.hostname }; +} + +/** The event's Join, when it has one that's safe to offer. */ +export function findJoinLink(event: EventPlaces): JoinLink | null { + const link = findMeetingLink(event); + return link ? joinLinkFrom(link.uri) : null; +} + /** The first location's display name, if any. */ export function primaryLocationName(event: Pick): string | undefined { const name = Object.values(event.locations ?? {})[0]?.name?.trim(); diff --git a/lib/mail-export/__tests__/local-store-export.test.ts b/lib/mail-export/__tests__/local-store-export.test.ts new file mode 100644 index 000000000..23c55dc4c --- /dev/null +++ b/lib/mail-export/__tests__/local-store-export.test.ts @@ -0,0 +1,331 @@ +// @vitest-environment node +/** + * An export from the local store, on real SQLite: the store keeps raw messages as it + * does in a browser, and what comes out is read back from the file. + */ +import { describe, expect, it, vi } from 'vitest'; +import { runExport, type ExportFormat, type ExportProgress } from '../engine'; +import { localExportClient } from '../local-store-client'; +import { memorySink, type ExportSink } from '../sinks'; +import { MboxSplitter } from '@/lib/mail-import/mbox'; +import { withoutStateHeaders } from '@/lib/mail-import/mbox-status'; +import { listZipEntries, readZipEntry } from '@/lib/mail-import/zip-reader'; +import { DEFAULT_SCOPE, type StoreScope } from '@/lib/store/area'; +import { AccountEngine } from '@/lib/store/engine/account-engine'; +import { TransportError, type MethodCall } from '@/lib/store/engine/transport'; +import { mailArea } from '@/lib/store/mail'; +import { outboxArea } from '@/lib/store/outbox'; +import { FakeJmapDrafts } from '@/lib/store/__tests__/helpers/fake-drafts'; +import { FAKE_SESSION, JMAP_ACCOUNT } from '@/lib/store/__tests__/helpers/fake-jmap'; +import { openMemoryStore } from '@/lib/store/__tests__/helpers/node-adapter'; + +const SCOPE: StoreScope = { ...DEFAULT_SCOPE, days: null }; +const enc = new TextEncoder(); + +/** Bytes a rebuilt or re-encoded message wouldn't have: bare LF, CRLF, Latin-1, trailing space, an mbox trap. */ +function rawMessage(n: number): Uint8Array { + const head = enc.encode(`From: a${n}@example.org\r\nSubject: Secret subject ${n} \r\nDKIM-Signature: v=1; b=abc${n}\r\n\r\n`); + const body = enc.encode(`From the start of a line\n>From quoted once\r\nlast line ${n}\r\n`); + const out = new Uint8Array(head.length + 2 + body.length); + out.set(head); + out.set([0xe4, 0x0a], head.length); + out.set(body, head.length + 2); + return out; +} + +class Server extends FakeJmapDrafts { + readonly files = new Map(); + readonly fetched: string[] = []; + gone = new Set(); + + private read(blobId: string): Uint8Array { + this.fetched.push(blobId); + if (this.gone.has(blobId)) throw new TransportError('http', 'Gone', 404); + return this.files.get(blobId) ?? new Uint8Array(); + } + + override async download(_jmap?: string, blobId?: string): Promise { + return this.read(String(blobId)); + } + + async downloadStream(_jmap: string, blobId: string, _type: string, _name: string, sink: (part: Uint8Array) => void): Promise { + sink(this.read(blobId)); + } +} + +function serverWith(ids: Record): Server { + const server = new Server({ mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox' }, { id: 'archive', name: 'Archive' }] }); + let n = 0; + for (const [id, box] of Object.entries(ids)) { + n += 1; + const data = rawMessage(n); + server.files.set(`raw-${id}`, data); + server.addEmail({ id, blobId: `raw-${id}`, size: data.length, subject: `Secret subject ${n}`, mailboxIds: { [box]: true }, receivedAt: new Date(Date.UTC(2026, 9, n)).toISOString() }); + } + return server; +} + +async function storeOf(server: Server, scope: StoreScope = SCOPE, areas = [mailArea]) { + const db = openMemoryStore(); + const engine = new AccountEngine({ + account: 'acc', + db, + session: FAKE_SESSION, + transport: server, + areas, + scope, + now: () => Date.UTC(2026, 10, 3), + sleep: async () => {}, + }); + await engine.requestSync(); + return { + engine, + db, + store: { + answer: (_account: string, calls: MethodCall[]) => engine.answer(calls), + area: (_account: string, name: string, params?: unknown) => engine.areaCall(name, params) as Promise, + }, + }; +} + +function collecting(): { sink: ExportSink; bytes: () => Uint8Array } { + const inner = memorySink('x', 'application/octet-stream'); + const parts: Uint8Array[] = []; + return { + sink: { ...inner, write: async (chunk) => { parts.push(chunk.slice()); } }, + bytes: () => new Uint8Array(Buffer.concat(parts)), + }; +} + +type Store = Awaited>['store']; +type Online = Parameters[0]['online']; + +async function run(store: Store, format: ExportFormat, mailboxes: string[], online: Online = () => null): Promise<{ result: ExportProgress; bytes: Uint8Array }> { + const { sink, bytes } = collecting(); + const result = await runExport({ + client: localExportClient({ account: 'acc', jmap: JMAP_ACCOUNT, store, online }), + accountId: JMAP_ACCOUNT, + folders: mailboxes.map((mailboxId) => ({ mailboxId, path: [mailboxId] })), + format, + sink, + signal: new AbortController().signal, + requestsPerMinute: Infinity, + sleep: async () => {}, + onProgress: () => {}, + }); + return { result, bytes: bytes() }; +} + +async function zipEntries(bytes: Uint8Array): Promise> { + const file = new Blob([bytes as BlobPart]); + const out = new Map(); + for (const entry of await listZipEntries(file)) out.set(entry.name, await readZipEntry(file, entry)); + return out; +} + +/** The messages of an mbox file with the quoting and state undone, the way the import reads it. */ +function mboxMessages(bytes: Uint8Array): Uint8Array[] { + const splitter = new MboxSplitter(); + const out = [...splitter.push(bytes), ...splitter.end()]; + return out.map((message) => new Uint8Array(Buffer.concat(withoutStateHeaders(message.parts)))); +} + +describe('an export from the local store', () => { + it('writes a folder offline as a ZIP of .eml files with the exact bytes the server has', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox', other: 'archive' }); + const { store } = await storeOf(server); + const before = server.fetched.length; + const { result, bytes } = await run(store, 'eml', ['inbox']); + expect(result.phase).toBe('done'); + expect(result.exported).toBe(2); + expect(result.failed).toBe(0); + const entries = await zipEntries(bytes); + expect([...entries.keys()]).toEqual(['inbox/00001 Secret subject 1.eml', 'inbox/00002 Secret subject 2.eml']); + expect([...entries.values()][0]).toEqual(server.files.get('raw-a')); + expect([...entries.values()][1]).toEqual(server.files.get('raw-b')); + // Offline in truth: nothing was asked of the server. + expect(server.fetched.length).toBe(before); + }); + + it('writes a folder offline as an mbox whose messages read back as the exact bytes', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox', other: 'archive' }); + const { store } = await storeOf(server); + const { result, bytes } = await run(store, 'mbox', ['inbox']); + expect(result.phase).toBe('done'); + const messages = mboxMessages(bytes); + expect(messages).toHaveLength(2); + expect(messages[0]).toEqual(server.files.get('raw-a')); + expect(messages[1]).toEqual(server.files.get('raw-b')); + }); + + it('keeps the exact bytes in an .eml, and in an mbox puts the store\'s state in place of the state headers it had', async () => { + const headers = 'Status: O\r\nX-Status: F\r\nX-Keywords: oldword\r\nX-Mozilla-Status: 0009\r\nX-Mozilla-Status2: 00800000\r\nX-Mozilla-Keys: oldkey\r\n'; + const rest = 'From: a@example.org\r\nSubject: Stateful\r\n\r\nStatus: in the body stays\r\n'; + const data = enc.encode(`${headers}${rest}`); + const server = new Server({ mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox' }] }); + server.files.set('raw-s', data); + server.addEmail({ id: 's', blobId: 'raw-s', size: data.length, subject: 'Stateful', keywords: { $seen: true }, mailboxIds: { inbox: true } }); + const { store } = await storeOf(server); + const eml = await run(store, 'eml', ['inbox']); + expect([...(await zipEntries(eml.bytes)).values()]).toEqual([data]); + const mbox = await run(store, 'mbox', ['inbox']); + const text = Buffer.from(mbox.bytes).toString('latin1'); + expect(text).toContain('Status: RO\r\n'); + for (const gone of ['X-Status: F', 'oldword', 'oldkey', 'X-Mozilla-Status']) expect(text).not.toContain(gone); + expect(text).toContain('Status: in the body stays'); + expect(Buffer.from(mboxMessages(mbox.bytes)[0]).toString('latin1')).toBe(rest); + }); + + it('writes what\'s kept while a full listing runs, when an empty folder refuses to answer', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox' }); + const { store, db } = await storeOf(server); + // As during a first sync or after a lost state: the store can\'t tell empty from not listed yet. + db.run("UPDATE sync_state SET state = NULL WHERE type = 'Email'"); + expect(await store.answer('acc', [['Email/query', { accountId: JMAP_ACCOUNT, filter: { inMailbox: 'archive' } }, '0']])).toBeNull(); + const { result, bytes } = await run(store, 'eml', ['inbox', 'archive']); + expect(result.phase).toBe('done'); + expect(result.exported).toBe(2); + expect([...(await zipEntries(bytes)).keys()].filter((name) => name.endsWith('.eml'))).toHaveLength(2); + }); + + it('stops, rather than pass off a refusal as an empty folder, when the store says the folder has mail', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox' }); + const { store } = await storeOf(server); + const refusing = { ...store, answer: async () => null }; + const { sink } = collecting(); + const result = await runExport({ + client: localExportClient({ account: 'acc', jmap: JMAP_ACCOUNT, store: refusing, online: () => null }), + accountId: JMAP_ACCOUNT, + folders: [{ mailboxId: 'inbox', path: ['inbox'] }], + format: 'mbox', + sink, + signal: new AbortController().signal, + requestsPerMinute: Infinity, + sleep: async () => {}, + onProgress: () => {}, + }); + expect(result.phase).toBe('failed'); + expect(result.file).toBeUndefined(); + }); + + it('says how large an export is and how much of it is here', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox', c: 'archive' }); + server.gone.add('raw-b'); + const { store } = await storeOf(server); + const size = (id: string) => server.files.get(id)!.length; + expect(await store.area('acc', 'mail.rawSize', { jmap: JMAP_ACCOUNT, mailboxId: 'inbox' })).toEqual({ bytes: size('raw-a') + size('raw-b'), keptBytes: size('raw-a') }); + expect(await store.area('acc', 'mail.rawSize', { jmap: JMAP_ACCOUNT, mailboxId: 'archive' })).toEqual({ bytes: size('raw-c'), keptBytes: size('raw-c') }); + }); + + it('says nothing is here with attachments off', async () => { + const server = serverWith({ a: 'inbox' }); + const { store } = await storeOf(server, { ...SCOPE, attachments: false }); + expect(await store.area('acc', 'mail.rawSize', { jmap: JMAP_ACCOUNT, mailboxId: 'inbox' })).toEqual({ bytes: server.files.get('raw-a')!.length, keptBytes: 0 }); + }); + + it('counts what it has no original for by reason, and still writes the rest', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox', c: 'inbox' }); + server.gone.add('raw-b'); + server.gone.add('raw-c'); + const { store } = await storeOf(server); + expect(await store.area('acc', 'mail.rawStatus', { jmap: JMAP_ACCOUNT, mailboxId: 'inbox' })).toEqual({ total: 3, kept: 1 }); + const { result, bytes } = await run(store, 'eml', ['inbox']); + expect(result.phase).toBe('done'); + expect(result.exported).toBe(1); + expect(result.failed).toBe(2); + expect(result.notKept).toEqual({ not_kept: 2 }); + // Counted, not a list of 2 errors the dialog would print one by one. + expect(result.failures).toEqual([]); + expect([...(await zipEntries(bytes)).values()]).toEqual([server.files.get('raw-a')]); + }); + + it('says it is because attachments are off when they are, and makes no empty file of nothing', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox' }); + const { store } = await storeOf(server, { ...SCOPE, attachments: false }); + const { result, bytes } = await run(store, 'eml', ['inbox']); + expect(result.phase).toBe('done'); + expect(result.notKept).toEqual({ attachments_off: 2 }); + expect(result.exported).toBe(0); + expect(result.file).toBeUndefined(); + expect(bytes.length).toBe(0); + }); + + it('fetches what isn\'t kept when online, one at a time or in a batch, and reads the rest from the store', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox', c: 'inbox' }); + server.gone.add('raw-b'); + server.gone.add('raw-c'); + const { store } = await storeOf(server); + const fetchBlobForTransfer = vi.fn(async (blobId: string) => server.files.get(blobId)!); + const getBlobsAsBase64 = vi.fn(async (blobIds: string[]) => ({ + data: new Map([[blobIds[0], Buffer.from(server.files.get(blobIds[0])!).toString('base64')]]), + failed: [], + })); + const { result, bytes } = await run(store, 'eml', ['inbox'], () => ({ fetchBlobForTransfer, getBlobsAsBase64, getMaxConcurrentRequests: () => 4 })); + expect(result.exported).toBe(3); + expect(result.notKept).toBeUndefined(); + // The kept one isn't asked for, once it isn't missing from the batch. + expect(getBlobsAsBase64.mock.calls[0][0]).toEqual(['raw-b', 'raw-c']); + expect(fetchBlobForTransfer.mock.calls.map((call) => call[0])).toEqual(['raw-c']); + expect([...(await zipEntries(bytes)).values()]).toEqual([server.files.get('raw-a'), server.files.get('raw-b'), server.files.get('raw-c')]); + }); + + it('counts the rest as not kept when the connection goes during the run, instead of waiting on each', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox', c: 'inbox' }); + for (const id of ['raw-a', 'raw-b', 'raw-c']) server.gone.add(id); + const { store } = await storeOf(server); + const fetchBlobForTransfer = vi.fn(async () => { throw new TypeError('Load failed'); }); + const { result } = await run(store, 'mbox', ['inbox'], () => ({ fetchBlobForTransfer })); + expect(result.phase).toBe('done'); + expect(result.notKept).toEqual({ not_kept: 3 }); + // Three tries in all, not three per message. + expect(fetchBlobForTransfer).toHaveBeenCalledTimes(3); + }); + + it('reports a server\'s refusal of one message as that message\'s failure', async () => { + const server = serverWith({ a: 'inbox' }); + server.gone.add('raw-a'); + const { store } = await storeOf(server); + const { result } = await run(store, 'mbox', ['inbox'], () => ({ + fetchBlobForTransfer: async () => { throw new Error('Failed to fetch blob: 403'); }, + })); + expect(result.notKept).toBeUndefined(); + expect(result.failures).toEqual([{ label: 'Secret subject 1', reason: 'Failed to fetch blob: 403' }]); + }); + + it('counts a draft written on this device, which has no original yet, and never asks the server for it', async () => { + const server = new Server({ mailboxes: [{ id: 'drafts', name: 'Drafts', role: 'drafts' }] }); + const { engine, store } = await storeOf(server, SCOPE, [mailArea, outboxArea]); + await engine.answer([['Email/set', { create: { c: { subject: 'Draft', keywords: { $draft: true }, mailboxIds: { drafts: true }, receivedAt: new Date().toISOString(), bodyValues: { 1: { value: 'x' } }, textBody: [{ partId: '1' }] } } }, 's']]); + const fetchBlobForTransfer = vi.fn(async () => new Uint8Array()); + const { result } = await run(store, 'mbox', ['drafts'], () => ({ fetchBlobForTransfer })); + expect(result.total).toBe(1); + expect(result.notKept).toEqual({ not_kept: 1 }); + expect(fetchBlobForTransfer).not.toHaveBeenCalled(); + }); + + it('reads the folder list and every message past one page', async () => { + const ids: Record = {}; + for (let i = 0; i < 503; i += 1) ids[`m${i}`] = 'inbox'; + const server = serverWith(ids); + for (let i = 0; i < 503; i += 1) server.gone.add(`raw-m${i}`); + const { store } = await storeOf(server, { ...SCOPE, attachments: false }); + const client = localExportClient({ account: 'acc', jmap: JMAP_ACCOUNT, store, online: () => null }); + expect((await client.getMailboxes()).map((box) => box.id).sort()).toEqual(['archive', 'inbox']); + const list = await client.queryEmailFields({ inMailbox: 'inbox' }, ['blobId', 'subject'], JMAP_ACCOUNT, Number.MAX_SAFE_INTEGER); + expect(list).toHaveLength(503); + expect(new Set(list.map((email) => email.id)).size).toBe(503); + }); + + it('keeps to the server\'s pace only for what goes to the network', async () => { + const server = serverWith({ a: 'inbox', b: 'inbox' }); + server.gone.add('raw-b'); + const { store } = await storeOf(server); + const take = vi.fn(async () => {}); + const fetchBlobForTransfer = vi.fn(async (blobId: string) => server.files.get(blobId)!); + const client = localExportClient({ account: 'acc', jmap: JMAP_ACCOUNT, store, online: () => ({ fetchBlobForTransfer }), gate: { take } }); + await client.fetchBlobForTransfer!('raw-a', JMAP_ACCOUNT); + expect(take).not.toHaveBeenCalled(); + await client.fetchBlobForTransfer!('raw-b', JMAP_ACCOUNT); + expect(take).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/mail-export/__tests__/sinks.test.ts b/lib/mail-export/__tests__/sinks.test.ts index 915cb4932..b882b5033 100644 --- a/lib/mail-export/__tests__/sinks.test.ts +++ b/lib/mail-export/__tests__/sinks.test.ts @@ -1,15 +1,20 @@ +// @vitest-environment node import { describe, it, expect, vi, afterEach } from 'vitest'; -import { canWritePrivateFile, downloadGraceMs, privateFileSink, sweepPrivateExports } from '@/lib/mail-export/sinks'; +import { blobHeldInMemory, canWritePrivateFile, downloadGraceMs, dropExportFiles, guardedSink, memorySink, openExportSink, pickSaveFile, privateFileSink, spoolLimit, sweepPrivateExports, ExportForbiddenError, ExportTooLargeError, SPOOL_MEMORY_LIMIT, type ExportFile, type ExportSink } from '@/lib/mail-export/sinks'; +import { ZipWriter } from '@/lib/mail-export/zip-writer'; +import { useMailTransferStore } from '@/stores/mail-transfer-store'; /** A tiny origin private file system: one directory of files. */ function fakeOpfs() { const files = new Map(); + /** Everything ever written to this file system, deleted or not. */ + const written: Uint8Array[] = []; const dir = { getFileHandle: async (name: string) => { if (!files.has(name)) files.set(name, []); return { createWritable: async () => ({ - write: async (data: Uint8Array) => { files.get(name)!.push(data); }, + write: async (data: Uint8Array) => { files.get(name)!.push(data); written.push(data); }, close: async () => {}, abort: async () => {}, }), @@ -29,7 +34,7 @@ function fakeOpfs() { removeEntry: vi.fn(async () => { files.clear(); }), }; let created = false; - return { files, root }; + return { files, written, root }; } /** Web Locks where `held` names belong to another tab. */ @@ -66,30 +71,332 @@ describe('sweepPrivateExports', () => { }); describe('privateFileSink', () => { - it('keeps the file while its download may still read it, and deletes it after', async () => { - vi.useFakeTimers(); + const MAIL = new TextEncoder().encode('Subject: Very secret mail\r\n\r\nThe plain body\r\n'); + + /** A sink on a fake file system; `handed` is what would be downloaded. */ + function setUp() { const opfs = fakeOpfs(); + const handed: Blob[] = []; + const revoke = vi.fn(); vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set()) }); - vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: () => 'blob:x', revokeObjectURL: vi.fn() })); + vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: (blob: Blob) => { handed.push(blob); return 'blob:x'; }, revokeObjectURL: revoke })); + return { opfs, handed, revoke }; + } + + const asText = (parts: Uint8Array[]) => Buffer.concat(parts).toString('latin1'); + + it('never writes readable mail to the private files, and hands out the exact bytes', async () => { + const { opfs, handed } = setUp(); const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; - await sink.write(new Uint8Array(10)); + // More than one piece, so the pieces have to come back in order. + const big = new Uint8Array(1024 * 1024 + 7).map((_, i) => i % 251); + await sink.write(MAIL); + await sink.write(big); + await sink.write(big); const file = await sink.close(); - const gone = file.discard(); - await vi.advanceTimersByTimeAsync(30_000); + expect(opfs.written.length).toBeGreaterThanOrEqual(2); + expect(asText(opfs.written)).not.toContain('secret'); + expect(asText(opfs.written)).not.toContain('plain body'); + expect(handed).toHaveLength(1); + expect(handed[0].type).toBe('application/mbox'); + // Not toEqual: it walks 2 MB element by element. + expect(Buffer.from(await handed[0].arrayBuffer()).equals(Buffer.concat([MAIL, big, big]))).toBe(true); + expect(file.size).toBe(MAIL.length + 2 * big.length); + }); + + it('seals the same mail differently each time', async () => { + const { opfs } = setUp(); + for (let i = 0; i < 2; i += 1) { + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); + await sink.close(); + } + expect(opfs.written).toHaveLength(2); + expect(asText([opfs.written[0]])).not.toBe(asText([opfs.written[1]])); + }); + + it('deletes the spool the moment the export is done, before any download is over', async () => { + const { opfs } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); expect(opfs.files.size).toBe(1); - await vi.advanceTimersByTimeAsync(downloadGraceMs(10)); - await gone; + await sink.close(); expect(opfs.files.size).toBe(0); }); - it('deletes at once on sign-out', async () => { + it('deletes the spool when the export is cancelled', async () => { + const { opfs } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); + await sink.abort(); + expect(opfs.files.size).toBe(0); + }); + + it('keeps the Blob while its download may still read it, and frees it after', async () => { + const { revoke } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); + const file: ExportFile = await sink.close(); + vi.useFakeTimers(); + const gone = file.discard(); + await vi.advanceTimersByTimeAsync(30_000); + expect(revoke).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(downloadGraceMs(MAIL.length)); + await gone; + expect(revoke).toHaveBeenCalledWith('blob:x'); + }); + + it('frees the Blob at once on sign-out', async () => { + const { revoke } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); + await (await sink.close()).discard(true); + expect(revoke).toHaveBeenCalledWith('blob:x'); + }); + + it('leaves nothing behind when the Blob can\'t be made', async () => { + const { opfs, handed } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); + vi.stubGlobal('Response', class { blob() { return Promise.reject(new Error('out of memory')); } }); + await expect(sink.close()).rejects.toThrow('out of memory'); + await sink.abort(); + expect(handed).toEqual([]); + expect(opfs.files.size).toBe(0); + }); + + it('never lets a ZIP through readable either, where a search for the text would find nothing', async () => { + const { opfs, handed } = setUp(); + const sink = (await privateFileSink('a.zip', 'application/zip'))!; + const zip = new ZipWriter((chunk) => sink.write(chunk)); + await zip.addFile('Inbox/1.eml', MAIL, new Date()); + await zip.finish(); + await sink.close(); + const asZip = (await import('@/lib/mail-import/zip-reader')).listZipEntries; + expect(asText(opfs.written)).not.toContain('PK\x03\x04'); + expect(asText(opfs.written)).not.toContain('Inbox/1.eml'); + await expect(asZip(new Blob(opfs.written as BlobPart[]))).rejects.toThrow(); + expect((await asZip(handed[0])).map((entry) => entry.name)).toEqual(['Inbox/1.eml']); + }); + + it('downloads nothing when stopped while the spool is being opened, and leaves no Blob', async () => { + const { opfs, handed } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + // Three pieces: a large spool must stop where it is, not be opened to the end first. + for (let i = 0; i < 3; i += 1) await sink.write(new Uint8Array(1 << 20)); + expect(opfs.written.length).toBe(3); + const controller = new AbortController(); + const decrypt = crypto.subtle.decrypt.bind(crypto.subtle); + const spy = vi.spyOn(crypto.subtle, 'decrypt').mockImplementation(async (...args) => { + controller.abort(); + return decrypt(...args); + }); + await expect(sink.close(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }); + // A stream reads one piece ahead at most before it hears the abort. + expect(spy.mock.calls.length).toBeLessThan(3); + spy.mockRestore(); + expect(handed).toEqual([]); + await sink.abort(); + expect(opfs.files.size).toBe(0); + }); + + it('downloads nothing when stopped just as the Blob is ready', async () => { + const { handed } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL); + const controller = new AbortController(); + vi.stubGlobal('Response', class { blob() { controller.abort(); return Promise.resolve(new Blob(['x'])); } }); + await expect(sink.close(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }); + expect(handed).toEqual([]); + }); + + it('stops at its limit while writing, and the spool goes', async () => { + const { opfs } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox', 10))!; + await sink.write(new Uint8Array(10)); + await expect(sink.write(new Uint8Array(1))).rejects.toBeInstanceOf(ExportTooLargeError); + await sink.abort(); + expect(opfs.files.size).toBe(0); + }); + + it('a crash leaves only sealed bytes, which the sweep at the next start deletes', async () => { + const { opfs } = setUp(); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + await sink.write(MAIL.slice()); + // 1 MB forces a piece out; a crash here never reaches close or abort. + await sink.write(new Uint8Array(1 << 20)); + expect(asText(opfs.written)).not.toContain('secret'); + expect(opfs.files.size).toBe(1); + // The tab that held the file is gone: its lock is free again. + await sweepPrivateExports(); + expect(opfs.files.size).toBe(0); + }); +}); + +describe('where the final Blob sits in memory', () => { + const AGENTS: [string, string, boolean][] = [ + ['Safari', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0 Safari/605.1.15', true], + ['Chrome on iOS, which is WebKit', 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/130.0 Mobile/15E148 Safari/604.1', true], + ['Firefox on iOS, which is WebKit', 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/130.0 Mobile/15E148 Safari/605.1.15', true], + ['Chrome', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36', false], + ['Edge', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0', false], + ['Firefox', 'Mozilla/5.0 (Windows NT 10.0; rv:148.0) Gecko/20100101 Firefox/148.0', false], + ['the desktop app', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Electron/40.0.0 Safari/537.36', false], + ]; + + it.each(AGENTS)('%s', (_name, userAgent, inMemory) => { + vi.stubGlobal('navigator', { userAgent }); + expect(blobHeldInMemory()).toBe(inMemory); + expect(spoolLimit()).toBe(inMemory ? SPOOL_MEMORY_LIMIT : Infinity); + }); + + it('a spool on such an engine takes the cap by default', async () => { + const opfs = fakeOpfs(); + vi.stubGlobal('navigator', { userAgent: AGENTS[0][1], storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set()) }); + const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; + // Nothing this size is allocated: the check is on the length it is told. + const huge = { length: SPOOL_MEMORY_LIMIT + 1 } as Uint8Array; + await expect(sink.write(huge)).rejects.toMatchObject({ name: 'ExportTooLargeError', limit: SPOOL_MEMORY_LIMIT }); + }); +}); + +describe('a finished export held for its download', () => { + function stubUrls() { + // What earlier tests left held. + dropExportFiles('all'); + const revoke = vi.fn(); + let n = 0; + vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: () => `blob:${++n}`, revokeObjectURL: revoke })); + return revoke; + } + + async function finished(scope: string) { + useMailTransferStore.setState({ job: { kind: 'export', scope, controller: new AbortController(), progress: { phase: 'running', exported: 0, failed: 0, failures: [], total: 0, fraction: 0 } } }); + const sink = memorySink('a.mbox', 'application/mbox'); + await sink.write(new Uint8Array(3)); + const file = await sink.close(); + useMailTransferStore.setState({ job: null }); + return file; + } + + it('goes at a sign-out of its account while it waits out the grace period, with the dialog closed', async () => { + const revoke = stubUrls(); + vi.useFakeTimers(); + const file = await finished('acc'); + void file.discard(); + expect(revoke).not.toHaveBeenCalled(); + dropExportFiles('someone-else'); + expect(revoke).not.toHaveBeenCalled(); + dropExportFiles('acc'); + expect(revoke).toHaveBeenCalledTimes(1); + // Nothing to do when the wait ends. + await vi.advanceTimersByTimeAsync(downloadGraceMs(3)); + expect(revoke).toHaveBeenCalledTimes(1); + }); + + it('goes when everything goes', async () => { + const revoke = stubUrls(); + await finished('acc'); + const opfs = fakeOpfs(); + vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root } }); + await sweepPrivateExports({ force: true }); + expect(revoke).toHaveBeenCalledTimes(1); + }); + + it('can\'t be downloaded once it has gone', async () => { + stubUrls(); + const anchors: string[] = []; + vi.stubGlobal('document', { createElement: () => ({ click() { anchors.push('clicked'); }, remove() {} }), body: { appendChild() {} } }); + const file = await finished('acc'); + expect(anchors).toEqual(['clicked']); + dropExportFiles('acc'); + file.download?.(); + expect(anchors).toEqual(['clicked']); + }); +}); + +describe('the file the user picked', () => { + function picked(handle: Record) { + const writable = { write: vi.fn(async () => {}), close: vi.fn(async () => {}), abort: vi.fn(async () => {}) }; + vi.stubGlobal('window', { showSaveFilePicker: async () => ({ createWritable: async () => writable, ...handle }) }); + return writable; + } + + it('is removed on an abort where the browser can', async () => { + const remove = vi.fn(async () => {}); + const writable = picked({ remove }); + const sink = await pickSaveFile('a.mbox', 'application/mbox', '.mbox'); + expect(await sink.abort()).toEqual({}); + expect(writable.abort).toHaveBeenCalled(); + expect(remove).toHaveBeenCalled(); + }); + + it('says so where it can\'t be removed', async () => { + picked({}); + expect(await (await pickSaveFile('a.mbox', 'application/mbox', '.mbox')).abort()).toEqual({ leftover: 'a.mbox' }); + picked({ remove: async () => { throw new Error('no'); } }); + expect(await (await pickSaveFile('a.mbox', 'application/mbox', '.mbox')).abort()).toEqual({ leftover: 'a.mbox' }); + }); + + it('names the file as the person named it', async () => { + picked({ name: 'my backup.mbox' }); + expect(await (await pickSaveFile('a.mbox', 'application/mbox', '.mbox')).abort()).toEqual({ leftover: 'my backup.mbox' }); + }); + + it('isn\'t finished when stopped first', async () => { + const writable = picked({}); + const sink = await pickSaveFile('a.mbox', 'application/mbox', '.mbox'); + const controller = new AbortController(); + controller.abort(); + await expect(sink.close(controller.signal)).rejects.toMatchObject({ name: 'AbortError' }); + expect(writable.close).not.toHaveBeenCalled(); + }); +}); + +describe('guardedSink', () => { + function fakeSink(): ExportSink & { write: ReturnType; close: ReturnType } { + return { + kind: 'picker', + write: vi.fn(async () => {}), + close: vi.fn(async () => ({ name: 'x', size: 0, discard: async () => {} })), + abort: vi.fn(async () => {}), + }; + } + + it('passes writes through while allowed', async () => { + const inner = fakeSink(); + const sink = guardedSink(inner, () => true); + await sink.write(new Uint8Array(1)); + await sink.close(); + expect(inner.write).toHaveBeenCalledTimes(1); + expect(inner.close).toHaveBeenCalledTimes(1); + }); + + it('stops a write and a close the moment the policy says no', async () => { + const inner = fakeSink(); + const sink = guardedSink(inner, () => false); + await expect(sink.write(new Uint8Array(1))).rejects.toBeInstanceOf(ExportForbiddenError); + await expect(sink.close()).rejects.toBeInstanceOf(ExportForbiddenError); + expect(inner.write).not.toHaveBeenCalled(); + expect(inner.close).not.toHaveBeenCalled(); + }); +}); + +describe('openExportSink', () => { + it('uses the picker where there is one, and nothing else when the user closes it', async () => { const opfs = fakeOpfs(); vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set()) }); - vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: () => 'blob:x', revokeObjectURL: vi.fn() })); - const sink = (await privateFileSink('a.mbox', 'application/mbox'))!; - await sink.write(new Uint8Array(10)); - await (await sink.close()).discard(true); + vi.stubGlobal('window', { showSaveFilePicker: async () => { throw new DOMException('closed', 'AbortError'); } }); + expect(await openExportSink('a.mbox', 'application/mbox', '.mbox')).toBeNull(); expect(opfs.files.size).toBe(0); + vi.stubGlobal('window', { showSaveFilePicker: async () => ({ createWritable: async () => ({ write: async () => {}, close: async () => {}, abort: async () => {} }) }) }); + expect((await openExportSink('a.mbox', 'application/mbox', '.mbox'))?.kind).toBe('picker'); + }); + + it('goes to the spool where there is no picker', async () => { + const opfs = fakeOpfs(); + vi.stubGlobal('navigator', { storage: { getDirectory: async () => opfs.root }, locks: fakeLocks(new Set()) }); + expect((await openExportSink('a.mbox', 'application/mbox', '.mbox'))?.kind).toBe('private'); }); }); diff --git a/lib/mail-export/engine.ts b/lib/mail-export/engine.ts index a49f5a724..c70386353 100644 --- a/lib/mail-export/engine.ts +++ b/lib/mail-export/engine.ts @@ -30,20 +30,44 @@ export interface ExportFolder { path: string[]; } +export type NotKeptReason = "attachments_off" | "not_kept"; + +/** + * A local-store export has no original of this message and can't reach the + * server. Counted by reason in `notKept`, not listed one by one. + */ +export class MessageNotKeptError extends Error { + constructor(readonly reason: NotKeptReason) { + super("The original of this message is not on this device"); + this.name = "MessageNotKeptError"; + } +} + export interface ExportProgress { phase: ExportPhase; exported: number; failed: number; failures: { label: string; reason: string }[]; + /** The part of `failed` a local-store export had no original for, by reason. */ + notKept?: Partial>; total: number; fraction: number; pausedUntil?: number; file?: ExportFile; error?: string; + /** What stopped it, as the error's name: a dialog says this in the user's language. */ + errorCode?: string; + /** The limit an ExportTooLargeError ran into, in bytes. */ + errorLimit?: number; + /** A file in the place the user picked that couldn't be removed after a run that wrote nothing whole. */ + leftover?: string; } +/** What an export reads through: the real client, or lib/mail-export/local-store-client.ts. */ +export type ExportClient = Pick; + export interface ExportOptions { - client: IJMAPClient; + client: ExportClient; accountId: string; folders: ExportFolder[]; format: ExportFormat; @@ -53,7 +77,12 @@ export interface ExportOptions { now?: () => number; sleep?: (ms: number, signal: AbortSignal) => Promise; reportEveryMs?: number; - /** Requests per minute the export allows itself (the server's limit is 1000 for everything). */ + /** No file when nothing was written, also when nothing failed: the person chose mail, not an empty file. */ + noEmptyFile?: boolean; + /** + * Requests per minute the export allows itself (the server's limit is 1000 for + * everything). Infinity for a client that keeps to the limit itself. + */ requestsPerMinute?: number; } @@ -74,6 +103,10 @@ const BATCH_COUNT = 100; const MAX_FAILURES_LISTED = 20; const RETRY_DELAYS_MS = [500, 2000, 5000]; +function leftoverOf(aborted: { leftover?: string } | void | undefined): string | undefined { + return aborted ? aborted.leftover : undefined; +} + function isAbort(error: unknown): boolean { return (error as { name?: string } | null)?.name === "AbortError"; } @@ -141,10 +174,11 @@ export function folderTree(mailboxes: Mailbox[]): ExportFolder[] { } /** Keeps the export under a number of requests per minute. */ -class RateGate { +export class RateGate { private times: number[] = []; constructor(private readonly perMinute: number, private readonly now: () => number, private readonly sleep: (ms: number) => Promise) {} async take(): Promise { + if (this.perMinute === Infinity) return; for (;;) { const t = this.now(); this.times = this.times.filter((x) => t - x < 60_000); @@ -167,7 +201,7 @@ export async function runExport(options: ExportOptions): Promise if (!force && t - lastReport < (options.reportEveryMs ?? 150)) return; lastReport = t; progress.fraction = progress.total > 0 ? Math.min(1, (progress.exported + progress.failed) / progress.total) : 0; - options.onProgress({ ...progress, failures: [...progress.failures] }); + options.onProgress({ ...progress, failures: [...progress.failures], ...(progress.notKept ? { notKept: { ...progress.notKept } } : {}) }); }; const fail = (label: string, reason: string) => { progress.failed++; @@ -319,7 +353,14 @@ export async function runExport(options: ExportOptions): Promise const raw = results[i]; seq++; if (raw instanceof Error) { - fail(ref.subject, raw.message); + if (raw.name === "MessageNotKeptError") { + const { reason } = raw as MessageNotKeptError; + progress.failed++; + progress.notKept = { ...progress.notKept, [reason]: (progress.notKept?.[reason] ?? 0) + 1 }; + } else { + fail(ref.subject, raw.message); + } + report(); continue; } if (options.format === "mbox") { @@ -335,18 +376,33 @@ export async function runExport(options: ExportOptions): Promise } if (stream) await stream.close(); } - if (zip) await zip.finish(); - progress.file = await sink.close(); + // A cancel with nothing left to fetch must not close and hand out the file. + if (signal.aborted) throw cancelled(); + if (progress.exported === 0 && (progress.failed > 0 || options.noEmptyFile)) { + // Every message failed: an empty file would only look like a backup. + progress.leftover = leftoverOf(await sink.abort()); + } else { + if (zip) await zip.finish(); + const file = await sink.close(signal); + // Stopped while the file was being finished: it goes, and nobody gets to download it. + if (signal.aborted) { + await file.discard(true).catch(() => {}); + throw cancelled(); + } + progress.file = file; + } progress.phase = "done"; progress.fraction = 1; } catch (error) { - await sink.abort().catch(() => {}); + progress.leftover = leftoverOf(await sink.abort().catch(() => undefined)); progress.pausedUntil = undefined; if (isAbort(error) || signal.aborted) { progress.phase = "cancelled"; } else { progress.phase = "failed"; progress.error = error instanceof Error ? error.message : String(error); + progress.errorCode = (error as { name?: string } | null)?.name; + progress.errorLimit = (error as { limit?: number } | null)?.limit; } } report(true); diff --git a/lib/mail-export/local-store-client.ts b/lib/mail-export/local-store-client.ts new file mode 100644 index 000000000..edd4943c6 --- /dev/null +++ b/lib/mail-export/local-store-client.ts @@ -0,0 +1,149 @@ +/** + * What an export reads from when it runs from the local store: the methods + * lib/mail-export/engine.ts uses, answered from the store. A message comes as the + * raw original the store kept, never rebuilt, since a rebuilt one loses signatures + * and exact bytes. For one that isn't kept, the real client fetches it as usual + * when the server can be reached. Otherwise it's a MessageNotKeptError, which the + * engine counts. + * + * Store reads have no rate limit, so the engine runs with no gate of its own and + * this keeps to the server's limit for what goes to the network. + */ + +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { isStandInId, newStandInId } from '@/lib/jmap/stand-in-ids'; +import type { Mailbox } from '@/lib/jmap/types'; +import type { StoreBackend } from '@/lib/store/client/api'; +import type { MethodCall } from '@/lib/store/engine/transport'; +import { MessageNotKeptError, RateGate, type ExportClient, type NotKeptReason } from './engine'; + +const PAGE = 500; +const REQUESTS_PER_MINUTE = 600; +/** Failed downloads in a row before the rest of the run stops trying the network. */ +const NETWORK_TRIES = 3; + +type RawAnswer = { ok: true; blob: Blob } | { ok: false; reason: NotKeptReason }; + +export interface LocalExportClientOptions { + account: string; + /** The JMAP account in the store (not the local account id). */ + jmap: string; + store: Pick; + /** The real client if the server may be reached right now, else null. Asked for each message. */ + online: () => Partial> | null; + /** Keeps what goes to the network to the server's pace. A test can pass its own. */ + gate?: Pick; +} + +export type LocalExportClient = ExportClient & { getMailboxes(): Promise }; + +function isAbort(error: unknown): boolean { + return (error as { name?: string } | null)?.name === 'AbortError'; +} + +/** A fetch that never got an answer: TypeError in every browser, whatever it says, or the client's own time-out. */ +function isNetworkFailure(error: unknown): boolean { + if (isAbort(error)) return false; + const { name, message } = (error ?? {}) as { name?: string; message?: string }; + return name === 'TypeError' || /network|timed out/i.test(message ?? ''); +} + +function networkGate(): RateGate { + return new RateGate(REQUESTS_PER_MINUTE, Date.now, (ms) => new Promise((resolve) => setTimeout(resolve, ms))); +} + +export function localExportClient({ account, jmap, store, online, gate = networkGate() }: LocalExportClientOptions): LocalExportClient { + let networkMisses = 0; + const reachable = () => (networkMisses >= NETWORK_TRIES ? null : online()); + + const call = async (name: string, args: Record): Promise => { + const calls: MethodCall[] = [[name, { accountId: jmap, ...args }, '0']]; + const answer = await store.answer(account, calls); + const [method, result] = answer?.methodResponses?.[0] ?? []; + if (!answer || method === 'error') { + const refusal = result as { description?: string; type?: string } | undefined; + // A request whose only call is refused comes back as no answer at all. + throw Object.assign(new Error(String(refusal?.description ?? refusal?.type ?? `${name} got no answer`)), { refusal: refusal?.type ?? 'serverUnavailable' }); + } + return result as T; + }; + + /** + * A folder with nothing in it refuses while a full listing runs: the store can't + * tell empty from not listed yet. To an export that's an empty folder, so what's + * kept in the other folders still goes out. Only when the store's own count says + * it holds nothing: any other refusal must not pass for an empty folder, or the + * person gets a file that's missing mail and no word about it. + */ + const queryIds = async (args: Record): Promise => { + try { + return (await call<{ ids: string[] }>('Email/query', args)).ids; + } catch (error) { + if ((error as { refusal?: string }).refusal !== 'serverUnavailable') throw error; + const mailboxId = (args.filter as { inMailbox?: string } | undefined)?.inMailbox; + const { total } = await store.area<{ total: number }>(account, 'mail.rawStatus', { jmap, mailboxId }); + if (total === 0) return []; + throw error; + } + }; + + const fetchBlobForTransfer = async (blobId: string, _accountId: string | undefined, signal?: AbortSignal): Promise => { + // A draft written on this device: the server has never heard of it, and it has no original yet. + if (isStandInId(blobId)) throw new MessageNotKeptError('not_kept'); + const kept = await store.area(account, 'mail.rawBlob', { jmap, blobId }); + if (kept.ok) return new Uint8Array(await kept.blob.arrayBuffer()); + const real = reachable(); + if (!real?.fetchBlobForTransfer) throw new MessageNotKeptError(kept.reason); + await gate.take(); + try { + const bytes = await real.fetchBlobForTransfer(blobId, jmap, signal); + networkMisses = 0; + return bytes; + } catch (error) { + if (!isNetworkFailure(error)) throw error; + networkMisses += 1; + // The engine tries this again; by the last try this message counts as not kept. + throw new Error('network failure'); + } + }; + + return { + getMailboxes: async () => (await call<{ list: Mailbox[] }>('Mailbox/get', { ids: null })).list, + + queryEmailFields: async (filter, properties, _accountId, limit = Number.MAX_SAFE_INTEGER) => { + const emails: Record[] = []; + for (let position = 0; emails.length < limit;) { + const ids = await queryIds({ + filter, + sort: [{ property: 'receivedAt', isAscending: false }], + position, + limit: Math.min(PAGE, limit - emails.length), + }); + if (ids.length === 0) break; + const { list } = await call<{ list: Record[] }>('Email/get', { ids, properties }); + // A message with no blobId still counts: the engine leaves such a row out silently. + emails.push(...list.map((email) => (typeof email.blobId === 'string' ? email : { ...email, blobId: newStandInId() }))); + position += ids.length; + if (ids.length < PAGE) break; + } + return emails as never; + }, + + getBlobsAsBase64: async (blobIds) => { + const real = reachable(); + const wanted = blobIds.filter((blobId) => !isStandInId(blobId)); + if (!real?.getBlobsAsBase64 || wanted.length === 0) return null; + const kept = new Set(await store.area(account, 'mail.rawKept', { jmap, blobIds: wanted })); + const missing = wanted.filter((blobId) => !kept.has(blobId)); + if (missing.length === 0) return null; + await gate.take(); + return real.getBlobsAsBase64(missing, jmap, { sized: true }); + }, + + fetchBlobForTransfer, + + fetchBlob: async (blobId) => new Blob([await fetchBlobForTransfer(blobId, jmap) as BlobPart]), + + getMaxConcurrentRequests: () => online()?.getMaxConcurrentRequests?.() ?? 4, + }; +} diff --git a/lib/mail-export/sinks.ts b/lib/mail-export/sinks.ts index 30908697d..ec0c82446 100644 --- a/lib/mail-export/sinks.ts +++ b/lib/mail-export/sinks.ts @@ -7,12 +7,19 @@ import { useMailTransferStore } from "@/stores/mail-transfer-store"; * * 1. File System Access (Chrome, Edge): the user picks the file first and the * export writes straight into it. - * 2. The origin private file system (Firefox, Safari): written to a private - * file on disk, then downloaded. The file is deleted a while after the - * dialog closes (so the download can finish), at once on sign-out, and by - * a sweep when the app starts, someone signs in or the next export starts. - * Each file is held under a Web Lock while its tab uses it, so the sweep - * of another tab leaves it alone. + * 2. The origin private file system (Firefox, Safari): an encrypted spool, so + * no readable mail is ever in those files. Each 1 MB piece is sealed with + * AES-GCM under a key made for this export, which can't be exported and is + * only ever in this tab's memory: a crash leaves bytes nobody can read. + * When the export is done the pieces are opened into a Blob the browser + * holds (Firefox and Chromium keep a large one in their own temporary + * storage; WebKit holds it in memory, so there the export has a cap), the + * spool is deleted at once, and the Blob is downloaded. The + * Blob goes a while after the dialog closes, so the download can finish, or + * at once on sign-out or a wipe. A sweep deletes what a crash left when the + * app starts, someone signs in or the next export starts. Each spool is + * held under a Web Lock while its tab uses it, so the sweep of another tab + * leaves it alone. * 3. Memory, for small exports only, where neither exists. * * Writes are gathered into 1 MB pieces: a message arrives in many small parts. @@ -30,8 +37,10 @@ export interface ExportFile { export interface ExportSink { kind: "picker" | "private" | "memory"; write(chunk: Uint8Array): Promise; - close(): Promise; - abort(): Promise; + /** Finishes the file. If `signal` fires, nothing is handed out and it rejects. */ + close(signal?: AbortSignal): Promise; + /** Throws away what was written. `leftover`: a file it couldn't remove from where the user put it. */ + abort(): Promise<{ leftover?: string } | void>; } export class ExportTooLargeError extends Error { @@ -41,11 +50,21 @@ export class ExportTooLargeError extends Error { } } +/** The device policy "No export" came on while the export ran. */ +export class ExportForbiddenError extends Error { + constructor() { + super("The organisation doesn't allow exporting mail on this device"); + this.name = "ExportForbiddenError"; + } +} + /** The most a memory-only export may hold. */ export const MEMORY_EXPORT_LIMIT = 200 * 1024 * 1024; +/** The most a spool may hold where its final Blob sits in memory: see spoolLimit. */ +export const SPOOL_MEMORY_LIMIT = 1024 * 1024 * 1024; const PRIVATE_DIR = "bulwark-exports"; const LOCK_PREFIX = "bulwark-export-file:"; -/** How long a closed export's private file stays for its download: a minute, plus a second per 50 MB. */ +/** How long a closed export's Blob stays for its download: a minute, plus a second per 50 MB. */ export function downloadGraceMs(size: number): number { return 60_000 + Math.ceil(size / 50_000_000) * 1000; } @@ -95,7 +114,79 @@ function triggerDownload(url: string, name: string): void { saveBlobAs(url, name, { kind: "export", subject: scope && scope !== "default" ? scope : null }); } -type SavePicker = (options: { suggestedName?: string; types?: { description?: string; accept: Record }[] }) => Promise<{ createWritable(): Promise }>; +type SavePicker = (options: { suggestedName?: string; types?: { description?: string; accept: Record }[] }) => Promise<{ name?: string; createWritable(): Promise; remove?(): Promise }>; + +function stopped(): DOMException { + return new DOMException("Export cancelled", "AbortError"); +} + +/** + * An engine that keeps a large Blob in memory: every browser on iOS, and Safari. + * Firefox and Chromium move a large one to their own temporary storage. + */ +export function blobHeldInMemory(): boolean { + const agent = typeof navigator === "undefined" ? "" : navigator.userAgent ?? ""; + return /AppleWebKit/.test(agent) && !/Chrome\/|Chromium\/|Edg\//.test(agent); +} + +/** + * How much a spool may hold before it stops. On an engine holding the Blob in memory + * it fails early: a tab killed with no message after hours is what this is for. + */ +export function spoolLimit(): number { + return blobHeldInMemory() ? SPOOL_MEMORY_LIMIT : Infinity; +} + +/** What holds a finished export in this browser, until it's released. */ +interface LiveFile { + url: string; + scope: string | null; +} + +const liveFiles = new Set(); + +/** The object URL is what keeps the Blob alive: once it's revoked nothing else here refers to it. */ +function holdFile(blob: Blob): LiveFile { + const scope = useMailTransferStore.getState().job?.scope; + const file: LiveFile = { url: URL.createObjectURL(blob), scope: scope && scope !== "default" ? scope : null }; + liveFiles.add(file); + return file; +} + +function releaseFile(file: LiveFile): void { + if (!liveFiles.delete(file)) return; + URL.revokeObjectURL(file.url); +} + +/** + * Drops finished exports still held for their download, also after the dialog closed: + * a sign-out or a wipe doesn't wait out the grace period. `all`: whoever's they are. + */ +export function dropExportFiles(account: string): void { + for (const file of [...liveFiles]) { + if (account === "all" || file.scope === null || file.scope === account) releaseFile(file); + } +} + +/** A file downloaded when done, for as long as the Blob is held. */ +function downloadableFile(name: string, blob: Blob): ExportFile { + const size = blob.size; + const held = holdFile(blob); + const download = () => { + if (liveFiles.has(held)) triggerDownload(held.url, name); + }; + download(); + return { + name, + size, + download, + discard: async (now = false) => { + // Taking the Blob away from a download that is still reading it would break the download. + if (!now) await new Promise((resolve) => setTimeout(resolve, downloadGraceMs(size))); + releaseFile(held); + }, + }; +} /** Whether this browser can write a private file from the page (Safari before 26 can't). */ export function canWritePrivateFile(): boolean { @@ -121,12 +212,25 @@ export async function pickSaveFile(name: string, mime: string, extension: string return { kind: "picker", write: (chunk) => buf.write(chunk), - close: async () => { + close: async (signal) => { await buf.flush(); + if (signal?.aborted) throw stopped(); await writable.close(); return { name, size: buf.total, discard: async () => {} }; }, - abort: async () => { await writable.abort().catch(() => {}); }, + abort: async () => { + await writable.abort().catch(() => {}); + // The picker made or emptied the file the moment it answered. Not every browser can remove it. + // What the person called it in the picker, which needn't be what was suggested. + const leftover = { leftover: handle.name ?? name }; + if (typeof handle.remove !== "function") return leftover; + try { + await handle.remove(); + return {}; + } catch { + return leftover; + } + }, }; } @@ -139,10 +243,38 @@ async function privateDir(): Promise { } } -/** A private file on disk, downloaded when done; null where the browser can't write one from the page. */ -export async function privateFileSink(name: string, mime: string): Promise { +/** The IV of a piece: its number. A key seals one spool, so a number never repeats under it. */ +function pieceIv(piece: number): Uint8Array { + const iv = new Uint8Array(12); + new DataView(iv.buffer).setUint32(8, piece); + return iv; +} + +/** Reads the pieces back in order, one at a time, opened. A piece that was changed fails the read. */ +function openedPieces(file: Blob, sizes: readonly number[], key: CryptoKey, signal?: AbortSignal): ReadableStream { + let piece = 0; + let at = 0; + return new ReadableStream({ + async pull(controller) { + if (signal?.aborted) throw stopped(); + if (piece === sizes.length) { + controller.close(); + return; + } + const size = sizes[piece]; + const sealed = await file.slice(at, at + size).arrayBuffer(); + controller.enqueue(new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: pieceIv(piece) }, key, sealed))); + piece += 1; + at += size; + }, + }); +} + +/** An encrypted spool on disk, downloaded when done; null where the browser can't write one from the page. */ +export async function privateFileSink(name: string, mime: string, limit = spoolLimit()): Promise { const dir = await privateDir(); - if (!dir) return null; + if (!dir || !globalThis.crypto?.subtle) return null; + const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); const entry = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; // Held until the file is gone, so no other tab's sweep deletes it meanwhile. const release = (await holdLock(`${LOCK_PREFIX}${entry}`)) ?? (() => {}); @@ -162,36 +294,53 @@ export async function privateFileSink(name: string, mime: string): Promise writable.write(chunk as BufferSource)); + const sizes: number[] = []; + const buf = buffered(async (chunk) => { + const sealed = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: pieceIv(sizes.length) }, key, chunk as BufferSource)); + await writable.write(sealed); + sizes.push(sealed.length); + }); const remove = async () => { await dir.removeEntry(entry).catch(() => {}); release(); }; return { kind: "private", - write: (chunk) => buf.write(chunk), - close: async () => { + write: async (chunk) => { + if (buf.total + chunk.length > limit) throw new ExportTooLargeError(limit); + await buf.write(chunk); + }, + close: async (signal) => { await buf.flush(); await writable.close(); - const file = await handle.getFile(); - const url = URL.createObjectURL(new Blob([file], { type: mime })); - triggerDownload(url, name); - return { - name, - size: file.size, - download: () => triggerDownload(url, name), - discard: async (now = false) => { - // Deleting the file under a download that is still reading it would break the download. - if (!now) await new Promise((resolve) => setTimeout(resolve, downloadGraceMs(file.size))); - URL.revokeObjectURL(url); - await remove(); - }, - }; + // Through a Response so the browser may spill a large Blob to its own storage. + const opened = await new Response(openedPieces(await handle.getFile(), sizes, key, signal)).blob(); + // A cancel, sign-out or wipe during that: the Blob is dropped here and nothing downloads. + if (signal?.aborted) throw stopped(); + await remove(); + return downloadableFile(name, new Blob([opened], { type: mime })); }, abort: async () => { await writable.abort().catch(() => {}); await remove(); }, }; } +/** + * The sink for an export: the file the user picks where the browser can ask (in + * the desktop app that's the OS's Save dialog), else the encrypted spool, else + * memory. Null when the user closed the picker. Call it straight from the click. + */ +export async function openExportSink(name: string, mime: string, extension: string): Promise { + if (canPickSaveFile()) { + try { + return await pickSaveFile(name, mime, extension); + } catch (error) { + if ((error as { name?: string } | null)?.name === "AbortError") return null; + } + } + await sweepPrivateExports(); + return (await privateFileSink(name, mime)) ?? memorySink(name, mime); +} + /** Everything in memory, then downloaded: only for small exports. */ export function memorySink(name: string, mime: string, limit = MEMORY_EXPORT_LIMIT): ExportSink { let parts: Uint8Array[] = []; @@ -203,32 +352,49 @@ export function memorySink(name: string, mime: string, limit = MEMORY_EXPORT_LIM if (size > limit) throw new ExportTooLargeError(limit); parts.push(chunk.slice()); }, - close: async () => { + close: async (signal) => { + if (signal?.aborted) { + parts = []; + throw stopped(); + } const blob = new Blob(parts as BlobPart[], { type: mime }); parts = []; - const url = URL.createObjectURL(blob); - triggerDownload(url, name); - return { - name, - size: blob.size, - download: () => triggerDownload(url, name), - discard: async (now = false) => { - if (!now) await new Promise((resolve) => setTimeout(resolve, downloadGraceMs(blob.size))); - URL.revokeObjectURL(url); - }, - }; + return downloadableFile(name, blob); }, abort: async () => { parts = []; }, }; } /** - * Deletes private export files no tab uses any more (a closed tab, a crash), - * so no copy of the mail stays in the browser profile. A file another tab - * holds is left alone; `force` deletes everything (everyone signed out). + * Stops an export the moment `allowed` says no, whichever sink it writes to: the + * save picker writes straight to the user's file, past the guard in saveBlobAs. + */ +export function guardedSink(sink: ExportSink, allowed: () => boolean): ExportSink { + const check = () => { + if (!allowed()) throw new ExportForbiddenError(); + }; + return { + kind: sink.kind, + write: async (chunk) => { + check(); + await sink.write(chunk); + }, + close: async (signal) => { + check(); + return sink.close(signal); + }, + abort: () => sink.abort(), + }; +} + +/** + * Deletes private export files no tab uses any more (a closed tab, a crash). + * They're encrypted under a key that died with the tab, but one from an older + * version is plain, and they take up room. A file another tab holds is left alone; `force` deletes everything (everyone signed out). * Without Web Locks only `force` sweeps, since another tab can't be asked. */ export async function sweepPrivateExports({ force = false }: { force?: boolean } = {}): Promise { + if (force) dropExportFiles("all"); if (!force && !hasWebLocks()) return; try { const root = await navigator.storage?.getDirectory?.(); diff --git a/lib/offline/__tests__/device-wipe.test.ts b/lib/offline/__tests__/device-wipe.test.ts index d0170d850..fea9dc1ff 100644 --- a/lib/offline/__tests__/device-wipe.test.ts +++ b/lib/offline/__tests__/device-wipe.test.ts @@ -530,14 +530,14 @@ describe('check-ins while running', () => { /** What the auth store does around the hook. */ function refuse(account = ALICE, certain = true): void { - wipeState.markLoginRefused(account, certain); + wipeState.markLoginRefused(account, { certain }); rejected.noteCredentialsRejected(account, certain); useAccountStore.getState().removeAccount(account, 'evicted'); } async function policyAsksForDeletion(): Promise { const { usePolicyStore } = await import('@/stores/policy-store'); - const policy = { organisation: { devices: { wipeOnRefusedLogin: true } } }; + const policy = { organisation: { devices: { wipeOnForcedSignOut: true } } }; usePolicyStore.setState({ policy, rawPolicy: policy, subject: null, loaded: true, source: 'server', partial: false, resolveFor: () => policy } as never); } @@ -579,7 +579,7 @@ describe('a refused login (lib/offline/credentials-rejected.ts)', () => { // What the sign-in page says, in numbers only. const [wait] = wipeState.refusedWaits(); - expect(wait).toEqual({ unsent: 1, until: expect.any(Number) }); + expect(wait).toEqual({ unsent: 1, until: expect.any(Number), kind: 'refused' }); expect(wait.until).toBeGreaterThanOrEqual(at + state.EVICTED_KEEP_MS); expect(wait.until).toBeLessThanOrEqual(at + state.EVICTED_KEEP_MS + 60_000); expect(JSON.stringify({ ...localStorage })).not.toContain('"Unsent"'); @@ -765,15 +765,16 @@ describe('a refused login (lib/offline/credentials-rejected.ts)', () => { expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]); }); - it('the session limit stays out of it: no hook, and the data waits', async () => { + it('the session limit hides too, as what it is, not as a refusal', async () => { wipe.registerDeviceWipe(); token(() => json(401, { error: 'session_expired', maxHours: 8 })); await useAuthStore.getState().refreshAccessToken({ accountId: ALICE }); - expect(heard).toEqual([]); + expect(heard).toEqual([ALICE]); await vi.waitFor(() => expect(state.isEvicted(ALICE)).toBe(true)); + await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined()); expect(purge.files).toBe(0); expect(store.removed).toEqual([]); - expect(wipeState.readRefusedLogins()).toEqual({}); + expect(wipeState.readRefusedLogins()[ALICE]).toMatchObject({ kind: 'session' }); }); }); }); @@ -1229,3 +1230,127 @@ describe('what else goes with a wipe', () => { expect(unsubscribed).toBe(0); }); }); + +describe('the session timers', () => { + const timerSignOut = () => useAuthStore.getState().logoutAll({ endProviderSession: false, keepLocalData: true, sessionLimit: true }); + + it('leave the outbox whole, and a sign-in brings it back', async () => { + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + wipe.registerDeviceWipe(); + const lost = await import('../lost'); + const at = Date.now(); + await timerSignOut(); + + expect(useAccountStore.getState().accounts).toEqual([]); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + expect(state.isEvicted(ALICE)).toBe(true); + expect(state.visibleAccounts()).toEqual([]); + expect(purge.files).toBe(0); + expect(store.removed).toEqual([]); + expect(state.useOfflineStore.getState().enabled).toEqual([ALICE]); + expect(lost.consumeLost()).toEqual([]); + await vi.waitFor(() => expect(wipeState.refusedWaits()).toEqual([{ unsent: 1, until: expect.any(Number), kind: 'session' }])); + + const offline = await import('../offline-mode'); + await offline.sweepAccountsGone(at + 14 * DAY); + expect(purge.files).toBe(0); + + useAccountStore.getState().addAccount(entry(ALICE, 0) as never); + expect(state.visibleAccounts()).toEqual([ALICE]); + expect((await queue.readQueue(ALICE)).outbox.map((row) => row.summary.subject)).toEqual(['Unsent']); + expect(wipeState.refusedWaits()).toEqual([]); + await wipe.sweepRefusedLogins(); + expect(wipeState.readRefusedLogins()).toEqual({}); + }); + + it('with no sign-in for 30 days it goes', async () => { + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + wipe.registerDeviceWipe(); + const at = Date.now(); + await timerSignOut(); + const offline = await import('../offline-mode'); + await offline.sweepAccountsGone(at + 31 * DAY); + expect(purge.files).toBeGreaterThan(0); + }); + + it('keep S/MIME and OpenPGP keys and the row: an admin can still reach the device', async () => { + await openDb('smime-plugin-store'); + wipe.registerDeviceWipe(); + await timerSignOut(); + expect(await databases()).toContain('smime-plugin-store'); + expect((await keys.getDeviceRecord(ALICE))?.id).toBe(ROW); + expect(sent.filter((call) => call.url.endsWith('/wiped'))).toEqual([]); + }); + + it('with the organisation asking for deletion, it goes at once, only that account\'s, named as the session limit', async () => { + await policyAsksForDeletion(); + respond = (url) => (url.endsWith('/wiped') ? json(200, { ok: true }) : noWipe()); + await openDb('smime-plugin-store'); + wipe.registerDeviceWipe(); + await timerSignOut(); + await vi.waitFor(() => expect(sent.find((call) => call.url.endsWith('/wiped'))?.body).toMatchObject({ reason: 'session', id: ROW })); + await vi.waitFor(() => expect(wipeState.readPendingWipes()).toEqual({})); + expect(state.useOfflineStore.getState().enabled).toEqual([]); + expect(await databases()).toContain('smime-plugin-store'); + }); + + it('a sign-out the user asks for still deletes', async () => { + await queue.queueSend(ALICE, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + await useAuthStore.getState().logoutAll({ endProviderSession: false }); + expect(state.isEvicted(ALICE)).toBe(false); + await vi.waitFor(() => expect(purge.files).toBeGreaterThan(0)); + expect(wipeState.readRefusedLogins()).toEqual({}); + }); +}); + +describe('a refused account with another still signed in', () => { + beforeEach(async () => { + useAccountStore.setState({ accounts: [entry(ALICE, 0), entry(BOB, 1)] } as never); + state.writeEnabled([ALICE, BOB]); + await keys.openAccountKey(BOB); + await queue.queueSend(BOB, { subject: 'Unsent', to: [] }, { kind: 'email', args: [] }); + }); + + /** What the auth store writes down, with who was there to read it. */ + function refuseBob(): void { + wipeState.markLoginRefused(BOB, { certain: true, seenBy: [ALICE], label: BOB, username: 'bob' }); + rejected.noteCredentialsRejected(BOB, true); + useAccountStore.getState().removeAccount(BOB, 'evicted'); + } + + it('is there for the one who was signed in beside it, with its count and date', async () => { + wipe.registerDeviceWipe(); + const at = Date.now(); + refuseBob(); + await vi.waitFor(() => expect(wipeState.siblingWaits(ALICE)[0]?.unsent).toBe(1)); + const [wait] = wipeState.siblingWaits(ALICE); + expect(wait).toMatchObject({ label: BOB, username: 'bob', kind: 'refused' }); + expect(wait.until).toBeGreaterThanOrEqual(at + state.EVICTED_KEEP_MS); + }); + + it('isn\'t for anyone who signs in here later, and goes when the account is back', async () => { + wipe.registerDeviceWipe(); + refuseBob(); + await vi.waitFor(() => expect(wipeState.siblingWaits(ALICE)).toHaveLength(1)); + expect(wipeState.siblingWaits('someone-else')).toEqual([]); + useAccountStore.getState().addAccount(entry(BOB, 1) as never); + expect(wipeState.siblingWaits(ALICE)).toEqual([]); + }); + + it('says so once, when the count is known', async () => { + wipe.registerDeviceWipe(); + expect(wipeState.takeSiblingToasts(ALICE)).toEqual([]); + refuseBob(); + expect(wipeState.takeSiblingToasts(ALICE)).toEqual([]); + await vi.waitFor(() => expect(wipeState.siblingWaits(ALICE)[0]?.unsent).toBe(1)); + expect(wipeState.takeSiblingToasts(ALICE)).toEqual([expect.objectContaining({ label: BOB, unsent: 1 })]); + expect(wipeState.takeSiblingToasts(ALICE)).toEqual([]); + }); + + it('says so with no number when the store can\'t give one', async () => { + store.failArea = () => true; + wipe.registerDeviceWipe(); + refuseBob(); + await vi.waitFor(() => expect(wipeState.takeSiblingToasts(ALICE)).toEqual([expect.objectContaining({ unsent: null })])); + }); +}); diff --git a/lib/offline/credentials-rejected.ts b/lib/offline/credentials-rejected.ts index 8a4162723..4fb1388f4 100644 --- a/lib/offline/credentials-rejected.ts +++ b/lib/offline/credentials-rejected.ts @@ -1,14 +1,17 @@ /** - * The server refused this login: `invalid_grant`, a lasting 401 after a fresh try - * (both `certain`), or a 401 at a connect that then failed (not, it's one answer). - * The auth store follows with a sign-out that hides the account's data, which - * waits for the new password. The organisation's session limit, an outage or any - * other refusal doesn't come here. lib/offline/device-wipe.ts listens, to keep the - * count for the sign-in page and to delete at once where the organisation asks for - * that. Free of the stores, so the auth store can call it. + * A login ended with nobody asking. `refused`: the server refused it, by + * `invalid_grant` or a lasting 401 after a fresh try (both `certain`), or a 401 at a + * connect that then failed (not, it's one answer). `session`: the organisation's + * idle or session limit. The auth store follows with a sign-out that hides the + * account's data, which waits for the next sign-in. An outage or any other refusal + * doesn't come here. lib/offline/device-wipe.ts listens, to keep the count for the + * sign-in page and to delete at once where the organisation asks for that. Free of + * the stores, so the auth store can call it. */ -type Listener = (accountId: string, certain: boolean) => void; +export type ForcedSignOutKind = 'refused' | 'session'; + +type Listener = (accountId: string, certain: boolean, kind: ForcedSignOutKind) => void; const listeners = new Set(); @@ -17,10 +20,10 @@ export function onCredentialsRejected(listener: Listener): () => void { return () => listeners.delete(listener); } -export function noteCredentialsRejected(accountId: string, certain = true): void { +export function noteCredentialsRejected(accountId: string, certain = true, kind: ForcedSignOutKind = 'refused'): void { for (const listener of [...listeners]) { try { - listener(accountId, certain); + listener(accountId, certain, kind); } catch { // A sign-out must go on whatever a listener does. } diff --git a/lib/offline/device-wipe.ts b/lib/offline/device-wipe.ts index b375d7be6..778823d64 100644 --- a/lib/offline/device-wipe.ts +++ b/lib/offline/device-wipe.ts @@ -5,11 +5,11 @@ * - the instance's registry (lib/devices): a wipe an admin asked for, heard at * check-in. At a start the check-in runs before the local copy is shown. * - the user, from settings. - * - the organisation, when it asks for deletion at once after a refused login - * (`organisation.devices.wipeOnRefusedLogin`). + * - the organisation, when it asks for deletion at once after a refused login or + * an idle or session limit (`organisation.devices.wipeOnForcedSignOut`). * - * A refused login on its own deletes nothing. The account signs out and its data - * hides until someone signs in with the new password, or for 30 days + * A refused login or an idle or session limit on its own deletes nothing. The + * account signs out and its data hides until the next sign-in, or for 30 days * (EVICTED_KEEP_MS). What this file keeps for it is a number, for the sign-in page. * The usual cause is a password changed on another device, and deleting there * would lose unsent mail for very little protection against a thief. A deletion @@ -86,7 +86,7 @@ import { import { onAccountRemoved, useAccountStore, type AccountEntry } from '@/stores/account-store'; import { useAuthStore } from '@/stores/auth-store'; import { usePolicyStore } from '@/stores/policy-store'; -import { onCredentialsRejected } from './credentials-rejected'; +import { onCredentialsRejected, type ForcedSignOutKind } from './credentials-rejected'; import { forgetEvictedSecrets } from './evicted-secrets'; import { confirmLoss, registerOfflineHooks, unsentForWipe, wipeAccount, wipeEverything } from './offline-mode'; import { clearEvicted, forgetMailSnapshot, isEvicted, readEvicted, serverKey, useOfflineStore } from './state'; @@ -397,7 +397,7 @@ async function eraseAccount(account: string, reason: WipeReason, options: WipeAc tellOtherWindows(account, 'start'); registerOfflineHooks(); - const ownOnly = reason === 'rejected'; + const ownOnly = reason === 'rejected' || reason === 'session'; const unsent = await unsentForWipe(account); if (record && !readWipeConfirmations().some((entry) => entry.id === record.id)) { queueWipeConfirmation({ account, id: record.id, secret: record.secret, reason, unsent }); @@ -642,14 +642,16 @@ async function countRefused(account: string): Promise { setRefusedCount(account, await unsentForWipe(account)); } +const wipeReasonFor = (kind: ForcedSignOutKind | undefined): WipeReason => (kind === 'session' ? 'session' : 'rejected'); + /** - * The login was refused, and the auth store is signing it out and hiding the data. - * It goes now only if the organisation asked for that, and only for a refusal that - * is one for certain: a single bad answer at a start can be a server in trouble. + * The login ended with nobody asking, and the auth store is signing it out and + * hiding the data. It goes now only if the organisation asked for that, and only + * when it's certain: a single bad answer at a start can be a server in trouble. */ -async function heardRefusal(account: string, certain: boolean): Promise { - if (certain && policyKnown() && devicesForAccount(account).wipeOnRefusedLogin) { - await wipeAccountFromDevice(account, 'rejected'); +async function heardRefusal(account: string, certain: boolean, kind: ForcedSignOutKind): Promise { + if (certain && policyKnown() && devicesForAccount(account).wipeOnForcedSignOut) { + await wipeAccountFromDevice(account, wipeReasonFor(kind)); return; } await countRefused(account); @@ -663,8 +665,8 @@ export async function sweepRefusedLogins(): Promise { continue; } // Nobody's signed in to it, so no groups to go by: what applies to everyone reaches it. - if (wait.certain && policyKnown() && devicesForNoOne().wipeOnRefusedLogin) { - await wipeAccountFromDevice(account, 'rejected'); + if (wait.certain && policyKnown() && devicesForNoOne().wipeOnForcedSignOut) { + await wipeAccountFromDevice(account, wipeReasonFor(wait.kind)); continue; } if (wait.unsent === null) await countRefused(account); @@ -677,8 +679,8 @@ let registered = false; export function registerDeviceWipe(): void { if (registered) return; registered = true; - onCredentialsRejected((account, certain) => { - void heardRefusal(account, certain); + onCredentialsRejected((account, certain, kind) => { + void heardRefusal(account, certain, kind); }); onBeforeSignOut(async (accountId, { keepLocalStore }) => { if (!keepLocalStore) await dropRows(accountId); diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 75accd910..d64d6850f 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -31,6 +31,7 @@ import { labelsPolicyFor } from '@/lib/outgoing/classification'; import { getSendGuardSource, guardState, guardsSending } from '@/lib/plugin-send-guards'; import { emitPushStateChange } from '@/lib/push-events'; import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync'; +import { emitStoreEvent } from '@/lib/offline/store-events'; import { STORAGE_ERROR_AREA, type StoreScope, type StoreUsage } from '@/lib/store/area'; import type { DraftError } from '@/lib/store/drafts/pending'; import { configureStore, getStore, type StorePageHooks } from '@/lib/store/client/api'; @@ -94,6 +95,8 @@ import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migr import { deleteShellCaches, tellServiceWorker } from './shell'; import { rememberLost } from './lost'; import { forgetEvictedSecrets } from './evicted-secrets'; +import { clearDesktopSurfaces } from '@/lib/desktop/bridge'; +import { addSleepHandler, reportStoreCreatedMail } from '@/lib/desktop/system-events'; export type OfflineNotice = | { kind: 'queued'; subject: string } @@ -256,6 +259,11 @@ const storeHooks: StorePageHooks = { void refreshQueueState(); }, onEvent(event) { + emitStoreEvent(event); + if (event.type === 'created' && event.collection === 'Email') { + const client = onlineClientFor(event.account); + if (client) reportStoreCreatedMail(client, event.jmap, event.ids); + } if (event.type === 'synced') { useOfflineStore.setState((s) => { const { [event.account]: _full, ...storageFull } = s.storageFull; @@ -1076,6 +1084,7 @@ async function nameLost(scope: string, stillSignedIn: boolean): Promise { } export async function wipeAccount(account: string): Promise { + clearDesktopSurfaces(); const timer = syncTimers.get(account); if (timer) clearTimeout(timer); syncTimers.delete(account); @@ -1111,6 +1120,7 @@ export async function wipeAccount(account: string): Promise { /** Nothing of offline mode stays on this device. */ export async function wipeEverything(): Promise { + clearDesktopSurfaces(); for (const timer of syncTimers.values()) clearTimeout(timer); syncTimers.clear(); writeEnabled([]); @@ -1132,7 +1142,7 @@ export async function wipeEverything(): Promise { } /** The organisation's word on this account, from the server or the last fetch. Defaults aren't its word. */ -function scopeDecided(account: string): boolean { +export function scopeDecided(account: string): boolean { const { loaded, source, partial } = usePolicyStore.getState(); // The public part a page gets before it's signed in has no caps and no scopes in it. return loaded && !partial && (source === 'server' || source === 'cache') && policyDecided(account); @@ -1254,6 +1264,11 @@ export function startOfflineMode(): () => void { window.removeEventListener('offline', onOffline); }); + // The desktop app going to sleep. Waking comes with the two events above and below. + cleanups.push(addSleepHandler(() => { + void getStore().setOnline(false).catch(() => {}); + })); + // Back in front: catch up now rather than at the next interval. const onVisible = () => { if (document.visibilityState !== 'visible') return; diff --git a/lib/offline/store-events.ts b/lib/offline/store-events.ts new file mode 100644 index 000000000..b857ec351 --- /dev/null +++ b/lib/offline/store-events.ts @@ -0,0 +1,18 @@ +/** The store's events, for the parts of the page that aren't offline mode itself. */ + +import type { StoreEvent } from '@/lib/store/area'; + +type Listener = (event: StoreEvent) => void; + +const listeners = new Set(); + +export function addStoreEventListener(listener: Listener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function emitStoreEvent(event: StoreEvent): void { + for (const listener of [...listeners]) listener(event); +} diff --git a/lib/pwa-notify/__tests__/page-actions.test.ts b/lib/pwa-notify/__tests__/page-actions.test.ts index 39287cd2f..c911eeae8 100644 --- a/lib/pwa-notify/__tests__/page-actions.test.ts +++ b/lib/pwa-notify/__tests__/page-actions.test.ts @@ -13,6 +13,7 @@ const makeClient = (accountId: string) => ({ getAccountId: () => accountId, getEmail: vi.fn(async (id: string) => (id === 'gone' ? null : { id, receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { [id === 'binned' ? 'bin' : 'inbox']: true } })), getMailboxes: vi.fn(async () => [{ id: 'inbox', role: 'inbox' }, { id: 'x', originalId: 'bin', role: 'trash' }]), + batchMoveEmails: vi.fn(async (..._args: unknown[]) => {}), }); let clients: Record>; let activeAccountId = 'alice@one'; @@ -31,7 +32,9 @@ vi.mock('@/stores/auth-store', () => ({ }), }, })); -vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({ batchMarkAsRead, batchArchive }) } })); +let listed: { id: string; sourceClientAccountId?: string }[] = []; +const deleteEmail = vi.fn(async (..._args: unknown[]) => true); +vi.mock('@/stores/email-store', () => ({ useEmailStore: { getState: () => ({ batchMarkAsRead, batchArchive, deleteEmail, emails: listed }) } })); // Each login's own subject, and a policy that reads it: the gate of the login // the notification is for, not of the one that's active here. vi.mock('@/lib/org/account-subjects', () => ({ subjectForAccount: (id: string) => ({ email: id, groups: [] }) })); @@ -43,6 +46,7 @@ vi.mock('@/stores/policy-store', () => ({ }, })); vi.mock('../plugin-veto', () => ({ vetoHooksRegistered: () => hooksRegistered })); +vi.mock('@/stores/settings-store', () => ({ useSettingsStore: { getState: () => ({ deleteAction: 'trash' }) } })); async function run(over: Partial = {}) { const request: NotificationActionRequest = { @@ -65,6 +69,7 @@ beforeEach(() => { activeAccountId = 'alice@one'; gateOff.clear(); hooksRegistered = false; + listed = []; batchMarkAsRead.mockResolvedValue(true); batchArchive.mockResolvedValue(true); }); @@ -278,3 +283,34 @@ describe('Reply', () => { removeCurrent(); }); }); + +describe("Delete, from the desktop app's toast", () => { + it('goes through the store when the message is in the list, with its hooks and the Delete setting', async () => { + listed = [{ id: 'e1' }]; + expect(await run({ action: 'delete' })).toEqual(['accepted', 'done']); + expect(deleteEmail).toHaveBeenCalledWith(clients['alice@one'], 'e1'); + expect(clients['alice@one'].batchMoveEmails).not.toHaveBeenCalled(); + }); + + it("moves it to Trash by the folder's real id when it is not in the list", async () => { + expect(await run({ action: 'delete' })).toEqual(['accepted', 'done']); + expect(clients['alice@one'].batchMoveEmails).toHaveBeenCalledWith(['e1'], 'bin', undefined, expect.any(Boolean)); + expect(deleteEmail).not.toHaveBeenCalled(); + }); + + it('plugins are never gone round: with a veto hook listening, the message opens instead', async () => { + hooksRegistered = true; + expect(await run({ action: 'delete' })).toEqual(['accepted', 'failed']); + expect(clients['alice@one'].batchMoveEmails).not.toHaveBeenCalled(); + }); + + it('a message since moved to Trash stays there', async () => { + clients['alice@one'].getEmail.mockResolvedValue({ id: 'e1', mailboxIds: { bin: true } } as never); + expect(await run({ action: 'delete' })).toEqual(['accepted', 'failed']); + expect(clients['alice@one'].batchMoveEmails).not.toHaveBeenCalled(); + }); + + it("the worker can't send one", () => { + expect(isNotificationActionRequest({ action: 'delete', emailId: 'e1', accountId: 'a', at: Date.now() })).toBe(false); + }); +}); diff --git a/lib/pwa-notify/page-actions.ts b/lib/pwa-notify/page-actions.ts index 96b1db101..1368a61be 100644 --- a/lib/pwa-notify/page-actions.ts +++ b/lib/pwa-notify/page-actions.ts @@ -5,10 +5,12 @@ import { useEmailStore } from '@/stores/email-store'; import { usePolicyStore } from '@/stores/policy-store'; import { loginFingerprint } from './login-fingerprint'; import { vetoHooksRegistered } from './plugin-veto'; +import { useSettingsStore } from '@/stores/settings-store'; /** What the service worker posts when a notification's button is clicked (public/sw.js). */ export interface NotificationActionRequest { - action: 'archive' | 'mark-read' | 'reply'; + /** `delete` only comes from the desktop app's toasts, never from the worker. */ + action: 'archive' | 'mark-read' | 'reply' | 'delete'; emailId: string; accountId: string; /** When the worker asked. */ @@ -149,6 +151,24 @@ export async function runNotificationAction( answer('failed'); return; } + if (request.action === 'delete') { + const store = useEmailStore.getState(); + // In the list: the store's own delete, with its hooks and the Delete setting. + if (store.emails.some((row) => row.id === request.emailId && (row.sourceClientAccountId ?? entry.id) === entry.id)) { + answer(await store.deleteEmail(client, request.emailId) ? 'done' : 'failed'); + return; + } + // Not in the list, so no store action can reach it, and plugins are never gone round. + const trash = mailboxes.find((mailbox) => mailbox.role === 'trash'); + if (!trash || !email || vetoHooksRegistered()) { + answer('failed'); + return; + } + const markRead = useSettingsStore.getState().deleteAction === 'trash-and-read'; + await client.batchMoveEmails([request.emailId], trash.originalId || trash.id, accountId, markRead); + answer('done'); + return; + } const done = request.action === 'mark-read' ? await useEmailStore.getState().batchMarkAsRead(activeClient, true, { emailIds: [request.emailId], client, accountId }) : await useEmailStore.getState().batchArchive(activeClient, { emailIds: [request.emailId], client, emails: email ? [email] : [], userArchive: true }); diff --git a/lib/sign-out-cleanup.ts b/lib/sign-out-cleanup.ts index 3f9f86700..f364514a3 100644 --- a/lib/sign-out-cleanup.ts +++ b/lib/sign-out-cleanup.ts @@ -2,6 +2,7 @@ import { forgetAppLockAtSignOut } from '@/lib/app-lock/sign-out'; import { fileStorage } from '@/lib/plugin-storage'; import { clearHandoffs } from '@/lib/launch-handoff'; import { clearAllCheckpoints } from '@/lib/mail-import/checkpoints'; +import { clearDesktopSurfaces } from '@/lib/desktop/bridge'; import { sweepPrivateExports } from '@/lib/mail-export/sinks'; import { purgeStoreFiles } from '@/lib/store/client/purge'; @@ -22,6 +23,7 @@ const SIGNED_OUT_STORAGE_KEYS = [ 'contact-storage', 'calendar-storage', 'calendar-notification-storage', + 'calendar-alert-claims', // Open tabs name messages and their subjects. 'pro-tabs', 'search-history-storage', @@ -150,6 +152,7 @@ export function purgeSignedOutData(options: SignOutCleanupOptions = {}): void { if (!options.keepLocalStore && !options.keepAppLock) void forgetAppLockAtSignOut(); // Files and links on their way into a draft or an import. clearHandoffs(); + clearDesktopSurfaces(); for (const cleanup of signedOutCleanups) { try { cleanup(options); diff --git a/lib/store/__tests__/calendar-no-dom.test.ts b/lib/store/__tests__/calendar-no-dom.test.ts index 8cd3d10a1..1fcd8543f 100644 --- a/lib/store/__tests__/calendar-no-dom.test.ts +++ b/lib/store/__tests__/calendar-no-dom.test.ts @@ -22,7 +22,7 @@ async function inputsOf(entry: string): Promise { } describe('what the worker pulls in', () => { - for (const entry of ['lib/store/calendar/index.ts', 'lib/store/calendar/reminders.ts']) { + for (const entry of ['lib/store/calendar/index.ts', 'lib/store/calendar/reminders.ts', 'lib/store/reminders/index.ts']) { it(`${entry} reaches no UI code`, async () => { const inputs = await inputsOf(entry); expect(inputs.length).toBeGreaterThan(3); diff --git a/lib/store/__tests__/reminders-review.test.ts b/lib/store/__tests__/reminders-review.test.ts new file mode 100644 index 000000000..ab834a7e8 --- /dev/null +++ b/lib/store/__tests__/reminders-review.test.ts @@ -0,0 +1,405 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { DEFAULT_SCOPE } from '../area'; +import { calendarArea } from '../calendar'; +import { listUpcomingAlerts } from '../calendar/reminders'; +import { instantOf } from '../calendar/records'; +import { AccountEngine } from '../engine/account-engine'; +import { remindersArea } from '../reminders'; +import { AWAY_MS, type MissedBatch, type TickResult } from '../reminders/ledger'; +import { CALENDAR_SESSION, FakeCalendarJmap } from './helpers/fake-calendar'; +import { JMAP_ACCOUNT } from './helpers/fake-jmap'; +import { openMemoryStore } from './helpers/node-adapter'; + +const MIN = 60_000; +const DAY = 24 * 60 * MIN; +const START = Date.UTC(2026, 10, 10, 9); +const FIRE = START - 15 * MIN; +const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } }; +const WEEKLY = { '@type': 'RecurrenceRule', frequency: 'weekly' }; +const ZONE = 'Europe/Berlin'; +const PARAMS = { timeZone: ZONE, tasks: true }; + +function setup(startAt = FIRE - 60 * MIN) { + const server = new FakeCalendarJmap('1.0'); + const db = openMemoryStore(); + const rig = { server, db, now: startAt }; + const make = () => new AccountEngine({ + account: 'a@fake.test', + db, + session: CALENDAR_SESSION, + transport: server, + areas: [calendarArea, remindersArea], + scope: DEFAULT_SCOPE, + now: () => rig.now, + sleep: async () => {}, + }); + let engine = make(); + return Object.assign(rig, { + get engine() { + return engine; + }, + restart: () => { + engine = make(); + }, + call: (name: string, params?: unknown) => engine.areaCall(name, params) as Promise, + tick: (params: Record = {}) => engine.areaCall('reminders.tick', { ...PARAMS, ...params }) as Promise, + }); +} + +type Rig = ReturnType; + +function event(partial: Record = {}) { + return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: ZONE, duration: 'PT30M', alerts: { a: ALERT }, ...partial }; +} + +async function syncedAndWatching(rig: Rig): Promise { + await rig.engine.requestSync(); + await rig.tick(); +} + +/** The last check just before the alert, so nothing counts as away. */ +async function justBefore(rig: Rig): Promise { + rig.now = FIRE - MIN; + await rig.tick(); +} + +describe('one bad event', () => { + const BAD: Array<[string, Record]> = [ + ['an offset out of all range', { alerts: { a: { ...ALERT, trigger: { '@type': 'OffsetTrigger', offset: 'P999999999D' } } } }], + ['a huge negative offset on a series', { recurrenceRule: WEEKLY, start: '2026-10-06T10:00:00', alerts: { a: { ...ALERT, trigger: { '@type': 'OffsetTrigger', offset: '-P999999999W' } } } }], + ['an alert without a trigger', { alerts: { a: { '@type': 'Alert', action: 'display' } } }], + ['a null alert', { alerts: { a: null } }], + ['a null alert on a series', { recurrenceRule: WEEKLY, start: '2026-10-06T10:00:00', alerts: { a: null } }], + ['a start that is no date', { start: 'yesterday-ish' }], + ]; + + for (const [name, partial] of BAD) { + it(`doesn't stop the others: ${name}`, async () => { + const rig = setup(); + rig.server.addEvent(event({ id: 'bad', uid: 'bad', title: 'Bad', ...partial })); + rig.server.addEvent(event()); + await syncedAndWatching(rig); + await justBefore(rig); + rig.now = FIRE; + expect((await rig.tick()).due.map((alert) => alert.title)).toEqual(['Standup']); + }); + } + + it("doesn't stop the others: a row that can't be read at all", async () => { + const rig = setup(); + rig.server.addEvent(event()); + await syncedAndWatching(rig); + rig.db.run('INSERT INTO calendar_event (jmap, id, uid, json) VALUES (?, ?, ?, ?)', ['a-other', 'broken', 'broken', '{not json']); + await justBefore(rig); + rig.now = FIRE; + expect((await rig.tick()).due.map((alert) => alert.title)).toEqual(['Standup']); + }); + + it('keeps the good alert of an event with a bad one', async () => { + const rig = setup(); + rig.server.addEvent(event({ alerts: { bad: null, huge: { ...ALERT, trigger: { '@type': 'OffsetTrigger', offset: '-P999999999W' } }, a: ALERT } })); + await syncedAndWatching(rig); + await justBefore(rig); + rig.now = FIRE; + expect((await rig.tick()).due.map((alert) => alert.alertId)).toEqual(['a']); + }); +}); + +describe('Join', () => { + const OPTIONS = { from: FIRE - MIN, to: FIRE + MIN, timeZone: ZONE }; + + async function joinOf(uri: string, extra: Record = {}) { + const rig = setup(); + rig.server.addEvent(event({ virtualLocations: { v: { '@type': 'VirtualLocation', uri } }, ...extra })); + await rig.engine.requestSync(); + const [alert] = listUpcomingAlerts(rig.db, OPTIONS); + return alert; + } + + for (const uri of ['ms-msdt:/id PCWDiagnostic', 'search-ms:query=x', 'smb://evil.example/share', 'msteams:/l/meetup-join/x', 'https://meet.google.com@evil.example/abc-defg-hij', 'javascript:alert(1)']) { + it(`offers none for ${uri}`, async () => { + expect((await joinOf(uri)).joinUrl).toBeNull(); + }); + } + + it('names the host of a link it does not know, not of one it does', async () => { + expect(await joinOf('https://calls.example.org/room')).toMatchObject({ joinUrl: 'https://calls.example.org/room', joinHost: 'calls.example.org' }); + expect(await joinOf('https://meet.jit.si/standup')).toMatchObject({ joinUrl: 'https://meet.jit.si/standup', joinHost: null }); + expect(await joinOf('https://meet.google.com/abc-defg-hij')).toMatchObject({ joinHost: null }); + }); +}); + +describe('shared calendars', () => { + function addOtherCopy(rig: Rig, jmap: string, json: Record) { + rig.db.run('INSERT INTO calendar_event (jmap, id, uid, json) VALUES (?, ?, ?, ?)', [jmap, json.id as string, json.uid as string, JSON.stringify({ calendarIds: { cal1: true }, '@type': 'Event', ...json })]); + } + + it('leaves out a calendar the user has not subscribed to', async () => { + const rig = setup(); + rig.server.calendars.get('cal1')!.isSubscribed = false; + rig.server.addEvent(event()); + await rig.engine.requestSync(); + expect(listUpcomingAlerts(rig.db, { from: FIRE - MIN, to: FIRE + MIN, timeZone: ZONE })).toEqual([]); + }); + + it('still reads a calendar that says nothing about it', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await rig.engine.requestSync(); + expect(listUpcomingAlerts(rig.db, { from: FIRE - MIN, to: FIRE + MIN, timeZone: ZONE })).toHaveLength(1); + }); + + it('rings once for a meeting in two accounts, from the own account', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await rig.engine.requestSync(); + addOtherCopy(rig, 'a-shared', event({ id: 'other', alerts: { other: ALERT } })); + const alerts = listUpcomingAlerts(rig.db, { from: FIRE - MIN, to: FIRE + MIN, timeZone: ZONE, prefer: JMAP_ACCOUNT }); + expect(alerts.map((alert) => alert.jmap)).toEqual([JMAP_ACCOUNT]); + const other = listUpcomingAlerts(rig.db, { from: FIRE - MIN, to: FIRE + MIN, timeZone: ZONE, prefer: 'a-shared' }); + expect(other.map((alert) => alert.jmap)).toEqual(['a-shared']); + }); + + it('prefers the account the session names as primary when it fires', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await syncedAndWatching(rig); + addOtherCopy(rig, 'a-shared', event({ id: 'other' })); + await justBefore(rig); + rig.now = FIRE; + expect((await rig.tick()).due.map((alert) => alert.jmap)).toEqual([JMAP_ACCOUNT]); + }); +}); + +describe('an edit that keeps the time', () => { + it('does not fire again when only the alert id changed', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await syncedAndWatching(rig); + await justBefore(rig); + rig.now = FIRE; + expect((await rig.tick()).due).toHaveLength(1); + rig.server.patchEvent('e1', { alerts: { renamed: ALERT } }); + await rig.engine.requestSync(); + rig.now = FIRE + MIN; + expect((await rig.tick()).due).toEqual([]); + }); + + it('keeps the snooze, and brings it back once', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await syncedAndWatching(rig); + await justBefore(rig); + rig.now = FIRE; + const [fired] = (await rig.tick()).due; + await rig.call('reminders.snooze', { key: fired.key, minutes: 5 }); + rig.server.patchEvent('e1', { alerts: { renamed: ALERT } }); + await rig.engine.requestSync(); + rig.now = FIRE + 5 * MIN; + expect((await rig.tick()).due.map((alert) => alert.alertId)).toEqual(['renamed']); + rig.now += MIN; + expect((await rig.tick()).due).toEqual([]); + }); + + it('a move while snoozed: the old time is forgotten, the new one rings', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await syncedAndWatching(rig); + await justBefore(rig); + rig.now = FIRE; + const [fired] = (await rig.tick()).due; + await rig.call('reminders.snooze', { key: fired.key, minutes: 5 }); + rig.server.patchEvent('e1', { start: '2026-11-10T10:30:00' }); + await rig.engine.requestSync(); + rig.now = FIRE + 5 * MIN; + expect((await rig.tick()).due).toEqual([]); + rig.now = FIRE + 10 * MIN; + await rig.tick(); + rig.now = FIRE + 15 * MIN; + await rig.tick(); + rig.now = FIRE + 20 * MIN; + await rig.tick(); + rig.now = FIRE + 25 * MIN; + await rig.tick(); + rig.now = FIRE + 30 * MIN; + const moved = (await rig.tick()).due; + expect(moved.map((alert) => alert.fireTimeMs)).toEqual([FIRE + 30 * MIN]); + }); +}); + +describe('zones', () => { + it('reads an all-day series in the Time zone setting, in any zone', async () => { + for (const zone of ['Pacific/Auckland', 'America/Los_Angeles', 'UTC']) { + const rig = setup(); + rig.server.addEvent(event({ + start: '2026-10-06T00:00:00', + showWithoutTime: true, + timeZone: null, + duration: 'P1D', + recurrenceRule: WEEKLY, + alerts: { a: { ...ALERT, trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' } } }, + })); + await rig.engine.requestSync(); + const startOfDay = instantOf('2026-11-10T00:00:00', zone)!; + const alerts = listUpcomingAlerts(rig.db, { from: startOfDay - 2 * 60 * MIN, to: startOfDay + MIN, timeZone: zone }); + expect(alerts.map((alert) => alert.fireTimeMs)).toEqual([startOfDay - 60 * MIN]); + expect(alerts[0].startMs).toBe(startOfDay); + } + }); +}); + +describe('a fixed-time alert on a series', () => { + it('rings as before', async () => { + const rig = setup(); + rig.server.addEvent(event({ + start: '2026-10-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: { ...ALERT, trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } }, + })); + await rig.engine.requestSync(); + expect(listUpcomingAlerts(rig.db, { from: FIRE - MIN, to: FIRE + MIN, timeZone: ZONE })).toHaveLength(1); + }); + + it('stays quiet when the occurrence it was for was excluded', async () => { + const rig = setup(); + rig.server.addEvent(event({ + start: '2026-10-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: { ...ALERT, trigger: { '@type': 'AbsoluteTrigger', when: new Date(FIRE).toISOString() } } }, + recurrenceOverrides: { '2026-11-10T10:00:00': { excluded: true } }, + })); + await rig.engine.requestSync(); + expect(listUpcomingAlerts(rig.db, { from: FIRE - MIN, to: FIRE + MIN, timeZone: ZONE })).toEqual([]); + }); +}); + +describe('the edge of being away', () => { + async function twoAlerts(gapMs: number): Promise { + const rig = setup(); + rig.server.addEvent(event()); + rig.server.addEvent(event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + await rig.engine.requestSync(); + rig.now = FIRE - 1000; + await rig.tick(); + rig.now = FIRE - 1000 + gapMs; + return rig.tick(); + } + + it('a gap of exactly the limit is no absence', async () => { + expect(await twoAlerts(AWAY_MS)).toMatchObject({ missed: 0, due: [{ title: 'Standup' }, { title: 'Review' }] }); + }); + + it('a gap a moment longer is', async () => { + expect(await twoAlerts(AWAY_MS + 1)).toMatchObject({ missed: 2, due: [] }); + }); + + it('a single alert past the limit is a late reminder, not a summary', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await rig.engine.requestSync(); + rig.now = FIRE - 1000; + await rig.tick(); + rig.now = FIRE - 1000 + AWAY_MS + 1; + expect(await rig.tick()).toMatchObject({ missed: 0, due: [{ title: 'Standup' }] }); + }); +}); + +describe('pruning', () => { + it('forgets what is past the reach, and keeps what still waits', async () => { + const rig = setup(); + rig.server.addEvent(event()); + rig.server.addEvent(event({ id: 'e2', uid: 'uid-2', title: 'Snoozed' })); + rig.server.addEvent(event({ id: 'e3', uid: 'uid-3', title: 'Held' })); + await syncedAndWatching(rig); + await justBefore(rig); + rig.now = FIRE; + const fired = (await rig.tick()).due; + const byTitle = Object.fromEntries(fired.map((alert) => [alert.title, alert.key])); + await rig.call('reminders.snooze', { key: byTitle.Snoozed, minutes: 60 }); + await rig.call('reminders.hold', { keys: [byTitle.Held] }); + const rows = () => rig.db.all<{ key: string }>('SELECT key FROM reminder').map((row) => row.key).sort(); + expect(rows()).toHaveLength(3); + rig.now = FIRE + 9 * DAY; + await rig.tick(); + expect(rows()).toEqual([byTitle.Snoozed, byTitle.Held].sort()); + }); +}); + +describe('bounded keys', () => { + it('stays short with a very long uid, and tells two long ones apart', async () => { + const rig = setup(); + const long = 'u'.repeat(5000); + rig.server.addEvent(event({ uid: `${long}1` })); + rig.server.addEvent(event({ id: 'e2', uid: `${long}2` })); + await rig.engine.requestSync(); + const alerts = listUpcomingAlerts(rig.db, { from: FIRE - MIN, to: FIRE + MIN, timeZone: ZONE }); + expect(alerts).toHaveLength(2); + expect(new Set(alerts.map((alert) => alert.key)).size).toBe(2); + for (const alert of alerts) expect(alert.key.length).toBeLessThan(300); + }); +}); + +describe('a summary on loan', () => { + async function missedTwo(rig: Rig): Promise { + rig.server.addEvent(event()); + rig.server.addEvent(event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + await syncedAndWatching(rig); + await justBefore(rig); + rig.now = FIRE + 60 * MIN; + await rig.tick(); + return rig.call('reminders.takeMissed', PARAMS); + } + + it('comes back when whoever took it never said it was shown: a crash, a reload', async () => { + const rig = setup(); + const batch = await missedTwo(rig); + expect(batch.alerts).toHaveLength(2); + rig.now += MIN; + expect((await rig.tick()).missed).toBe(0); + rig.now += 5 * MIN; + expect((await rig.tick()).missed).toBe(2); + }); + + it('stays while the window showing it keeps saying so', async () => { + const rig = setup(); + const batch = await missedTwo(rig); + for (let i = 0; i < 6; i += 1) { + rig.now += MIN; + await rig.call('reminders.renewMissed', { batch: batch.batch }); + expect((await rig.tick()).missed).toBe(0); + } + expect(await rig.call('reminders.batch', { batch: batch.batch })).toHaveLength(2); + }); + + it('is done once a native notification showed it, and can still open later', async () => { + const rig = setup(); + const batch = await missedTwo(rig); + await rig.call('reminders.settleMissed', { batch: batch.batch }); + rig.now += 60 * MIN; + expect((await rig.tick()).missed).toBe(0); + expect(await rig.call('reminders.batch', { batch: batch.batch })).toHaveLength(2); + }); + + it('does not come back once dismissed', async () => { + const rig = setup(); + const batch = await missedTwo(rig); + await rig.call('reminders.dismiss', { keys: batch.alerts.map((alert) => alert.key) }); + rig.now += 60 * MIN; + expect((await rig.tick()).missed).toBe(0); + }); +}); + +describe('muted', () => { + it('asks for no summary of what already waited', async () => { + const rig = setup(); + rig.server.addEvent(event()); + rig.server.addEvent(event({ id: 'e2', uid: 'uid-2', title: 'Review' })); + await syncedAndWatching(rig); + await justBefore(rig); + rig.now = FIRE + 60 * MIN; + expect((await rig.tick()).missed).toBe(2); + rig.now += MIN; + expect((await rig.tick({ muted: true })).missed).toBe(0); + }); +}); diff --git a/lib/store/__tests__/reminders.test.ts b/lib/store/__tests__/reminders.test.ts new file mode 100644 index 000000000..8974b9050 --- /dev/null +++ b/lib/store/__tests__/reminders.test.ts @@ -0,0 +1,394 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { DEFAULT_SCOPE } from '../area'; +import { calendarArea } from '../calendar'; +import { AccountEngine } from '../engine/account-engine'; +import { remindersArea } from '../reminders'; +import { FRESH_MS, MISSED_REACH_MS, type MissedBatch, type TickResult } from '../reminders/ledger'; +import type { StorageAdapter } from '../storage'; +import { CALENDAR_SESSION, FakeCalendarJmap, type StalwartVersion } from './helpers/fake-calendar'; +import { openMemoryStore } from './helpers/node-adapter'; + +const MIN = 60_000; +/** 10:00 in Berlin on 2026-11-10, a Tuesday. The events' alert goes 15 minutes before. */ +const START = Date.UTC(2026, 10, 10, 9); +const FIRE = START - 15 * MIN; +const ALERT = { '@type': 'Alert', action: 'display', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } }; +const WEEKLY = { '@type': 'RecurrenceRule', frequency: 'weekly' }; +const PARAMS = { timeZone: 'Europe/Berlin', tasks: true }; + +interface Rig { + server: FakeCalendarJmap; + db: StorageAdapter; + engine: AccountEngine; + now: number; + /** What a tab, a window or the next start of the app asks. */ + tick(params?: Partial & { muted?: boolean }): Promise; + call(name: string, params?: unknown): Promise; + restart(): void; +} + +function makeEngine(rig: Pick): AccountEngine { + return new AccountEngine({ + account: 'a@fake.test', + db: rig.db, + session: CALENDAR_SESSION, + transport: rig.server, + areas: [calendarArea, remindersArea], + scope: DEFAULT_SCOPE, + now: () => rig.now, + sleep: async () => {}, + }); +} + +function setup(version: StalwartVersion = '1.0', startAt = FIRE - 60 * MIN): Rig { + const rig = { + server: new FakeCalendarJmap(version), + db: openMemoryStore(), + now: startAt, + } as Rig; + rig.engine = makeEngine(rig); + rig.call = (name: string, params?: unknown) => rig.engine.areaCall(name, params) as Promise; + rig.tick = (params = {}) => rig.call('reminders.tick', { ...PARAMS, ...params }); + rig.restart = () => { + rig.engine = makeEngine(rig); + }; + return rig; +} + +/** Synced, with a first check done, so the store has been watching since before the alert. */ +async function synced(rig: Rig): Promise { + await rig.engine.requestSync(); + await rig.tick(); + return rig; +} + +function event(partial: Record = {}) { + return { id: 'e1', uid: 'uid-1', title: 'Standup', start: '2026-11-10T10:00:00', timeZone: 'Europe/Berlin', duration: 'PT30M', alerts: { a: ALERT }, ...partial }; +} + +/** Comes back every minute or so, with no gap that would look like being away. */ +async function walkTo(rig: Rig, to: number): Promise { + const due: TickResult['due'] = []; + while (rig.now < to) { + rig.now = Math.min(rig.now + MIN, to); + due.push(...(await rig.tick()).due); + } + return due; +} + +describe('firing', () => { + it('fires from what is stored with the network gone', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + rig.engine.setOnline(false); + rig.server.request = (() => { + throw new Error('Nothing may reach the server for a reminder'); + }) as unknown as FakeCalendarJmap['request']; + const due = await walkTo(rig, FIRE); + expect(due.map((alert) => [alert.title, alert.fireTimeMs, alert.startMs])).toEqual([['Standup', FIRE, START]]); + }); + + it('does not fire early, and says when to ask next', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + rig.now = FIRE - 90_000; + const before = await rig.tick(); + expect(before.due).toEqual([]); + expect(before.nextAt).toBe(FIRE); + }); + + it('gives an alert to one caller only, across tabs and restarts', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + rig.now = FIRE - MIN; + await rig.tick(); + rig.now = FIRE; + const [first, second] = await Promise.all([rig.tick(), rig.tick()]); + expect([first.due.length, second.due.length]).toEqual([1, 0]); + rig.restart(); + rig.now = FIRE + MIN; + expect((await rig.tick()).due).toEqual([]); + }); + + it('expands a series itself: every occurrence fires, under its own key', async () => { + for (const version of ['1.0', '0.16'] as const) { + const rig = setup(version); + rig.server.addEvent(event({ start: '2026-10-06T10:00:00', recurrenceRule: WEEKLY })); + await synced(rig); + const first = await walkTo(rig, FIRE); + const week = 7 * 24 * 60 * MIN; + rig.now = FIRE + week - MIN; + await rig.tick(); + await rig.tick(); + rig.now = FIRE + week; + const second = (await rig.tick()).due; + expect(first.map((alert) => alert.fireTimeMs)).toEqual([FIRE]); + expect(second.map((alert) => alert.fireTimeMs)).toEqual([FIRE + week]); + expect(second[0].baseEventId).toBe('e1'); + expect(second[0].key).not.toBe(first[0].key); + expect(second[0].recurrenceId).toBeTruthy(); + } + }); + + it('keeps one key when a series gets an override, so nothing fires twice', async () => { + const rig = setup(); + rig.server.addEvent(event({ start: '2026-10-06T10:00:00', recurrenceRule: WEEKLY })); + await synced(rig); + const [before] = await walkTo(rig, FIRE); + // Another occurrence changes. On a server with synthetic ids this reshuffles them. + rig.server.patchEvent('e1', { recurrenceOverrides: { '2026-11-17T10:00:00': { title: 'Other' } } }); + expect(before.key).toBe(`${before.jmap}:uid-1:a:${FIRE}`); + await rig.engine.requestSync(); + rig.now = FIRE + MIN; + expect((await rig.tick()).due).toEqual([]); + }); + + it('reads `acknowledged` on a series as covering what fired up to then', async () => { + const rig = setup(); + const week = 7 * 24 * 60 * MIN; + rig.server.addEvent(event({ + start: '2026-10-06T10:00:00', + recurrenceRule: WEEKLY, + alerts: { a: { ...ALERT, acknowledged: new Date(FIRE + 60_000).toISOString() } }, + })); + await synced(rig); + expect(await walkTo(rig, FIRE + 2 * MIN)).toEqual([]); + rig.now = FIRE + week - MIN; + await rig.tick(); + await rig.tick(); + rig.now = FIRE + week; + expect((await rig.tick()).due.map((alert) => alert.fireTimeMs)).toEqual([FIRE + week]); + }); + + it('uses the calendar defaults while the event does, and leaves tasks out when asked', async () => { + const rig = setup('0.16'); + rig.server.calendars.get('cal1')!.defaultAlertsWithTime = { d1: ALERT }; + rig.server.addEvent(event({ id: 'def', uid: 'def', useDefaultAlerts: true, alerts: null })); + rig.server.addEvent({ id: 'task', uid: 'task', '@type': 'Task', title: 'Pay', start: undefined, due: '2026-11-10T09:00:00Z', alerts: { a: ALERT } }); + await rig.engine.requestSync(); + rig.now = FIRE - MIN; + await rig.tick({ tasks: false }); + rig.now = FIRE; + const due = (await rig.tick({ tasks: false })).due; + expect(due.map((alert) => [alert.baseEventId, alert.alertId])).toEqual([['def', 'd1']]); + }); + + it('holds a join link only for an event that has one', async () => { + const rig = setup(); + rig.server.addEvent(event({ virtualLocations: { v: { '@type': 'VirtualLocation', uri: 'https://meet.jit.si/standup' } } })); + rig.server.addEvent(event({ id: 'e2', uid: 'uid-2', title: 'Lunch', locations: { l: { '@type': 'Location', name: 'Cafe' } } })); + await synced(rig); + const due = await walkTo(rig, FIRE); + expect(Object.fromEntries(due.map((alert) => [alert.title, alert.joinUrl]))).toEqual({ + Standup: 'https://meet.jit.si/standup', + Lunch: null, + }); + }); +}); + +describe('away', () => { + async function awayWithThree(): Promise { + const rig = setup(); + rig.server.addEvent(event()); + rig.server.addEvent(event({ id: 'e2', uid: 'uid-2', title: 'Review', start: '2026-11-10T11:00:00' })); + rig.server.addEvent(event({ id: 'e3', uid: 'uid-3', title: 'Lunch', start: '2026-11-10T12:00:00' })); + await synced(rig); + rig.now = FIRE - MIN; + await rig.tick(); + return rig; + } + + it('sends what came due while away to one summary, not a burst, and only once', async () => { + const rig = await awayWithThree(); + rig.restart(); + rig.now = FIRE + 3 * 60 * MIN; + const back = await rig.tick(); + expect(back.due).toEqual([]); + expect(back.missed).toBe(3); + const [one, two] = await Promise.all([ + rig.call('reminders.takeMissed', PARAMS), + rig.call('reminders.takeMissed', PARAMS), + ]); + expect(one!.alerts.map((alert) => alert.title)).toEqual(['Standup', 'Review', 'Lunch']); + expect(two).toBeNull(); + rig.restart(); + rig.now += MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 0 }); + expect(await rig.call('reminders.takeMissed', PARAMS)).toBeNull(); + }); + + it('summarises the ones within ten minutes as well: a wake-up is no time for a burst', async () => { + const rig = await awayWithThree(); + rig.now = START + 2 * 60 * MIN - 15 * MIN + 2 * MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 3 }); + }); + + it('shows a single alert that just came due as a normal reminder', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + rig.now = FIRE - 2 * MIN; + await rig.tick(); + // Over the limit for being away, and under ten minutes old. + rig.now = FIRE + 5 * MIN; + expect(await rig.tick()).toMatchObject({ due: [{ title: 'Standup' }], missed: 0 }); + }); + + it('summarises a single alert that is long past', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + rig.now = FIRE - MIN; + await rig.tick(); + rig.now = FIRE + FRESH_MS + MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 1 }); + }); + + it('counts nothing as missed on the very first check, however old the calendar', async () => { + const rig = setup('1.0', FIRE + 60 * MIN); + rig.server.addEvent(event()); + await rig.engine.requestSync(); + expect(await rig.tick()).toMatchObject({ due: [], missed: 0 }); + }); + + it('reaches back a week at most', async () => { + const rig = await awayWithThree(); + rig.now = FIRE + MISSED_REACH_MS + 3 * 60 * MIN; + expect((await rig.tick()).missed).toBe(0); + }); + + it('leaves out what was deleted while away', async () => { + const rig = await awayWithThree(); + rig.now = FIRE + 3 * 60 * MIN; + expect((await rig.tick()).missed).toBe(3); + rig.server.removeEvent('e2'); + await rig.engine.requestSync(); + const batch = await rig.call('reminders.takeMissed', PARAMS); + expect(batch.alerts.map((alert) => alert.title)).toEqual(['Standup', 'Lunch']); + }); + + it('puts a summary that could not be shown back, and reads a shown one again for a late click', async () => { + const rig = await awayWithThree(); + rig.now = FIRE + 3 * 60 * MIN; + await rig.tick(); + const refused = await rig.call('reminders.takeMissed', PARAMS); + await rig.call('reminders.releaseMissed', { batch: refused.batch }); + expect((await rig.tick()).missed).toBe(3); + const shown = await rig.call('reminders.takeMissed', PARAMS); + expect(shown.alerts).toHaveLength(3); + rig.restart(); + const read = await rig.call('reminders.batch', { batch: shown.batch }); + expect(read).toHaveLength(3); + await rig.call('reminders.dismiss', { keys: shown.alerts.map((alert) => alert.key) }); + expect(await rig.call('reminders.batch', { batch: shown.batch })).toEqual([]); + }); + + it('moves a fired alert nobody could be told about to the summary', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + const [fired] = await walkTo(rig, FIRE); + await rig.call('reminders.hold', { keys: [fired.key] }); + rig.now += MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 1 }); + }); + + it('keeps quiet while reminders are off, and nothing from that time is missed later', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + rig.now = FIRE - MIN; + await rig.tick(); + rig.now = FIRE + 60 * MIN; + expect(await rig.tick({ muted: true })).toMatchObject({ due: [], missed: 0, nextAt: null }); + rig.now += MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 0 }); + }); +}); + +describe('snooze', () => { + async function fired(version: StalwartVersion = '1.0') { + const rig = setup(version); + rig.server.addEvent(event()); + await synced(rig); + const [alert] = await walkTo(rig, FIRE); + return { rig, alert }; + } + + it('comes back after the minutes asked for, once, and survives a restart', async () => { + const { rig, alert } = await fired(); + expect(await rig.call('reminders.snooze', { key: alert.key, minutes: 5 })).toBe(true); + rig.now = FIRE + 2 * MIN; + const during = await rig.tick(); + expect(during.due).toEqual([]); + expect(during.nextAt).toBe(FIRE + 5 * MIN); + + rig.restart(); + rig.now = FIRE + 4 * MIN; + expect((await rig.tick()).due).toEqual([]); + rig.now = FIRE + 5 * MIN; + const back = (await rig.tick()).due; + expect(back.map((a) => [a.title, a.snoozedUntil])).toEqual([['Standup', FIRE + 5 * MIN]]); + rig.now += MIN; + expect((await rig.tick()).due).toEqual([]); + }); + + it('can be snoozed again', async () => { + const { rig, alert } = await fired(); + await rig.call('reminders.snooze', { key: alert.key, minutes: 5 }); + rig.now = FIRE + 5 * MIN; + expect((await rig.tick()).due).toHaveLength(1); + await rig.call('reminders.snooze', { key: alert.key, minutes: 10 }); + rig.now = FIRE + 15 * MIN; + const back = (await rig.tick()).due; + expect(back[0].snoozedUntil).toBe(FIRE + 15 * MIN); + }); + + it('goes to the summary when it ran out while the app was quit', async () => { + const { rig, alert } = await fired(); + await rig.call('reminders.snooze', { key: alert.key, minutes: 5 }); + rig.restart(); + rig.now = FIRE + 3 * 60 * MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 1 }); + }); + + it('does not come back for an event deleted in the meantime', async () => { + const { rig, alert } = await fired(); + await rig.call('reminders.snooze', { key: alert.key, minutes: 5 }); + rig.server.removeEvent('e1'); + await rig.engine.requestSync(); + rig.now = FIRE + 5 * MIN; + expect(await rig.tick()).toMatchObject({ due: [], missed: 0 }); + }); + + it('does not come back once dismissed in another window', async () => { + const { rig, alert } = await fired(); + await rig.call('reminders.snooze', { key: alert.key, minutes: 5 }); + await rig.call('reminders.dismiss', { keys: [alert.key] }); + rig.now = FIRE + 5 * MIN; + expect(await rig.tick()).toMatchObject({ due: [], nextAt: null }); + }); + + it('refuses an unknown reminder and silly minutes', async () => { + const { rig, alert } = await fired(); + expect(await rig.call('reminders.snooze', { key: 'nope', minutes: 5 })).toBe(false); + expect(await rig.call('reminders.snooze', { key: alert.key, minutes: -1 })).toBe(false); + expect(await rig.call('reminders.snooze', { key: alert.key, minutes: 99_999 })).toBe(false); + }); +}); + +it('keeps reminders off the server', async () => { + const rig = setup(); + rig.server.addEvent(event()); + await synced(rig); + const before = JSON.stringify(rig.server.events.get('e1')); + const [alert] = await walkTo(rig, FIRE); + await rig.call('reminders.snooze', { key: alert.key, minutes: 5 }); + await rig.call('reminders.dismiss', { keys: [alert.key] }); + await rig.engine.requestSync(); + expect(JSON.stringify(rig.server.events.get('e1'))).toBe(before); +}); diff --git a/lib/store/areas.ts b/lib/store/areas.ts index 4155c1487..a863cc3a9 100644 --- a/lib/store/areas.ts +++ b/lib/store/areas.ts @@ -6,5 +6,6 @@ import { contactsArea } from './contacts'; import { filesArea } from './files'; import { mailArea } from './mail'; import { outboxArea } from './outbox'; +import { remindersArea } from './reminders'; -export const STORE_AREAS: readonly StoreArea[] = [mailArea, outboxArea, calendarArea, contactsArea, filesArea]; +export const STORE_AREAS: readonly StoreArea[] = [mailArea, outboxArea, calendarArea, contactsArea, filesArea, remindersArea]; diff --git a/lib/store/calendar/reminders.ts b/lib/store/calendar/reminders.ts index b42307346..80f431328 100644 --- a/lib/store/calendar/reminders.ts +++ b/lib/store/calendar/reminders.ts @@ -12,16 +12,19 @@ import { format, parseISO } from 'date-fns'; import { buildAlertKey, computeFireTime, computeTaskFireTime, getEffectiveAlerts, parseAlertOffset } from '@/lib/calendar-alerts'; import { isTaskLikeObject } from '@/lib/calendar-component-detection'; import { normalizeCalendarEventLike, parseDurationSeconds } from '@/lib/calendar-event-normalization'; +import { findJoinLink, type EventPlaces } from '@/lib/event-links'; import type { Calendar, CalendarEvent, CalendarEventAlert, CalendarTask } from '@/lib/jmap/types'; import { expandRecurringEvents } from '@/lib/recurrence-expansion'; import { listObjects } from '../engine/core-schema'; import type { StorageAdapter } from '../storage'; -import { instantsOf, isSeries, type CalendarObject } from './records'; +import { instantOf, instantsOf, isSeries, type CalendarObject } from './records'; export interface UpcomingAlert { /** Stable for this alert at this time. Use it to remember what was shown. */ key: string; jmap: string; + /** The series' uid, or the id where there's none: the same one whichever path shows the reminder. */ + uid: string; kind: 'event' | 'task'; /** The stored event or task: the master of a series. */ baseEventId: string; @@ -32,6 +35,12 @@ export interface UpcomingAlert { title: string; /** The start as a wall clock, or a task's due when it has no start. */ start: string | null; + /** The start as an instant, to say how long until it. Null when it can't be told. */ + startMs: number | null; + /** Joins the meeting, when the event has a video link. Web links only. */ + joinUrl: string | null; + /** Where that goes, when it's no service this app knows. */ + joinHost: string | null; timeZone: string | null; calendarId: string | null; calendarName: string | null; @@ -44,23 +53,68 @@ export interface UpcomingAlertsOptions { to: number; /** Reads floating events. The user's zone. */ timeZone: string; + /** Which account's copy to use for a meeting that's in several. */ + prefer?: string; } type AlertMap = Record | null | undefined; +/** Anyone asking to be reminded further off than this has made a typing error, and expanding that far has no end. */ +const LONGEST_OFFSET_MS = 366 * 24 * 3_600_000; + // An alert fires up to its offset away from its occurrence, and the occurrence's // wall clock is up to 26 hours from the instant. Expand wide, filter by instant. function offsetReachMs(alerts: AlertMap): number { let reach = 0; for (const alert of Object.values(alerts ?? {})) { - if (alert.trigger?.['@type'] === 'AbsoluteTrigger') continue; - reach = Math.max(reach, Math.abs(parseAlertOffset(alert.trigger?.offset ?? '') ?? 0)); + if (!alert?.trigger || alert.trigger['@type'] === 'AbsoluteTrigger') continue; + const offset = Math.abs(parseAlertOffset(alert.trigger.offset ?? '') ?? 0); + if (offset <= LONGEST_OFFSET_MS) reach = Math.max(reach, offset); } return reach; } +/** What can fire at all. The server keeps whatever a client wrote, so none of this is a given. */ +function usableAlerts(alerts: AlertMap): Array<[string, CalendarEventAlert]> { + return Object.entries(alerts ?? {}).filter(([, alert]) => { + if (!alert?.trigger || alert.action !== 'display') return false; + if (alert.trigger['@type'] === 'AbsoluteTrigger') return true; + return Math.abs(parseAlertOffset(alert.trigger.offset ?? '') ?? Infinity) <= LONGEST_OFFSET_MS; + }); +} + +/** Keeps keys, and the toast that hands one back, a sensible size. Not for secrecy. */ +function bounded(value: string, limit: number): string { + if (value.length <= limit) return value; + let h1 = 0xdeadbeef; + let h2 = 0x41c6ce57; + for (let i = 0; i < value.length; i += 1) { + const code = value.charCodeAt(i); + h1 = Math.imul(h1 ^ code, 2654435761); + h2 = Math.imul(h2 ^ code, 1597334677); + } + return `#${(h1 >>> 0).toString(36)}${(h2 >>> 0).toString(36)}:${value.length}`; +} + +/** + * A fixed time on a series is for the occurrence that follows it. When that one was + * excluded there's nothing to remind of, and no later occurrence takes it over. + */ +function fixedAlertForExcluded(master: CalendarEvent, fireTimeMs: number, occurrences: CalendarEvent[], zone: string): boolean { + const excluded = Object.entries(master.recurrenceOverrides ?? {}) + .filter(([, override]) => (override as { excluded?: boolean }).excluded) + .map(([at]) => instantOf(at, master.timeZone ?? zone)) + .filter((at): at is number => at !== null && at >= fireTimeMs); + if (excluded.length === 0) return false; + const nextExcluded = Math.min(...excluded); + return !occurrences.some((occurrence) => { + const at = instantOf(occurrence.start, occurrence.timeZone ?? zone); + return at !== null && at >= fireTimeMs && at < nextExcluded; + }); +} + function alertsInOverrides(event: CalendarEvent): AlertMap[] { - return Object.values(event.recurrenceOverrides ?? {}).map((override) => (override as { alerts?: AlertMap }).alerts); + return Object.values(event.recurrenceOverrides ?? {}).map((override) => (override as { alerts?: AlertMap } | null)?.alerts); } const ZONE_REACH_MS = 27 * 3_600_000; @@ -82,82 +136,139 @@ function withInstants(object: CalendarObject, zone: string): CalendarObject { }; } +/** + * RFC 8984 4.5.2: on a series `acknowledged` sits on the master and covers what fired + * up to then. Read as "never again", one dismissal in another app would silence every + * later occurrence. An event once is done with it either way. + */ +function acknowledgedAlready(alert: CalendarEventAlert, fireTimeMs: number, series: boolean): boolean { + if (!alert.acknowledged) return false; + if (!series) return true; + const at = new Date(alert.acknowledged).getTime(); + return Number.isNaN(at) || fireTimeMs <= at; +} + +function instant(value: string | null | undefined): number | null { + if (!value) return null; + const ms = new Date(value).getTime(); + return Number.isNaN(ms) ? null : ms; +} + function firstCalendar(calendarIds: unknown, calendars: Calendar[]): { id: string | null; name: string | null } { const id = Object.keys((calendarIds ?? {}) as Record)[0] ?? null; return { id, name: calendars.find((calendar) => calendar.id === id)?.name ?? null }; } -/** Every `display` alert of the stored events and tasks that fires in the window, soonest first. */ +/** + * Someone else's calendar on 1.0 is unsubscribed until the user adds it, and an account + * can be shared without anyone asking for its reminders. Says nothing: it counts. + */ +function inSubscribedCalendar(calendarIds: unknown, calendars: Calendar[]): boolean { + const ids = Object.keys((calendarIds ?? {}) as Record); + return ids.length === 0 || ids.some((id) => calendars.find((calendar) => calendar.id === id)?.isSubscribed !== false); +} + +function matchOf(alert: Pick): string { + return `${alert.uid}:${alert.fireTimeMs}`; +} + +/** Every `display` alert of the stored events and tasks that fires in the window, soonest first. One event that can't be read is left out, not the rest. */ export function listUpcomingAlerts(db: StorageAdapter, options: UpcomingAlertsOptions): UpcomingAlert[] { + // By uid and time, not by account: one meeting in the own and a shared calendar rings once. const found = new Map(); const add = (alert: UpcomingAlert) => { - if (alert.fireTimeMs >= options.from && alert.fireTimeMs < options.to) found.set(alert.key, alert); + if (alert.fireTimeMs < options.from || alert.fireTimeMs >= options.to) return; + const match = matchOf(alert); + const held = found.get(match); + if (!held || (alert.jmap === options.prefer && held.jmap !== options.prefer)) found.set(match, alert); }; const rows = db.all<{ jmap: string; json: string }>('SELECT jmap, json FROM calendar_event WHERE hidden = 0'); const calendarsOf = new Map(); for (const row of rows) { if (!calendarsOf.has(row.jmap)) calendarsOf.set(row.jmap, listObjects(db, row.jmap, 'Calendar')); - const calendars = calendarsOf.get(row.jmap)!; - const object = JSON.parse(row.json) as CalendarObject; - const calendar = firstCalendar(object.calendarIds, calendars); - // With the account: Stalwart's ids repeat across accounts, and one meeting can be in two. - const stableId = `${row.jmap}:${typeof object.uid === 'string' && object.uid ? object.uid : object.id}`; - - if (isTaskLikeObject(object)) { - const task = object as unknown as CalendarTask; - if (task.progress === 'completed' || task.progress === 'cancelled') continue; - for (const [alertId, alert] of Object.entries(task.alerts ?? {})) { - if (alert.action !== 'display' || alert.acknowledged) continue; - const fireTimeMs = computeTaskFireTime(task, alert.trigger); - if (fireTimeMs === null) continue; - add({ - key: buildAlertKey(stableId, alertId, fireTimeMs), - jmap: row.jmap, - kind: 'task', - baseEventId: object.id, - recurrenceId: null, - alertId, - fireTimeMs, - title: task.title ?? '', - start: task.start ?? task.due ?? null, - timeZone: task.timeZone ?? null, - calendarId: calendar.id, - calendarName: calendar.name, - }); - } - continue; - } - - const master = normalizeCalendarEventLike(withInstants(object, options.timeZone) as unknown as CalendarEvent); - if (typeof master.start !== 'string' || Number.isNaN(parseISO(master.start).getTime())) continue; - const series = isSeries(object); - const occurrences = series ? occurrencesNear(master, calendars, options) : [master]; - // A fixed time doesn't need an occurrence nearby. - const candidates = series ? [master, ...occurrences] : occurrences; - for (const occurrence of candidates) { - if (occurrence.status === 'cancelled') continue; - const isMasterOfSeries = series && occurrence === master; - for (const [alertId, alert] of Object.entries(getEffectiveAlerts(occurrence, calendars) ?? {})) { - if (alert.action !== 'display' || alert.acknowledged) continue; - if (isMasterOfSeries && alert.trigger['@type'] !== 'AbsoluteTrigger') continue; - const fireTimeMs = computeFireTime(occurrence, alert.trigger); - if (fireTimeMs === null) continue; - add({ - key: buildAlertKey(stableId, alertId, fireTimeMs), - jmap: row.jmap, - kind: 'event', - baseEventId: object.id, - recurrenceId: occurrence.recurrenceId ?? null, - alertId, - fireTimeMs, - title: occurrence.title ?? '', - start: occurrence.start, - timeZone: occurrence.timeZone ?? null, - calendarId: calendar.id, - calendarName: calendar.name, - }); - } + try { + addAlertsOf(row, calendarsOf.get(row.jmap)!, options, add); + } catch { + // Whatever is wrong with this one, the others still have to ring. } } return [...found.values()].sort((a, b) => a.fireTimeMs - b.fireTimeMs || a.key.localeCompare(b.key)); } + +function addAlertsOf(row: { jmap: string; json: string }, calendars: Calendar[], options: UpcomingAlertsOptions, add: (alert: UpcomingAlert) => void): void { + const object = JSON.parse(row.json) as CalendarObject; + if (!inSubscribedCalendar(object.calendarIds, calendars)) return; + const calendar = firstCalendar(object.calendarIds, calendars); + const uid = typeof object.uid === 'string' && object.uid ? object.uid : object.id; + // With the account: Stalwart's ids repeat across accounts. + const stableId = `${row.jmap}:${bounded(uid, 200)}`; + + if (isTaskLikeObject(object)) { + const task = object as unknown as CalendarTask; + if (task.progress === 'completed' || task.progress === 'cancelled') return; + for (const [alertId, alert] of usableAlerts(task.alerts)) { + if (alert.acknowledged) continue; + const fireTimeMs = computeTaskFireTime(task, alert.trigger); + if (fireTimeMs === null) continue; + const join = findJoinLink(task as unknown as EventPlaces); + add({ + key: buildAlertKey(stableId, bounded(alertId, 64), fireTimeMs), + jmap: row.jmap, + uid, + kind: 'task', + baseEventId: object.id, + recurrenceId: null, + alertId, + fireTimeMs, + title: task.title ?? '', + start: task.start ?? task.due ?? null, + startMs: instant(task.start ?? task.due), + joinUrl: join?.url ?? null, + joinHost: join?.host ?? null, + timeZone: task.timeZone ?? null, + calendarId: calendar.id, + calendarName: calendar.name, + }); + } + return; + } + + const master = normalizeCalendarEventLike(withInstants(object, options.timeZone) as unknown as CalendarEvent); + if (typeof master.start !== 'string' || Number.isNaN(parseISO(master.start).getTime())) return; + const series = isSeries(object); + // Expansion reads an all-day or floating occurrence in this machine's zone. The setting decides. + const occurrences = series + ? occurrencesNear(master, calendars, options).map((occurrence) => withInstants(occurrence as unknown as CalendarObject, options.timeZone) as unknown as CalendarEvent) + : [master]; + // A fixed time doesn't need an occurrence nearby. + const candidates = series ? [master, ...occurrences] : occurrences; + for (const occurrence of candidates) { + if (occurrence.status === 'cancelled') continue; + const isMasterOfSeries = series && occurrence === master; + for (const [alertId, alert] of usableAlerts(getEffectiveAlerts(occurrence, calendars))) { + if (isMasterOfSeries && alert.trigger['@type'] !== 'AbsoluteTrigger') continue; + const fireTimeMs = computeFireTime(occurrence, alert.trigger); + if (fireTimeMs === null || acknowledgedAlready(alert, fireTimeMs, series)) continue; + if (series && alert.trigger['@type'] === 'AbsoluteTrigger' && fixedAlertForExcluded(master, fireTimeMs, occurrences, options.timeZone)) continue; + const join = findJoinLink(occurrence as unknown as EventPlaces); + add({ + key: buildAlertKey(stableId, bounded(alertId, 64), fireTimeMs), + jmap: row.jmap, + uid, + kind: 'event', + baseEventId: object.id, + recurrenceId: occurrence.recurrenceId ?? null, + alertId, + fireTimeMs, + title: occurrence.title ?? '', + start: occurrence.start, + startMs: instant(occurrence.utcStart) ?? instant(occurrence.start), + joinUrl: join?.url ?? null, + joinHost: join?.host ?? null, + timeZone: occurrence.timeZone ?? null, + calendarId: calendar.id, + calendarName: calendar.name, + }); + } + } +} diff --git a/lib/store/mail/attachments.ts b/lib/store/mail/attachments.ts index 12feba84a..4b2fbc34d 100644 --- a/lib/store/mail/attachments.ts +++ b/lib/store/mail/attachments.ts @@ -169,6 +169,31 @@ export function rawBlob(ctx: Pick, jmap: string, b return { ok: true, type: RAW_TYPE, size: kept.size, blob: new Blob([kept.blob], { type: RAW_TYPE }) }; } +/** Which of these whole messages are here, so an online export asks the server only for the rest. */ +export function rawKept(ctx: Pick, jmap: string, blobIds: readonly string[]): string[] { + if (!ctx.scope.attachments || blobIds.length === 0) return []; + const marks = blobIds.map(() => '?').join(', '); + return ctx.db.all<{ blob_id: string }>( + `SELECT DISTINCT p.blob_id FROM email_part p JOIN blob b ON b.jmap = p.jmap AND b.blob_id = p.blob_id + WHERE p.jmap = ? AND p.raw = 1 AND p.blob_id IN (${marks})`, + [jmap, ...blobIds], + ).map((row) => String(row.blob_id)); +} + +/** How large the originals of a folder are, and the part of that kept here: what an export will need room for. */ +export function rawSize(ctx: Pick, jmap: string, mailboxId?: string): { bytes: number; keptBytes: number } { + const inFolder = mailboxId ? 'JOIN email_mailbox m ON m.email_rowid = e.rowid AND m.mailbox_id = ?' : ''; + const row = ctx.db.get<{ bytes: number | null; kept: number | null }>( + `SELECT SUM(p.size) AS bytes, SUM(CASE WHEN b.blob_id IS NULL THEN 0 ELSE p.size END) AS kept + FROM email e ${inFolder} + JOIN email_part p ON p.email_rowid = e.rowid AND p.raw = 1 + LEFT JOIN blob b ON b.jmap = p.jmap AND b.blob_id = p.blob_id + WHERE e.jmap = ?`, + mailboxId ? [mailboxId, jmap] : [jmap], + ); + return { bytes: Number(row?.bytes ?? 0), keptBytes: ctx.scope.attachments ? Number(row?.kept ?? 0) : 0 }; +} + /** How many messages (of one folder, or all) an export could write right now. */ export function rawStatus(ctx: Pick, jmap: string, mailboxId?: string): { total: number; kept: number } { const inFolder = mailboxId ? 'JOIN email_mailbox m ON m.email_rowid = e.rowid AND m.mailbox_id = ?' : ''; diff --git a/lib/store/mail/calls.ts b/lib/store/mail/calls.ts index b8202c86e..0169eceff 100644 --- a/lib/store/mail/calls.ts +++ b/lib/store/mail/calls.ts @@ -1,7 +1,7 @@ /** What the page asks the mail part of the store outside JMAP. */ import type { AreaCall, StoreContext } from '../area'; -import { keptBlob, rawBlob, rawStatus, recordKeptBlob } from './attachments'; +import { keptBlob, rawBlob, rawKept, rawSize, rawStatus, recordKeptBlob } from './attachments'; import { storeBody, upsertEmail, type EmailObject } from './records'; /** @@ -55,8 +55,12 @@ export const MAIL_CALLS: Readonly> = { 'mail.getBlob': getBlob, /** A whole message for an offline export, or why not. */ 'mail.rawBlob': (ctx, params: { jmap: string; blobId: string }) => rawBlob(ctx, params.jmap, params.blobId), + /** Which of a batch are here: an online export fetches the others in one request. */ + 'mail.rawKept': (ctx, params: { jmap: string; blobIds: string[] }) => rawKept(ctx, params.jmap, params.blobIds), /** How many messages an export could write now, so it can say how many it can't. */ 'mail.rawStatus': (ctx, params: { jmap: string; mailboxId?: string }) => rawStatus(ctx, params.jmap, params.mailboxId), + /** How much room an export of them takes. */ + 'mail.rawSize': (ctx, params: { jmap: string; mailboxId?: string }) => rawSize(ctx, params.jmap, params.mailboxId), 'mail.usage': usage, /** The session kept with the account, for a start from the local copy. */ 'mail.session': (ctx) => ctx.session, diff --git a/lib/store/reminders/index.ts b/lib/store/reminders/index.ts new file mode 100644 index 000000000..28d27e738 --- /dev/null +++ b/lib/store/reminders/index.ts @@ -0,0 +1,29 @@ +import type { StoreArea, StoreContext } from '../area'; +import { dismiss, holdFired, readBatch, readReminder, releaseMissed, renewMissed, settleMissed, snooze, takeMissed, tick, type TickParams } from './ledger'; +import { REMINDER_MIGRATIONS } from './schema'; + +const CALENDARS = 'urn:ietf:params:jmap:calendars'; + +/** For a meeting in the own and a shared calendar: the own copy. */ +function primaryAccount(ctx: Pick): string | undefined { + return ctx.session.primaryAccounts[CALENDARS]; +} + +export const remindersArea: StoreArea = { + name: 'reminders', + migrations: REMINDER_MIGRATIONS, + methods: {}, + syncers: [], + calls: { + 'reminders.tick': (ctx, params: TickParams) => tick(ctx.db, ctx.now(), { ...params, prefer: primaryAccount(ctx) }), + 'reminders.takeMissed': (ctx, params: { timeZone: string }) => takeMissed(ctx.db, ctx.now(), { ...params, prefer: primaryAccount(ctx) }), + 'reminders.releaseMissed': (ctx, params: { batch: number }) => releaseMissed(ctx.db, ctx.now(), params.batch), + 'reminders.renewMissed': (ctx, params: { batch: number }) => renewMissed(ctx.db, ctx.now(), params.batch), + 'reminders.settleMissed': (ctx, params: { batch: number }) => settleMissed(ctx.db, ctx.now(), params.batch), + 'reminders.batch': (ctx, params: { batch: number }) => readBatch(ctx.db, params.batch), + 'reminders.hold': (ctx, params: { keys: string[] }) => holdFired(ctx.db, ctx.now(), params.keys), + 'reminders.snooze': (ctx, params: { key: string; minutes: number }) => snooze(ctx.db, ctx.now(), params.key, params.minutes), + 'reminders.dismiss': (ctx, params: { keys: string[] }) => dismiss(ctx.db, ctx.now(), params.keys), + 'reminders.get': (ctx, params: { key: string }) => readReminder(ctx.db, params.key), + }, +}; diff --git a/lib/store/reminders/ledger.ts b/lib/store/reminders/ledger.ts new file mode 100644 index 000000000..35b81ea33 --- /dev/null +++ b/lib/store/reminders/ledger.ts @@ -0,0 +1,255 @@ +/** + * What has become of each reminder on this device: the one place that decides what + * fires. Every call is one synchronous transaction in the store's worker, and all tabs + * and windows reach the same worker, so whichever asks first gets an alert and nobody + * else ever does, across restarts too. localStorage can't do this: a worker can't read + * it, and two tabs can read it at the same moment. + * + * A row's status: + * - `fired`: shown, nothing more to do. + * - `snoozed`: comes back at `snooze_until`. + * - `missed`: waits for a summary, because the device was away or notifications were + * paused. + * - `summarised`: went into the summary `batch`. On loan until `lease_until` is cleared, + * so a crash or a reload before anyone saw it brings it back. + * - `dismissed`: the user is done with it. + * + * None of this goes to the server. JSCalendar's `acknowledged` is read (reminders.ts), + * never written: decisions file, reminders. + */ + +import { metaGet, metaSet } from '../engine/core-schema'; +import { listUpcomingAlerts, type UpcomingAlert } from '../calendar/reminders'; +import type { StorageAdapter } from '../storage'; + +/** Younger than this, an alert still fires by itself. As lib/calendar-alerts.ts. */ +export const FRESH_MS = 10 * 60_000; +/** A gap between two checks longer than this means the device was away. A little over the page's refresh. */ +export const AWAY_MS = 6 * 60_000; +/** How far back a summary reaches. After a month away nobody needs a month of reminders. */ +export const MISSED_REACH_MS = 7 * 24 * 3_600_000; +/** How far ahead a check looks for the next fire time. */ +export const LOOK_AHEAD_MS = 6 * 3_600_000; +/** Rows older than the reach can't come up again. */ +const KEEP_MS = MISSED_REACH_MS + 24 * 3_600_000; +const CURSOR = 'reminder_cursor'; +/** How long a summary stays with whoever took it, unless they keep saying it's on screen. */ +export const LEASE_MS = 3 * 60_000; +const LONGEST_SNOOZE_MIN = 24 * 60; + +export interface ReminderAlert extends UpcomingAlert { + /** Set when it comes back from a snooze: when that was due. */ + snoozedUntil?: number; +} + +export interface TickParams { + /** Reads floating events: the user's zone. */ + timeZone: string; + /** Tasks' alerts too. */ + tasks: boolean; + /** Reminders are switched off: time passes, and nothing counts as missed later. */ + muted?: boolean; + /** The account's own copy of a meeting that's in several. */ + prefer?: string; +} + +export interface TickResult { + /** Yours to show. No other caller gets these. */ + due: ReminderAlert[]; + /** How many wait for a summary. */ + missed: number; + /** When to ask again at the latest for the next reminder. Null: none in sight. */ + nextAt: number | null; + /** The calendar has synced at least once, so an empty answer means something. */ + ready: boolean; +} + +interface Row { + key: string; + uid: string; + status: string; + fire_time: number; + snooze_until: number | null; + alert: string; +} + +function put(db: StorageAdapter, alert: ReminderAlert, status: string, now: number): void { + db.run( + `INSERT INTO reminder (key, uid, status, fire_time, snooze_until, batch, lease_until, alert, updated_at) VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, ?) + ON CONFLICT (key) DO UPDATE SET status = excluded.status, snooze_until = NULL, batch = NULL, lease_until = NULL, alert = excluded.alert, updated_at = excluded.updated_at`, + [alert.key, alert.uid, status, alert.fireTimeMs, JSON.stringify(alert), now], + ); +} + +function readAlert(row: Pick): ReminderAlert { + return JSON.parse(row.alert) as ReminderAlert; +} + +function missedCount(db: StorageAdapter): number { + return Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM reminder WHERE status = 'missed'")?.n ?? 0); +} + +function calendarReady(db: StorageAdapter): boolean { + return !!db.get("SELECT 1 AS yes FROM sync_state WHERE type = 'CalendarEvent' AND state IS NOT NULL LIMIT 1"); +} + +/** The same reminder to the user: an edit can give an alert a new id without moving it. */ +function sameReminder(uid: string, fireTime: number): string { + return `${uid}:${fireTime}`; +} + +/** Whether a snoozed alert still stands: the event may have gone, moved or been dismissed elsewhere. */ +function alertNow(db: StorageAdapter, row: Pick, params: Pick): UpcomingAlert | undefined { + return listUpcomingAlerts(db, { from: row.fire_time, to: row.fire_time + 1, timeZone: params.timeZone, prefer: params.prefer }) + .find((alert) => alert.uid === row.uid); +} + +/** + * Hands out what's due and settles the rest. Alerts that came due while the device was + * away go to the summary instead of arriving as a burst. A single recent one is just a + * reminder that came a little late. + */ +export function tick(db: StorageAdapter, now: number, params: TickParams): TickResult { + return db.transaction(() => { + const raw = metaGet(db, CURSOR); + const cursor = raw === null ? null : Number(raw); + metaSet(db, CURSOR, String(now)); + db.run("UPDATE reminder SET status = 'missed', batch = NULL, lease_until = NULL WHERE status = 'summarised' AND lease_until IS NOT NULL AND lease_until <= ?", [now]); + if (params.muted) return { due: [], missed: 0, nextAt: null, ready: calendarReady(db) }; + + const freshFrom = now - FRESH_MS; + const from = cursor === null ? freshFrom : Math.max(Math.min(cursor, freshFrom), now - MISSED_REACH_MS); + const listed = listUpcomingAlerts(db, { from, to: now + LOOK_AHEAD_MS, timeZone: params.timeZone, prefer: params.prefer }) + .filter((alert) => params.tasks || alert.kind !== 'task'); + const past = listed.filter((alert) => alert.fireTimeMs <= now); + const known = new Set( + past.length === 0 + ? [] + : db.all<{ uid: string; fire_time: number }>('SELECT uid, fire_time FROM reminder WHERE fire_time >= ? AND fire_time <= ?', [past[0].fireTimeMs, past[past.length - 1].fireTimeMs]) + .map((row) => sameReminder(row.uid, row.fire_time)), + ); + const unseen = past.filter((alert) => !known.has(sameReminder(alert.uid, alert.fireTimeMs))); + + const away = cursor === null || now - cursor > AWAY_MS; + const lastSeen = cursor ?? freshFrom; + // Behind the cursor: it reached the store late, through a sync. Nobody was away from that. + const inGap = away ? unseen.filter((alert) => alert.fireTimeMs > lastSeen) : []; + const summarise = new Set( + inGap.length === 1 && inGap[0].fireTimeMs >= freshFrom ? [] : inGap.map((alert) => alert.key), + ); + const due: ReminderAlert[] = []; + for (const alert of unseen) { + if (summarise.has(alert.key) || alert.fireTimeMs < freshFrom) { + put(db, alert, 'missed', now); + } else { + put(db, alert, 'fired', now); + due.push(alert); + } + } + + const expired = db.all("SELECT key, uid, status, fire_time, snooze_until, alert FROM reminder WHERE status = 'snoozed' AND snooze_until <= ?", [now]); + for (const row of expired) { + const alert = alertNow(db, row, params); + if (!alert) { + db.run("UPDATE reminder SET status = 'dismissed', snooze_until = NULL, updated_at = ? WHERE key = ?", [now, row.key]); + continue; + } + // The alert got a new id: its row goes on under that. + if (alert.key !== row.key) db.run('DELETE FROM reminder WHERE key = ?', [row.key]); + if (now - row.snooze_until! < FRESH_MS) { + const back: ReminderAlert = { ...alert, snoozedUntil: row.snooze_until! }; + put(db, back, 'fired', now); + due.push(back); + } else { + put(db, alert, 'missed', now); + } + } + + db.run("DELETE FROM reminder WHERE fire_time < ? AND status NOT IN ('snoozed', 'missed')", [now - KEEP_MS]); + + const coming = listed.find((alert) => alert.fireTimeMs > now)?.fireTimeMs ?? null; + const snooze = db.get<{ at: number | null }>("SELECT MIN(snooze_until) AS at FROM reminder WHERE status = 'snoozed'")?.at ?? null; + const nextAt = coming === null ? snooze : snooze === null ? coming : Math.min(coming, snooze); + return { due, missed: missedCount(db), nextAt, ready: calendarReady(db) }; + }); +} + +export interface MissedBatch { + /** Says which summary, for a toast clicked later. */ + batch: number; + alerts: ReminderAlert[]; +} + +/** + * The summary, for one caller only. What's gone from the calendar since doesn't come + * back to haunt anyone. Null: nothing to say. + */ +export function takeMissed(db: StorageAdapter, now: number, params: { timeZone: string; prefer?: string }): MissedBatch | null { + return db.transaction(() => { + const rows = db.all("SELECT key, uid, status, fire_time, snooze_until, alert FROM reminder WHERE status = 'missed' ORDER BY fire_time, key"); + if (rows.length === 0) return null; + const standing = new Map( + listUpcomingAlerts(db, { from: rows[0].fire_time, to: rows[rows.length - 1].fire_time + 1, timeZone: params.timeZone, prefer: params.prefer }) + .map((alert) => [sameReminder(alert.uid, alert.fireTimeMs), alert]), + ); + const newest = db.get<{ batch: number | null }>('SELECT MAX(batch) AS batch FROM reminder')?.batch ?? 0; + const batch = Math.max(now, newest + 1); + const alerts: ReminderAlert[] = []; + for (const row of rows) { + const alert = standing.get(sameReminder(row.uid, row.fire_time)); + if (!alert) { + db.run("UPDATE reminder SET status = 'dismissed', updated_at = ? WHERE key = ?", [now, row.key]); + continue; + } + db.run("UPDATE reminder SET status = 'summarised', batch = ?, lease_until = ?, alert = ?, updated_at = ? WHERE key = ?", [batch, now + LEASE_MS, JSON.stringify(alert), now, row.key]); + alerts.push(alert); + } + return alerts.length > 0 ? { batch, alerts } : null; + }); +} + +/** The summary couldn't be shown after all (paused, quiet hours): it stays due. */ +export function releaseMissed(db: StorageAdapter, now: number, batch: number): void { + db.run("UPDATE reminder SET status = 'missed', batch = NULL, lease_until = NULL, updated_at = ? WHERE status = 'summarised' AND batch = ?", [now, batch]); +} + +/** Still on screen here. */ +export function renewMissed(db: StorageAdapter, now: number, batch: number): void { + db.run("UPDATE reminder SET lease_until = ?, updated_at = ? WHERE status = 'summarised' AND batch = ? AND lease_until IS NOT NULL", [now + LEASE_MS, now, batch]); +} + +/** A native notification has it now, and the OS keeps that for as long as it wants. */ +export function settleMissed(db: StorageAdapter, now: number, batch: number): void { + db.run("UPDATE reminder SET lease_until = NULL, updated_at = ? WHERE status = 'summarised' AND batch = ?", [now, batch]); +} + +/** What a summary said, for a click on it, possibly after a restart. */ +export function readBatch(db: StorageAdapter, batch: number): ReminderAlert[] { + return db.all("SELECT alert FROM reminder WHERE status = 'summarised' AND batch = ? ORDER BY fire_time, key", [batch]).map(readAlert); +} + +/** Fired, but nobody could be told: it goes in the next summary. */ +export function holdFired(db: StorageAdapter, now: number, keys: readonly string[]): void { + db.transaction(() => { + for (const key of keys) db.run("UPDATE reminder SET status = 'missed', updated_at = ? WHERE status = 'fired' AND key = ?", [now, key]); + }); +} + +/** False when this device knows no such reminder, or the minutes make no sense. */ +export function snooze(db: StorageAdapter, now: number, key: string, minutes: number): boolean { + if (!Number.isFinite(minutes) || minutes <= 0 || minutes > LONGEST_SNOOZE_MIN) return false; + const until = now + minutes * 60_000; + return db.run("UPDATE reminder SET status = 'snoozed', snooze_until = ?, batch = NULL, lease_until = NULL, updated_at = ? WHERE key = ?", [until, now, key]).changes > 0; +} + +export function dismiss(db: StorageAdapter, now: number, keys: readonly string[]): void { + db.transaction(() => { + for (const key of keys) db.run("UPDATE reminder SET status = 'dismissed', snooze_until = NULL, lease_until = NULL, updated_at = ? WHERE key = ?", [now, key]); + }); +} + +/** One reminder as it was last shown: what a toast's Join reads, since the toast holds only the key. */ +export function readReminder(db: StorageAdapter, key: string): ReminderAlert | null { + const row = db.get>('SELECT alert FROM reminder WHERE key = ?', [key]); + return row ? readAlert(row) : null; +} diff --git a/lib/store/reminders/schema.ts b/lib/store/reminders/schema.ts new file mode 100644 index 000000000..213eec18a --- /dev/null +++ b/lib/store/reminders/schema.ts @@ -0,0 +1,24 @@ +import type { Migration } from '../storage'; + +export const REMINDER_MIGRATIONS: readonly Migration[] = [ + { + id: 850, + name: 'reminders', + sql: ` + CREATE TABLE reminder ( + key TEXT PRIMARY KEY, + uid TEXT NOT NULL, + status TEXT NOT NULL, + fire_time INTEGER NOT NULL, + snooze_until INTEGER, + batch INTEGER, + -- A summary someone took: unless they say it's shown by then, it comes back. + lease_until INTEGER, + alert TEXT NOT NULL, + updated_at INTEGER NOT NULL + ) WITHOUT ROWID; + CREATE INDEX reminder_status ON reminder (status, snooze_until); + CREATE INDEX reminder_fire ON reminder (fire_time); + `, + }, +]; diff --git a/lib/telemetry/instant-errors.ts b/lib/telemetry/instant-errors.ts index bfc248153..2114a8017 100644 --- a/lib/telemetry/instant-errors.ts +++ b/lib/telemetry/instant-errors.ts @@ -1,9 +1,10 @@ import { logger } from '@/lib/logger'; import { readErrors, utcDay } from '@/lib/error-reporting/aggregate'; -import { effectiveConsent, effectiveErrorsConsent, endpointEnabled, getInstanceId } from './state'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { effectiveConsent, effectiveErrorsConsent, endpointEnabled, getInstanceId, telemetryEndpoint } from './state'; import { errorsForDay } from './payload'; import { fetchTelemetryTarget, resolveEndpointAllowed } from './endpoint-guard'; -import { DEFAULT_ENDPOINT, errorsEndpointFor, type TelemetryErrorBatch } from './types'; +import { errorsEndpointFor, type TelemetryErrorBatch } from './types'; /** * Instant error counts. With the error-count consent on "instant", the @@ -45,7 +46,7 @@ async function tick(now: number): Promise { return; } const { state: stored } = await effectiveConsent(); - const endpoint = stored.endpoint ?? DEFAULT_ENDPOINT; + const endpoint = telemetryEndpoint(stored); if (!endpointEnabled(endpoint)) return; let target: string; try { @@ -81,7 +82,9 @@ async function tick(now: number): Promise { if (!res.ok) { // Not marked as sent: the next tick tries them again. logger.warn('telemetry: instant error counts rejected', { status: res.status }); - return; + // A 4xx from the collector stays a 4xx. A desktop app, often a beta on a collector + // that doesn't know its version yet, shouldn't knock on the door every minute. + if (!IS_DESKTOP_BUILD || res.status < 400 || res.status >= 500 || res.status === 429) return; } for (const error of changed) s.sent.set(`${error.fingerprint}|${error.version}`, error.count); } diff --git a/lib/telemetry/payload.ts b/lib/telemetry/payload.ts index 6c56c0e4f..7d0b9fe75 100644 --- a/lib/telemetry/payload.ts +++ b/lib/telemetry/payload.ts @@ -5,6 +5,8 @@ import { effectiveErrorsConsent, getInstanceId } from './state'; import { getLoginCounts } from './login-tracker'; import { isErrorCode } from '@/lib/error-reporting/envelope'; import { readErrors, utcDay, type Aggregate } from '@/lib/error-reporting/aggregate'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { desktopServerPrivacy } from '@/lib/desktop/server-privacy'; import { MAX_TELEMETRY_ERRORS, MAX_TELEMETRY_FRAMES, @@ -23,6 +25,10 @@ export function markProcessStart(): void { } function readPackage(): { version: string; build: string | null } { + // The desktop app's version, not the webmail's it carries, and mode and channel in the + // one field the collector keeps (lib/desktop/desktop-telemetry.ts). + const desktop = desktopServerPrivacy(); + if (desktop) return { version: desktop.appVersion, build: desktop.build }; try { const pkg = JSON.parse( readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'), @@ -34,6 +40,7 @@ function readPackage(): { version: string; build: string | null } { } function detectPlatform(): Platform { + if (IS_DESKTOP_BUILD) return 'desktop'; if (process.env.KUBERNETES_SERVICE_HOST) return 'k8s'; // /.dockerenv is the standard Docker container marker. try { @@ -154,6 +161,8 @@ export function errorsForDay(agg: Aggregate, day: string): TelemetryError[] { */ export async function buildPayload(): Promise { const base = await buildPayloadV1(); + // Crash report consent has its own way out, /v1/errors. A heartbeat is only telemetry. + if (IS_DESKTOP_BUILD) return base; // Daily and instant mode both send the last complete day with the heartbeat. if ((await effectiveErrorsConsent()).consent === 'off') return base; return { ...base, schema: '2', errors: errorsForPayload(await readErrors()) }; diff --git a/lib/telemetry/sender.ts b/lib/telemetry/sender.ts index 5eeab4366..4157ce6b4 100644 --- a/lib/telemetry/sender.ts +++ b/lib/telemetry/sender.ts @@ -2,11 +2,12 @@ import { logger } from '@/lib/logger'; import { isSharedMode } from '@/lib/shared-store'; import { announceChange } from '@/lib/shared-store/changes'; import { - effectiveConsent, endpointEnabled, loadState, loadStateVersioned, saveStateIfUnchanged, updateState, + effectiveConsent, endpointEnabled, loadState, loadStateVersioned, saveStateIfUnchanged, telemetryEndpoint, updateState, } from './state'; import { buildPayload } from './payload'; import { fetchTelemetryTarget, resolveEndpointAllowed } from './endpoint-guard'; -import { DEFAULT_ENDPOINT } from './types'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { DESKTOP_RETRY_MS, desktopCatchUpMs } from '@/lib/desktop/desktop-telemetry'; const DAY_MS = 24 * 60 * 60 * 1000; const JITTER_MS = 2 * 60 * 60 * 1000; // ± 2 hours @@ -96,7 +97,7 @@ export async function sendOnce(opts?: { reason?: string; slot?: string }): Promi if (consent !== 'on') return { ok: false, error: `consent ${consent} (source ${source})` }; // A cleared collector URL is stored as '' and switches sending off; only a // state file without the field at all gets the project's collector. - const endpoint = state.endpoint ?? DEFAULT_ENDPOINT; + const endpoint = telemetryEndpoint(state); if (!endpointEnabled(endpoint)) return { ok: false, error: 'endpoint blank' }; // Re-check at fetch time: defeats DNS rebinding, and catches the case @@ -164,12 +165,18 @@ async function scheduleNext(delayMs: number, generation: number): Promise } async function tick(slot: string, generation: number): Promise { + let failed = false; try { - await sendOnce({ reason: 'scheduled', slot }); + const result = await sendOnce({ reason: 'scheduled', slot }); + // A 4xx says the collector won't take this, and an hour from now it still won't. + const refused = result.status !== undefined && result.status >= 400 && result.status < 500 && result.status !== 429; + failed = !result.ok && result.error !== 'already sent' && !refused; } catch (err) { + failed = true; logger.warn('telemetry: heartbeat failed', { error: err instanceof Error ? err.message : String(err) }); } - await scheduleNext(jitteredDelay(DAY_MS), generation); + // A laptop that woke up offline shouldn't lose the day. + await scheduleNext(IS_DESKTOP_BUILD && failed ? DESKTOP_RETRY_MS : jitteredDelay(DAY_MS), generation); } // Run by the scheduler leader (lib/scheduler-leader.ts). Idempotent. @@ -181,13 +188,16 @@ export async function startScheduler(): Promise { return; } const state = await loadState(); - if (!endpointEnabled(state.endpoint ?? DEFAULT_ENDPOINT)) { + const endpoint = telemetryEndpoint(state); + if (!endpointEnabled(endpoint)) { logger.info('telemetry: scheduler not started', { consent, endpoint: 'blank' }); return; } // If we have a next-scheduled time in the future use it; otherwise schedule // FIRST_DELAY_MS out. This means after a restart we don't fire immediately. - let delay = FIRST_DELAY_MS; + // A desktop app isn't a server: quit within the hour on every start, and it would + // never send. So what's due goes out soon after a start, at a random delay. + let delay = IS_DESKTOP_BUILD ? desktopCatchUpMs() : FIRST_DELAY_MS; if (state.nextScheduledAt) { const remaining = new Date(state.nextScheduledAt).getTime() - Date.now(); if (remaining > 0) delay = Math.min(remaining, DAY_MS + JITTER_MS); @@ -195,7 +205,7 @@ export async function startScheduler(): Promise { await scheduleNext(delay, generation); logger.info('telemetry: scheduler started', { nextInMs: delay, - endpoint: state.endpoint, + endpoint, }); } diff --git a/lib/telemetry/state.ts b/lib/telemetry/state.ts index ca9f8655b..7386a2ca3 100644 --- a/lib/telemetry/state.ts +++ b/lib/telemetry/state.ts @@ -6,6 +6,8 @@ import { isStoreUnavailable, StoredDataUnreadableError } from '@/lib/stored-data import type { TelemetryStateFile, ConsentState, ErrorsConsent } from './types'; import { DEFAULT_ENDPOINT } from './types'; import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { desktopServerPrivacy } from '@/lib/desktop/server-privacy'; export function getDir(): string { return process.env.TELEMETRY_DATA_DIR || @@ -195,6 +197,13 @@ export async function effectiveErrorsConsent(): Promise<{ }> { const telemetry = await effectiveConsent(); if (telemetry.source === 'no-third-party') return { consent: 'off', source: 'no-third-party' }; + // The desktop user's crash report consent, separate from telemetry: either can be on + // without the other. Instant, because without a heartbeat nothing else carries them. + // BULWARK_TELEMETRY=off stops these too, so it means what it says on a managed machine. + if (IS_DESKTOP_BUILD) { + const on = desktopServerPrivacy()?.crashReports && envOverride() !== 'off' && errorsEnvOverride() !== 'off'; + return { consent: on ? 'instant' : 'off', source: 'env' }; + } if (telemetry.consent !== 'on') return { consent: 'off', source: 'telemetry' }; const env = errorsEnvOverride(); if (env) return { consent: env, source: 'env' }; @@ -213,10 +222,22 @@ export async function effectiveConsent(): Promise<{ const envState = envOverride(); const state = await loadState(); if (await isNoThirdPartyTrafficOn()) return { consent: 'off', source: 'no-third-party', state }; + // The user answers in the shell. Nothing on disk can say yes for them, and in the + // environment only an off counts: main passes that through and nothing else. + if (IS_DESKTOP_BUILD) return { consent: desktopServerPrivacy()?.telemetry && envState !== 'off' ? 'on' : 'off', source: 'env', state }; if (envState) return { consent: envState, source: 'env', state }; return { consent: state.consent, source: 'file', state }; } +/** + * Where heartbeats go. On the desktop build the state file has no say, since anything on + * the machine can write to it. A desktop user has their two switches instead. + */ +export function telemetryEndpoint(state: TelemetryStateFile): string { + if (IS_DESKTOP_BUILD) return desktopServerPrivacy()?.endpoint ?? DEFAULT_ENDPOINT; + return state.endpoint ?? DEFAULT_ENDPOINT; +} + export function endpointEnabled(endpoint: string | undefined): boolean { return !!endpoint && endpoint.trim().length > 0; } diff --git a/lib/telemetry/types.ts b/lib/telemetry/types.ts index 21deb9eb4..bdbc9c9fb 100644 --- a/lib/telemetry/types.ts +++ b/lib/telemetry/types.ts @@ -4,7 +4,7 @@ export type ConsentState = 'pending' | 'on' | 'off'; -export type Platform = 'docker' | 'bare' | 'k8s' | 'unknown'; +export type Platform = 'docker' | 'bare' | 'k8s' | 'desktop' | 'unknown'; export type OsFamily = 'linux' | 'darwin' | 'windows' | 'unknown'; export type CountBucket = '0' | '1' | '2-5' | '6-10' | '11-50' | '51-200' | '201+'; diff --git a/locales/ar/common.json b/locales/ar/common.json index 58741cd94..4bd788ba0 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "رسائل لم تُرسل تنتظر كلمة مرورك الجديدة: {count}. تبقى حتى {date}.", "refused_waiting_unknown": "ربما توجد رسائل لم تُرسل تنتظر كلمة مرورك الجديدة. تبقى حتى {date}.", "confirm_unsent_unknown": "ربما توجد هنا رسائل لم تُرسل. ستُفقد.", - "confirm_templates": "قوالب البريد الإلكتروني المحفوظة على هذا الجهاز" + "confirm_templates": "قوالب البريد الإلكتروني المحفوظة على هذا الجهاز", + "session_waiting": "رسائل لم تُرسل تنتظر تسجيل دخولك التالي: {count}. تبقى حتى {date}.", + "session_waiting_unknown": "ربما توجد رسائل لم تُرسل تنتظر تسجيل دخولك التالي. تبقى حتى {date}.", + "needs_sign_in": "يلزم تسجيل الدخول", + "switcher_waiting": "رسائل لم تُرسل: {count}. ستُحذف من هذا الجهاز في {date}.", + "switcher_kept": "سيُحذف البريد الموجود على هذا الجهاز في {date}.", + "sibling_waiting": "تم تسجيل خروج {name}. رسائل لم تُرسل: {count}. سجّل الدخول قبل {date} وإلا فستُحذف من هذا الجهاز.", + "sibling_kept": "تم تسجيل خروج {name}. سجّل الدخول قبل {date} وإلا فسيُحذف البريد من هذا الجهاز." }, "offlineStorage": { "keep_description": "يحتفظ ببريدك وتقويمك وجهات اتصالك والملفات التي تختارها، مشفّرة على هذا الجهاز. دون اتصال يمكنك قراءة البريد ووضع علامات عليه ونقله، وما تكتبه ينتظر في صندوق الصادر. يؤدي تسجيل الخروج إلى إزالة كل ذلك.", @@ -5866,6 +5873,38 @@ "banner_refused": "المسودات التي رفضها الخادم: {count}", "banner_waiting": "مسودات في انتظار الخادم: {count}" }, + "offlineExport": { + "settings_label": "تصدير البريد المحفوظ", + "settings_description": "احفظ بريد هذا الجهاز كملفات mbox أو ‎.eml. يعمل ذلك دون اتصال أيضًا.", + "title": "تصدير البريد المحفوظ", + "description": "يحفظ هذا النسخ الأصلية التي يحتفظ بها هذا الجهاز. ملفات ‎.eml نسخ مطابقة تمامًا. أما ملف mbox فيضيف العلامات التي يتطلبها تنسيقه ويكتب حالة القراءة والتمييز في ترويسات كل رسالة بدلًا مما كان فيها. البريد الأقدم من الفترة التي اخترتها غير مشمول. لحفظ كل ما على الخادم، استخدم «تصدير كل البريد».", + "folders_label": "المجلدات", + "all_folders": "كل المجلدات", + "folder_kept": "هنا: {kept} من {total}", + "loading": "جارٍ قراءة ما على هذا الجهاز…", + "load_failed": "تعذّرت قراءة البريد على هذا الجهاز.", + "nothing_kept": "لا يوجد بريد محفوظ على هذا الجهاز بعد.", + "kept": "النسخ الأصلية على هذا الجهاز: {kept} من {total} رسالة", + "missing_online": "للتنزيل من الخادم: {count}", + "missing_offline": "يتعذّر التصدير دون اتصال: {count}. يمكنك المحاولة مرة أخرى عند الاتصال.", + "reason_attachments_off": "لا تُحفظ الرسائل الأصلية إلا عند تشغيل «الاحتفاظ بالمرفقات».", + "reason_not_kept": "تُنزَّل الرسائل الأصلية في النهاية، وما دامت هناك مساحة لها فقط.", + "not_kept_attachments_off": "لا توجد نسخة أصلية لأن هذا الجهاز لا يحتفظ بالمرفقات: {count}", + "not_kept_not_kept": "لا توجد نسخة أصلية على هذا الجهاز بعد: {count}", + "retry": "إعادة المحاولة عند الاتصال", + "retry_online": "أنت متصل الآن، لذا يمكن جلبها من الخادم.", + "retry_offline": "يمكنك المحاولة مرة أخرى عند الاتصال.", + "settings_button": "تصدير…", + "message_failed": "لم يسلّم الخادم هذه الرسالة.", + "failed_generic": "توقف التصدير بسبب خطأ. حاول مرة أخرى.", + "stopped_limit": "توقف التصدير عند {limit}، وهو أقصى ما يستطيع هذا المتصفح الاحتفاظ به. اختر مجلدات أقل، أو استخدم Chromium أو Firefox أو تطبيق سطح المكتب.", + "over_limit": "يستطيع هذا المتصفح الاحتفاظ بما لا يزيد عن {limit}، وهذا الاختيار نحو {size}. اختر مجلدات أقل، أو استخدم Chromium أو Firefox أو تطبيق سطح المكتب.", + "no_room": "يحتاج هذا التصدير إلى نحو {size}، والمتاح لهذا المتصفح {free}. اختر مجلدات أقل أو أفرغ مساحة.", + "stopped_no_room": "نفدت مساحة المتصفح أثناء التصدير. اختر مجلدات أقل أو أفرغ مساحة.", + "stopped_offline_off": "توقف التصدير لأن هذا الحساب لم يعد يحتفظ بالبريد على هذا الجهاز.", + "policy_unknown": "قواعد مؤسستك لهذا الحساب غير معروفة بعد، لذا يبقى التصدير متوقفًا حاليًا.", + "leftover_file": "تعذّرت إزالة الملف {name} من المكان الذي حفظته فيه. إنه غير مكتمل، ويمكنك حذفه." + }, "deep_link": { "message_not_found": "لم تعد هذه الرسالة متاحة.", "thread_not_found": "لم تعد هذه المحادثة متاحة.", @@ -6728,6 +6767,53 @@ "revoke": "سحب الإذن", "revoked": "تم سحب الإذن" }, + "desktopOs": { + "section_title": "تطبيق سطح المكتب", + "section_description": "طريقة عمل التطبيق مع حاسوبك.", + "close_to_tray_label": "الاستمرار في العمل في شريط النظام عند الإغلاق", + "close_to_tray_description": "يستمر وصول البريد الجديد والتذكيرات بعد إغلاق النافذة.", + "start_at_login_label": "البدء عند تسجيل الدخول إلى الحاسوب", + "start_at_login_description": "يبدأ التطبيق تلقائيًا عند تسجيل الدخول.", + "start_minimized_label": "البدء في شريط النظام", + "start_minimized_description": "لا تُفتح نافذة عند تسجيل الدخول. استخدم أيقونة شريط النظام لفتحه.", + "attention_label": "وميض شريط المهام عند وصول بريد جديد", + "attention_mac_label": "قفز أيقونة Dock عند وصول بريد جديد", + "attention_description": "فقط عندما لا يكون التطبيق في المقدمة.", + "quiet_hours_label": "ساعات الهدوء", + "quiet_hours_description": "لا إشعارات ولا أصوات في هذه الأوقات. يستمر وصول البريد.", + "quiet_hours_from": "من", + "quiet_hours_to": "إلى", + "show_shortcut_label": "إظهار التطبيق باختصار لوحة المفاتيح", + "show_shortcut_description": "{keys} يُظهر التطبيق في المقدمة من أي تطبيق آخر.", + "show_shortcut_taken": "يستخدم تطبيق آخر {keys}. أغلق ذلك التطبيق، أو أوقف هذا الخيار ثم شغّله مرة أخرى.", + "default_app_label": "تطبيق البريد الافتراضي", + "default_app_description": "فتح روابط البريد وملفات ‎.eml و‎.ics و‎.vcf بهذا التطبيق.", + "default_app_is_default": "هذا التطبيق يفتح روابط البريد لديك.", + "default_app_button": "اختيار التطبيق الافتراضي…", + "new_mail": "بريد جديد", + "no_subject": "(بدون موضوع)", + "action_delete": "حذف", + "copy_as_file": "نسخ كملف", + "copied": "تم نسخ الملف. يمكنك لصقه في مجلد.", + "copy_failed": "تعذّر نسخ الملف.", + "reply_lost": "تعذّر الاحتفاظ بالرد الذي كتبته. افتح الرسالة وأجب من هناك." + }, + "desktopReminders": { + "generic_title": "تذكير", + "snooze": "تأجيل {count} د", + "snooze_short": "{count} د", + "open": "فتح", + "join": "انضمام", + "missed_title": "{count, plural, zero {لا تذكيرات فائتة} one {تذكير فائت واحد} two {تذكيران فائتان} few {# تذكيرات فائتة} many {# تذكيرًا فائتًا} other {# تذكير فائت}}", + "missed_more": "و{count} أخرى", + "missed_show": "عرض", + "missed_dialog_title": "التذكيرات الفائتة", + "missed_dialog_description": "حان موعد هذه التذكيرات بينما كان التطبيق مغلقًا أو الكمبيوتر في وضع السكون أو الإشعارات متوقفة مؤقتًا.", + "open_calendar": "فتح التقويم", + "done": "تم", + "join_host": "انضمام ({host})", + "join_named": "انضمام: {title}" + }, "desktop_policy": { "server_refused": "لا تسمح مؤسستك بهذا الخادم على هذا الجهاز.", "plugin_refused": "لا تسمح مؤسستك بهذه الإضافة على هذا الجهاز، لذلك لا تعمل. تبقى إعداداتها وبياناتها محفوظة.", @@ -6736,5 +6822,30 @@ "plugins_off": "أوقفت مؤسستك الإضافات على هذا الجهاز.", "plugins_allowlist": "لا تسمح مؤسستك إلا بإضافات معينة على هذا الجهاز.", "off": "إيقاف" + }, + "desktopTelemetry": { + "section_title": "معلومات الاستخدام وتقارير الأعطال", + "section_description": "كلاهما متوقف حتى تشغّله، ولكل منهما مفتاحه الخاص.", + "telemetry_label": "إرسال معلومات الاستخدام", + "telemetry_description": "مرة واحدة في اليوم: معرّف عشوائي وإصدار التطبيق وإصدار Node ونظام التشغيل ومدة تشغيل التطبيق وطريقة اتصاله. ومع خادم بريد خاص بك، أيضًا الميزات المفعّلة وعدد تقريبي للحسابات وعدد الإضافات والسمات. لا يُرسل أبدًا البريد أو العناوين أو أسماء الخوادم.", + "crash_label": "إرسال تقارير الأعطال", + "crash_description": "تُرسل أخطاء التطبيق مع معرّف عشوائي وأسماء ملفات من شيفرة التطبيق نفسه ورسائل قصيرة منقّحة. وإذا تعطّل التطبيق نفسه، يبقى ملف العطل على هذا الحاسوب ولا يغادره إلا إذا أرسلته.", + "crash_memory": "ملف العطل نسخة من جزء من ذاكرة التطبيق لحظة العطل. يزيل Bulwark ما يجده من أسماء المستخدمين والعناوين ورموز تسجيل الدخول، لكن ملف العطل قد يظل يحتوي على نص بريدك.", + "instance_note": "على خادم Bulwark، يقرر مسؤول ذلك الخادم بشكل منفصل ما تبلّغه صفحاته به.", + "forced_off": "أوقفت مؤسستك هذا الخيار.", + "url_label": "وجهة ملفات الأعطال", + "url_description": "عنوان https الذي يستقبل ملف العطل. لا يأتي Bulwark بعنوان.", + "url_invalid": "أدخل عنوان https كاملًا.", + "url_save": "حفظ", + "files_label": "ملفات الأعطال على هذا الحاسوب", + "files_description": "يحتفظ Bulwark بالأحدث ويحذف ما مضى عليه أكثر من شهر.", + "files_empty": "لا توجد ملفات أعطال.", + "file_size": "{size} ك.ب", + "send": "إرسال تقرير العطل…", + "delete_all": "حذف الكل", + "sent": "أُرسل تقرير العطل.", + "send_no_consent": "شغّل تقارير الأعطال أولًا.", + "send_no_url": "لم يُحدَّد عنوان، لذا لا توجد وجهة للإرسال. أدخل عنوانًا في الأعلى.", + "send_failed": "تعذّر إرسال تقرير العطل." } } diff --git a/locales/ca/common.json b/locales/ca/common.json index 9047d313b..08aa47781 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Missatges sense enviar que esperen la teva contrasenya nova: {count}. Es conserven fins al {date}.", "refused_waiting_unknown": "És possible que hi hagi missatges sense enviar esperant la teva contrasenya nova. Es conserven fins al {date}.", "confirm_unsent_unknown": "És possible que hi hagi missatges sense enviar esperant aquí. Es perdran.", - "confirm_templates": "Plantilles de correu desades en aquest dispositiu" + "confirm_templates": "Plantilles de correu desades en aquest dispositiu", + "session_waiting": "Missatges sense enviar que esperen el teu proper inici de sessió: {count}. Es conserven fins al {date}.", + "session_waiting_unknown": "És possible que hi hagi missatges sense enviar esperant el teu proper inici de sessió. Es conserven fins al {date}.", + "needs_sign_in": "Cal iniciar la sessió", + "switcher_waiting": "Missatges sense enviar: {count}. S'eliminaran d'aquest dispositiu el {date}.", + "switcher_kept": "El correu d'aquest dispositiu s'eliminarà el {date}.", + "sibling_waiting": "S'ha tancat la sessió de {name}. Missatges sense enviar: {count}. Inicia la sessió abans del {date} o s'eliminaran d'aquest dispositiu.", + "sibling_kept": "S'ha tancat la sessió de {name}. Inicia la sessió abans del {date} o el correu s'eliminarà d'aquest dispositiu." }, "offlineStorage": { "keep_description": "Desa el vostre correu, calendari, contactes i els fitxers que trieu, xifrats en aquest dispositiu. Sense connexió podeu llegir, marcar i moure correu, i el que escriviu espera a la safata de sortida. En tancar la sessió s'esborra tot.", @@ -5866,6 +5873,38 @@ "banner_refused": "Esborranys rebutjats pel servidor: {count}", "banner_waiting": "Esborranys que esperen el servidor: {count}" }, + "offlineExport": { + "settings_label": "Exporta el correu desat", + "settings_description": "Desa el correu d'aquest dispositiu com a fitxers mbox o .eml. Funciona sense connexió.", + "title": "Exporta el correu desat", + "description": "Desa els originals que conserva aquest dispositiu. Els fitxers .eml són còpies exactes. Un fitxer mbox afegeix les marques que requereix el seu format i escriu l'estat de llegit i marcat a les capçaleres de cada missatge, en lloc de les que tingués. El correu anterior al període triat no hi és. Per desar-ho tot del servidor, fes servir «Exporta tot el correu».", + "folders_label": "Carpetes", + "all_folders": "Totes les carpetes", + "folder_kept": "{kept} de {total} aquí", + "loading": "S'està llegint el que hi ha en aquest dispositiu…", + "load_failed": "No s'ha pogut llegir el correu d'aquest dispositiu.", + "nothing_kept": "Encara no hi ha correu desat en aquest dispositiu.", + "kept": "Originals en aquest dispositiu: {kept} de {total} missatges", + "missing_online": "Per baixar del servidor: {count}", + "missing_offline": "No es poden exportar sense connexió: {count}. Pots tornar-ho a provar quan tinguis connexió.", + "reason_attachments_off": "Els missatges originals només es conserven si «Desa els fitxers adjunts» està activat.", + "reason_not_kept": "Els missatges originals es baixen al final, i només si queda espai.", + "not_kept_attachments_off": "Sense original aquí perquè aquest dispositiu no conserva adjunts: {count}", + "not_kept_not_kept": "Encara sense original en aquest dispositiu: {count}", + "retry": "Torna-ho a provar amb connexió", + "retry_online": "Ja tens connexió, així que poden venir del servidor.", + "retry_offline": "Pots tornar-ho a provar quan tinguis connexió.", + "settings_button": "Exporta…", + "message_failed": "El servidor no ha lliurat aquest missatge.", + "failed_generic": "L'exportació s'ha aturat per un error. Torna-ho a provar.", + "stopped_limit": "L'exportació s'ha aturat a {limit}, el màxim que pot contenir aquest navegador. Tria menys carpetes o fes servir Chromium, Firefox o l'aplicació d'escriptori.", + "over_limit": "Aquest navegador pot contenir com a màxim {limit}, i aquesta selecció ocupa uns {size}. Tria menys carpetes o fes servir Chromium, Firefox o l'aplicació d'escriptori.", + "no_room": "Aquesta exportació necessita uns {size}, i aquest navegador en té {free} disponibles. Tria menys carpetes o allibera espai.", + "stopped_no_room": "El navegador s'ha quedat sense espai per a l'exportació. Tria menys carpetes o allibera espai.", + "stopped_offline_off": "L'exportació s'ha aturat perquè aquest compte ja no conserva correu en aquest dispositiu.", + "policy_unknown": "Encara no es coneixen les regles de la teva organització per a aquest compte, així que l'exportació continua desactivada de moment.", + "leftover_file": "No s'ha pogut treure el fitxer {name} d'on l'has desat. Està incomplet, el pots suprimir." + }, "deep_link": { "message_not_found": "Aquest missatge ja no està disponible.", "thread_not_found": "Aquesta conversa ja no està disponible.", @@ -6728,6 +6767,53 @@ "revoke": "Retira", "revoked": "Permís retirat" }, + "desktopOs": { + "section_title": "Aplicació d'escriptori", + "section_description": "Com funciona l'aplicació amb l'ordinador.", + "close_to_tray_label": "Continua a la safata en tancar", + "close_to_tray_description": "El correu nou i els recordatoris continuen arribant després de tancar la finestra.", + "start_at_login_label": "Inicia en entrar a l'ordinador", + "start_at_login_description": "L'aplicació s'inicia sola quan entreu.", + "start_minimized_label": "Inicia a la safata", + "start_minimized_description": "No s'obre cap finestra en entrar. Obriu-la amb la icona de la safata.", + "attention_label": "Fes parpellejar la barra de tasques amb correu nou", + "attention_mac_label": "Fes botar la icona del Dock amb correu nou", + "attention_description": "Només quan l'aplicació no és al davant.", + "quiet_hours_label": "Hores de silenci", + "quiet_hours_description": "Cap notificació ni so en aquestes hores. El correu continua arribant.", + "quiet_hours_from": "De", + "quiet_hours_to": "a", + "show_shortcut_label": "Mostra l'aplicació amb una drecera de teclat", + "show_shortcut_description": "{keys} porta l'aplicació al davant des de qualsevol altra aplicació.", + "show_shortcut_taken": "Una altra aplicació fa servir {keys}. Tanqueu-la o desactiveu i torneu a activar això.", + "default_app_label": "Aplicació de correu predeterminada", + "default_app_description": "Obre els enllaços de correu i els fitxers .eml, .ics i .vcf amb aquesta aplicació.", + "default_app_is_default": "Aquesta aplicació obre els vostres enllaços de correu.", + "default_app_button": "Tria l'aplicació predeterminada…", + "new_mail": "Correu nou", + "no_subject": "(sense assumpte)", + "action_delete": "Suprimeix", + "copy_as_file": "Copia com a fitxer", + "copied": "Fitxer copiat. El podeu enganxar en una carpeta.", + "copy_failed": "No s'ha pogut copiar el fitxer.", + "reply_lost": "No s'ha pogut conservar la resposta que heu escrit. Obriu el missatge i responeu des d'allà." + }, + "desktopReminders": { + "generic_title": "Recordatori", + "snooze": "Posposa {count} min", + "snooze_short": "{count} min", + "open": "Obre", + "join": "Uneix-te", + "missed_title": "{count, plural, one {# recordatori perdut} other {# recordatoris perduts}}", + "missed_more": "i {count} més", + "missed_show": "Mostra", + "missed_dialog_title": "Recordatoris perduts", + "missed_dialog_description": "Van vèncer mentre l'aplicació estava tancada, l'ordinador en repòs o les notificacions en pausa.", + "open_calendar": "Obre el calendari", + "done": "Fet", + "join_host": "Uneix-te ({host})", + "join_named": "Uneix-te: {title}" + }, "desktop_policy": { "server_refused": "La teva organització no permet aquest servidor en aquest dispositiu.", "plugin_refused": "La teva organització no permet aquest connector en aquest dispositiu, per tant no s'executa. Se'n conserven la configuració i les dades.", @@ -6736,5 +6822,30 @@ "plugins_off": "La teva organització ha desactivat els connectors en aquest dispositiu.", "plugins_allowlist": "La teva organització només permet determinats connectors en aquest dispositiu.", "off": "Desactivat" + }, + "desktopTelemetry": { + "section_title": "Dades d'ús i informes d'errors greus", + "section_description": "Tots dos estan desactivats fins que els activis, i cadascun té el seu interruptor.", + "telemetry_label": "Envia dades d'ús", + "telemetry_description": "Un cop al dia: un identificador aleatori, la versió de l'aplicació, la versió de Node, el teu sistema operatiu, quant de temps fa que funciona l'aplicació i com es connecta. Amb un servidor de correu propi, també quines funcions estan actives, un nombre aproximat de comptes i quants connectors i temes tens. Mai correus, adreces ni noms de servidor.", + "crash_label": "Envia informes d'errors greus", + "crash_description": "Els errors de l'aplicació s'envien amb un identificador aleatori, noms de fitxer del codi de l'aplicació i missatges curts i nets. Si la mateixa aplicació es tanca per un error, un fitxer d'error queda en aquest ordinador i només en surt si l'envies.", + "crash_memory": "Un fitxer d'error és una còpia d'una part de la memòria de l'aplicació en el moment de l'error. Bulwark elimina els noms d'usuari, les adreces i els testimonis d'inici de sessió que troba, però un fitxer d'error encara pot contenir el text dels teus correus.", + "instance_note": "En un servidor Bulwark, el seu administrador decideix per separat què li comuniquen les pàgines.", + "forced_off": "La teva organització ho ha desactivat.", + "url_label": "Destinació dels fitxers d'error", + "url_description": "L'adreça https que rep un fitxer d'error. Bulwark no n'inclou cap.", + "url_invalid": "Escriu una adreça https completa.", + "url_save": "Desa", + "files_label": "Fitxers d'error en aquest ordinador", + "files_description": "Bulwark conserva els més recents i elimina els de més d'un mes.", + "files_empty": "No hi ha fitxers d'error.", + "file_size": "{size} KB", + "send": "Envia l'informe d'error…", + "delete_all": "Elimina-ho tot", + "sent": "Informe d'error enviat.", + "send_no_consent": "Activa primer els informes d'errors greus.", + "send_no_url": "No hi ha cap adreça, així que no hi ha on enviar-ho. Escriu-ne una a dalt.", + "send_failed": "No s'ha pogut enviar l'informe d'error." } } diff --git a/locales/cs/common.json b/locales/cs/common.json index a2d6bac72..e7a279644 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Neodeslané zprávy čekající na nové heslo: {count}. Zůstanou do {date}.", "refused_waiting_unknown": "Na nové heslo možná čekají neodeslané zprávy. Zůstanou do {date}.", "confirm_unsent_unknown": "Možná tu čekají neodeslané zprávy. Budou ztraceny.", - "confirm_templates": "Šablony e-mailů uložené v tomto zařízení" + "confirm_templates": "Šablony e-mailů uložené v tomto zařízení", + "session_waiting": "Neodeslané zprávy čekající na další přihlášení: {count}. Zůstanou do {date}.", + "session_waiting_unknown": "Na další přihlášení možná čekají neodeslané zprávy. Zůstanou do {date}.", + "needs_sign_in": "Vyžaduje přihlášení", + "switcher_waiting": "Neodeslané zprávy: {count}. Budou smazány z tohoto zařízení {date}.", + "switcher_kept": "Pošta v tomto zařízení bude smazána {date}.", + "sibling_waiting": "Účet {name} byl odhlášen. Neodeslané zprávy: {count}. Přihlaste se do {date}, jinak budou z tohoto zařízení smazány.", + "sibling_kept": "Účet {name} byl odhlášen. Přihlaste se do {date}, jinak bude pošta z tohoto zařízení smazána." }, "offlineStorage": { "keep_description": "Uchovává vaši poštu, kalendář, kontakty a vybrané soubory zašifrované v tomto zařízení. Offline můžete poštu číst, označovat a přesouvat a to, co napíšete, čeká v odesílané poště. Odhlášení vše odstraní.", @@ -5866,6 +5873,38 @@ "banner_refused": "Koncepty odmítnuté serverem: {count}", "banner_waiting": "Koncepty čekající na server: {count}" }, + "offlineExport": { + "settings_label": "Exportovat uloženou poštu", + "settings_description": "Uložte poštu z tohoto zařízení jako soubory mbox nebo .eml. Funguje to i offline.", + "title": "Exportovat uloženou poštu", + "description": "Uloží originály, které toto zařízení uchovává. Soubory .eml jsou přesné kopie. Soubor mbox přidá značky, které jeho formát vyžaduje, a zapíše stav přečtení a označení do záhlaví každé zprávy místo těch, která měla. Pošta starší než zvolené období v tom není. Chcete-li uložit vše ze serveru, použijte „Exportovat všechnu poštu“.", + "folders_label": "Složky", + "all_folders": "Všechny složky", + "folder_kept": "{kept} z {total} zde", + "loading": "Čtení dat z tohoto zařízení…", + "load_failed": "Poštu na tomto zařízení se nepodařilo přečíst.", + "nothing_kept": "Na tomto zařízení zatím není uložená žádná pošta.", + "kept": "Originály na tomto zařízení: {kept} z {total} zpráv", + "missing_online": "Ke stažení ze serveru: {count}", + "missing_offline": "Nelze exportovat offline: {count}. Můžete to zkusit znovu, až budete online.", + "reason_attachments_off": "Původní zprávy se uchovávají jen tehdy, když je zapnuto „Uchovávat přílohy“.", + "reason_not_kept": "Původní zprávy se stahují jako poslední a jen tehdy, když je na ně místo.", + "not_kept_attachments_off": "Bez originálu, protože toto zařízení neuchovává přílohy: {count}", + "not_kept_not_kept": "Zatím bez originálu na tomto zařízení: {count}", + "retry": "Zkusit znovu online", + "retry_online": "Jste online, takže mohou přijít ze serveru.", + "retry_offline": "Můžete to zkusit znovu, až budete online.", + "settings_button": "Exportovat…", + "message_failed": "Server tuto zprávu nedodal.", + "failed_generic": "Export se zastavil kvůli chybě. Zkuste to znovu.", + "stopped_limit": "Export se zastavil na {limit}, což je nejvíc, co tento prohlížeč pojme. Vyberte méně složek nebo použijte Chromium, Firefox či desktopovou aplikaci.", + "over_limit": "Tento prohlížeč pojme nejvýše {limit} a tento výběr má asi {size}. Vyberte méně složek nebo použijte Chromium, Firefox či desktopovou aplikaci.", + "no_room": "Tento export potřebuje asi {size} a tento prohlížeč má k dispozici {free}. Vyberte méně složek nebo uvolněte místo.", + "stopped_no_room": "Prohlížeči došlo místo pro export. Vyberte méně složek nebo uvolněte místo.", + "stopped_offline_off": "Export se zastavil, protože tento účet už na tomto zařízení poštu neuchovává.", + "policy_unknown": "Pravidla vaší organizace pro tento účet zatím nejsou známa, proto export zatím zůstává vypnutý.", + "leftover_file": "Soubor {name} se nepodařilo odebrat z místa, kam jste ho uložili. Je neúplný, můžete ho smazat." + }, "deep_link": { "message_not_found": "Tato zpráva už není dostupná.", "thread_not_found": "Tato konverzace už není dostupná.", @@ -6728,6 +6767,53 @@ "revoke": "Odvolat", "revoked": "Povolení bylo odvoláno" }, + "desktopOs": { + "section_title": "Desktopová aplikace", + "section_description": "Jak aplikace spolupracuje s vaším počítačem.", + "close_to_tray_label": "Po zavření běžet dál v oznamovací oblasti", + "close_to_tray_description": "Nová pošta a připomenutí chodí i po zavření okna.", + "start_at_login_label": "Spustit po přihlášení k počítači", + "start_at_login_description": "Aplikace se po přihlášení spustí sama.", + "start_minimized_label": "Spustit v oznamovací oblasti", + "start_minimized_description": "Po přihlášení se neotevře žádné okno. Otevřete ji ikonou v oznamovací oblasti.", + "attention_label": "Při nové poště blikat na hlavním panelu", + "attention_mac_label": "Při nové poště poskakovat ikonou v Docku", + "attention_description": "Jen když aplikace není v popředí.", + "quiet_hours_label": "Doba klidu", + "quiet_hours_description": "V tuto dobu žádná oznámení ani zvuky. Pošta dál přichází.", + "quiet_hours_from": "Od", + "quiet_hours_to": "do", + "show_shortcut_label": "Zobrazit aplikaci klávesovou zkratkou", + "show_shortcut_description": "{keys} přenese aplikaci do popředí z jakékoli jiné aplikace.", + "show_shortcut_taken": "{keys} používá jiná aplikace. Zavřete ji, nebo tuto volbu vypněte a znovu zapněte.", + "default_app_label": "Výchozí poštovní aplikace", + "default_app_description": "Otevírat poštovní odkazy a soubory .eml, .ics a .vcf v této aplikaci.", + "default_app_is_default": "Tato aplikace otevírá vaše poštovní odkazy.", + "default_app_button": "Vybrat výchozí aplikaci…", + "new_mail": "Nová pošta", + "no_subject": "(bez předmětu)", + "action_delete": "Smazat", + "copy_as_file": "Kopírovat jako soubor", + "copied": "Soubor zkopírován. Můžete ho vložit do složky.", + "copy_failed": "Soubor se nepodařilo zkopírovat.", + "reply_lost": "Napsanou odpověď se nepodařilo zachovat. Otevřete zprávu a odpovězte v ní." + }, + "desktopReminders": { + "generic_title": "Připomenutí", + "snooze": "Odložit o {count} min", + "snooze_short": "{count} min", + "open": "Otevřít", + "join": "Připojit se", + "missed_title": "{count, plural, one {# zmeškané připomenutí} few {# zmeškaná připomenutí} many {# zmeškaného připomenutí} other {# zmeškaných připomenutí}}", + "missed_more": "a další ({count})", + "missed_show": "Zobrazit", + "missed_dialog_title": "Zmeškaná připomenutí", + "missed_dialog_description": "Nastala, když byla aplikace zavřená, počítač v režimu spánku nebo oznámení pozastavená.", + "open_calendar": "Otevřít kalendář", + "done": "Hotovo", + "join_host": "Připojit se ({host})", + "join_named": "Připojit se: {title}" + }, "desktop_policy": { "server_refused": "Vaše organizace tento server na tomto zařízení nepovoluje.", "plugin_refused": "Vaše organizace tento plugin na tomto zařízení nepovoluje, proto neběží. Jeho nastavení a data zůstávají zachována.", @@ -6736,5 +6822,30 @@ "plugins_off": "Vaše organizace na tomto zařízení vypnula pluginy.", "plugins_allowlist": "Vaše organizace na tomto zařízení povoluje jen některé pluginy.", "off": "Vypnuto" + }, + "desktopTelemetry": { + "section_title": "Údaje o používání a hlášení o pádech", + "section_description": "Obojí je vypnuté, dokud to nezapnete, a každé má vlastní přepínač.", + "telemetry_label": "Odesílat údaje o používání", + "telemetry_description": "Jednou denně: náhodné ID, verze aplikace, verze Node, váš operační systém, jak dlouho aplikace běží a jak se připojuje. U vlastního poštovního serveru také zapnuté funkce, přibližný počet účtů a počet pluginů a motivů. Nikdy pošta, adresy ani názvy serverů.", + "crash_label": "Odesílat hlášení o pádech", + "crash_description": "Chyby v aplikaci se odesílají s náhodným ID, názvy souborů z kódu aplikace a krátkými, očištěnými zprávami. Když spadne sama aplikace, soubor o pádu zůstane v tomto počítači a odejde jen tehdy, když ho odešlete.", + "crash_memory": "Soubor o pádu je kopie části paměti aplikace v okamžiku pádu. Bulwark odstraní uživatelská jména, adresy a přihlašovací tokeny, které najde, ale soubor o pádu může přesto obsahovat text vaší pošty.", + "instance_note": "Na serveru Bulwark rozhoduje jeho správce zvlášť o tom, co mu stránky hlásí.", + "forced_off": "Vaše organizace to vypnula.", + "url_label": "Kam jdou soubory o pádu", + "url_description": "Adresa https, která přijímá soubor o pádu. Bulwark žádnou nemá.", + "url_invalid": "Zadejte úplnou adresu https.", + "url_save": "Uložit", + "files_label": "Soubory o pádu v tomto počítači", + "files_description": "Bulwark ponechá ty nejnovější a smaže ty starší než měsíc.", + "files_empty": "Žádné soubory o pádu.", + "file_size": "{size} KB", + "send": "Odeslat hlášení o pádu…", + "delete_all": "Smazat vše", + "sent": "Hlášení o pádu odesláno.", + "send_no_consent": "Nejprve zapněte hlášení o pádech.", + "send_no_url": "Není nastavena žádná adresa, takže to nejde kam odeslat. Zadejte ji výše.", + "send_failed": "Hlášení o pádu se nepodařilo odeslat." } } diff --git a/locales/da/common.json b/locales/da/common.json index e27af58de..27f70e6bb 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Usendte beskeder, der venter på din nye adgangskode: {count}. De bliver til {date}.", "refused_waiting_unknown": "Usendte beskeder venter måske på din nye adgangskode. De bliver til {date}.", "confirm_unsent_unknown": "Der venter måske usendte beskeder her. De går tabt.", - "confirm_templates": "E-mailskabeloner gemt på denne enhed" + "confirm_templates": "E-mailskabeloner gemt på denne enhed", + "session_waiting": "Usendte beskeder, der venter på din næste login: {count}. De bliver til {date}.", + "session_waiting_unknown": "Usendte beskeder venter måske på din næste login. De bliver til {date}.", + "needs_sign_in": "Kræver login", + "switcher_waiting": "Usendte beskeder: {count}. Slettes fra denne enhed den {date}.", + "switcher_kept": "Mailen på denne enhed slettes den {date}.", + "sibling_waiting": "{name} blev logget ud. Usendte beskeder: {count}. Log ind senest {date}, ellers slettes de fra denne enhed.", + "sibling_kept": "{name} blev logget ud. Log ind senest {date}, ellers slettes mailen fra denne enhed." }, "offlineStorage": { "keep_description": "Beholder din mail, kalender, dine kontakter og de filer, du vælger, krypteret på denne enhed. Offline kan du læse, markere og flytte mail, og det, du skriver, venter i udbakken. Når du logger ud, fjernes det hele.", @@ -5866,6 +5873,38 @@ "banner_refused": "Kladder afvist af serveren: {count}", "banner_waiting": "Kladder, der venter på serveren: {count}" }, + "offlineExport": { + "settings_label": "Eksportér gemt post", + "settings_description": "Gem posten på denne enhed som mbox- eller .eml-filer. Det virker også offline.", + "title": "Eksportér gemt post", + "description": "Dette gemmer de originaler, enheden opbevarer. .eml-filer er nøjagtige kopier. En mbox-fil tilføjer de markeringer, formatet kræver, og skriver status for læst og markeret i hver beskeds overskrifter i stedet for dem, den havde. Post, der er ældre end den valgte periode, er ikke med. Brug “Eksportér al mail” for at gemme alt fra serveren.", + "folders_label": "Mapper", + "all_folders": "Alle mapper", + "folder_kept": "{kept} af {total} her", + "loading": "Læser det, der ligger på denne enhed…", + "load_failed": "Posten på denne enhed kunne ikke læses.", + "nothing_kept": "Der er endnu ikke gemt post på denne enhed.", + "kept": "Originaler på denne enhed: {kept} af {total} beskeder", + "missing_online": "Skal hentes fra serveren: {count}", + "missing_offline": "Kan ikke eksporteres offline: {count}. Du kan prøve igen, når du er online.", + "reason_attachments_off": "Originale beskeder gemmes kun, når “Behold vedhæftede filer” er slået til.", + "reason_not_kept": "Originale beskeder hentes til sidst, og kun så længe der er plads.", + "not_kept_attachments_off": "Ingen original her, fordi vedhæftede filer ikke gemmes på denne enhed: {count}", + "not_kept_not_kept": "Endnu ingen original på denne enhed: {count}", + "retry": "Prøv igen online", + "retry_online": "Du er online nu, så de kan komme fra serveren.", + "retry_offline": "Du kan prøve igen, når du er online.", + "settings_button": "Eksportér…", + "message_failed": "Serveren leverede ikke denne besked.", + "failed_generic": "Eksporten stoppede på grund af en fejl. Prøv igen.", + "stopped_limit": "Eksporten stoppede ved {limit}, det meste denne browser kan rumme. Vælg færre mapper, eller brug Chromium, Firefox eller skrivebordsappen.", + "over_limit": "Denne browser kan højst rumme {limit}, og dette valg fylder cirka {size}. Vælg færre mapper, eller brug Chromium, Firefox eller skrivebordsappen.", + "no_room": "Denne eksport kræver cirka {size}, og denne browser har {free} tilbage. Vælg færre mapper, eller frigør plads.", + "stopped_no_room": "Browseren løb tør for plads til eksporten. Vælg færre mapper, eller frigør plads.", + "stopped_offline_off": "Eksporten stoppede, fordi denne konto ikke længere gemmer post på denne enhed.", + "policy_unknown": "Din organisations regler for denne konto kendes endnu ikke, så eksport er slået fra indtil videre.", + "leftover_file": "Filen {name} kunne ikke fjernes fra det sted, du gemte den. Den er ufuldstændig, så du kan slette den." + }, "deep_link": { "message_not_found": "Denne besked er ikke længere tilgængelig.", "thread_not_found": "Denne samtale er ikke længere tilgængelig.", @@ -6728,6 +6767,53 @@ "revoke": "Træk tilbage", "revoked": "Tilladelsen er trukket tilbage" }, + "desktopOs": { + "section_title": "Skrivebordsapp", + "section_description": "Sådan arbejder appen sammen med din computer.", + "close_to_tray_label": "Fortsæt i systembakken ved lukning", + "close_to_tray_description": "Ny post og påmindelser bliver ved med at komme, når du lukker vinduet.", + "start_at_login_label": "Start, når du logger på computeren", + "start_at_login_description": "Appen starter selv, når du logger på.", + "start_minimized_label": "Start i systembakken", + "start_minimized_description": "Der åbnes intet vindue ved logon. Åbn appen med ikonet i systembakken.", + "attention_label": "Blink i proceslinjen ved ny post", + "attention_mac_label": "Lad Dock-ikonet hoppe ved ny post", + "attention_description": "Kun når appen ikke er forrest.", + "quiet_hours_label": "Stille timer", + "quiet_hours_description": "Ingen notifikationer eller lyde i dette tidsrum. Post kommer stadig.", + "quiet_hours_from": "Fra", + "quiet_hours_to": "til", + "show_shortcut_label": "Vis appen med en tastaturgenvej", + "show_shortcut_description": "{keys} henter appen frem fra enhver anden app.", + "show_shortcut_taken": "En anden app bruger {keys}. Luk den app, eller slå dette fra og til igen.", + "default_app_label": "Standardmailapp", + "default_app_description": "Åbn maillinks og .eml-, .ics- og .vcf-filer med denne app.", + "default_app_is_default": "Denne app åbner dine maillinks.", + "default_app_button": "Vælg standardapp…", + "new_mail": "Ny post", + "no_subject": "(intet emne)", + "action_delete": "Slet", + "copy_as_file": "Kopiér som fil", + "copied": "Filen er kopieret. Du kan indsætte den i en mappe.", + "copy_failed": "Filen kunne ikke kopieres.", + "reply_lost": "Dit skrevne svar kunne ikke gemmes. Åbn beskeden, og svar der." + }, + "desktopReminders": { + "generic_title": "Påmindelse", + "snooze": "Udsæt {count} min.", + "snooze_short": "{count} min.", + "open": "Åbn", + "join": "Deltag", + "missed_title": "{count, plural, one {# overset påmindelse} other {# oversete påmindelser}}", + "missed_more": "og {count} mere", + "missed_show": "Vis", + "missed_dialog_title": "Oversete påmindelser", + "missed_dialog_description": "De forfaldt, mens appen var lukket, computeren sov, eller notifikationer var sat på pause.", + "open_calendar": "Åbn kalender", + "done": "Færdig", + "join_host": "Deltag ({host})", + "join_named": "Deltag: {title}" + }, "desktop_policy": { "server_refused": "Din organisation tillader ikke denne server på denne enhed.", "plugin_refused": "Din organisation tillader ikke dette plugin på denne enhed, så det kører ikke. Dets indstillinger og data bevares.", @@ -6736,5 +6822,30 @@ "plugins_off": "Din organisation har slået plugins fra på denne enhed.", "plugins_allowlist": "Din organisation tillader kun bestemte plugins på denne enhed.", "off": "Fra" + }, + "desktopTelemetry": { + "section_title": "Brugsdata og nedbrudsrapporter", + "section_description": "Begge er slået fra, indtil du slår dem til, og hver har sin egen kontakt.", + "telemetry_label": "Send brugsdata", + "telemetry_description": "Én gang om dagen: et tilfældigt id, appens version, Node-versionen, dit styresystem, hvor længe appen har kørt, og hvordan den forbinder. Med din egen mailserver også hvilke funktioner der er slået til, et omtrentligt antal konti og hvor mange plugins og temaer du har. Aldrig mails, adresser eller servernavne.", + "crash_label": "Send nedbrudsrapporter", + "crash_description": "Fejl i appen sendes med et tilfældigt id, filnavne fra appens egen kode og korte, rensede beskeder. Hvis selve appen går ned, bliver en nedbrudsfil på denne computer og forlader den kun, hvis du sender den.", + "crash_memory": "En nedbrudsfil er en kopi af en del af appens hukommelse, da den gik ned. Bulwark fjerner de brugernavne, adresser og login-tokens, det finder, men en nedbrudsfil kan stadig indeholde teksten fra dine mails.", + "instance_note": "På en Bulwark-server bestemmer dens administrator særskilt, hvad siderne melder til den.", + "forced_off": "Din organisation har slået dette fra.", + "url_label": "Hvor nedbrudsfiler sendes hen", + "url_description": "Den https-adresse, der modtager en nedbrudsfil. Bulwark har ingen med.", + "url_invalid": "Skriv en fuld https-adresse.", + "url_save": "Gem", + "files_label": "Nedbrudsfiler på denne computer", + "files_description": "Bulwark beholder de nyeste og sletter dem, der er ældre end en måned.", + "files_empty": "Ingen nedbrudsfiler.", + "file_size": "{size} KB", + "send": "Send nedbrudsrapport…", + "delete_all": "Slet alle", + "sent": "Nedbrudsrapport sendt.", + "send_no_consent": "Slå først nedbrudsrapporter til.", + "send_no_url": "Der er ikke angivet en adresse, så den kan ikke sendes nogen steder. Skriv en ovenfor.", + "send_failed": "Nedbrudsrapporten kunne ikke sendes." } } diff --git a/locales/de/common.json b/locales/de/common.json index 4226bef5f..005ee88bf 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Auf Ihr neues Passwort warten ungesendete Nachrichten: {count}. Sie bleiben bis zum {date}.", "refused_waiting_unknown": "Möglicherweise warten ungesendete Nachrichten auf Ihr neues Passwort. Sie bleiben bis zum {date}.", "confirm_unsent_unknown": "Hier warten möglicherweise ungesendete Nachrichten. Sie gehen verloren.", - "confirm_templates": "Auf diesem Gerät gespeicherte E-Mail-Vorlagen" + "confirm_templates": "Auf diesem Gerät gespeicherte E-Mail-Vorlagen", + "session_waiting": "Ungesendete Nachrichten, die auf Ihre nächste Anmeldung warten: {count}. Sie bleiben bis zum {date}.", + "session_waiting_unknown": "Möglicherweise warten ungesendete Nachrichten auf Ihre nächste Anmeldung. Sie bleiben bis zum {date}.", + "needs_sign_in": "Anmeldung nötig", + "switcher_waiting": "Ungesendete Nachrichten: {count}. Werden am {date} von diesem Gerät gelöscht.", + "switcher_kept": "Die E-Mails auf diesem Gerät werden am {date} gelöscht.", + "sibling_waiting": "{name} wurde abgemeldet. Ungesendete Nachrichten: {count}. Melden Sie sich bis zum {date} an, sonst werden sie von diesem Gerät gelöscht.", + "sibling_kept": "{name} wurde abgemeldet. Melden Sie sich bis zum {date} an, sonst werden die E-Mails von diesem Gerät gelöscht." }, "offlineStorage": { "keep_description": "Behält Ihre E-Mails, Ihren Kalender, Ihre Kontakte und ausgewählte Dateien verschlüsselt auf diesem Gerät. Offline können Sie E-Mails lesen, markieren und verschieben, und was Sie schreiben, wartet im Postausgang. Beim Abmelden wird alles entfernt.", @@ -5866,6 +5873,38 @@ "banner_refused": "Vom Server abgelehnte Entwürfe: {count}", "banner_waiting": "Entwürfe warten auf den Server: {count}" }, + "offlineExport": { + "settings_label": "Gespeicherte E-Mails exportieren", + "settings_description": "Speichern Sie die E-Mails auf diesem Gerät als mbox- oder .eml-Dateien. Das geht auch offline.", + "title": "Gespeicherte E-Mails exportieren", + "description": "Das speichert die Originale, die dieses Gerät aufbewahrt. .eml-Dateien sind exakte Kopien. Eine mbox-Datei ergänzt die Markierungen, die ihr Format braucht, und schreibt den Gelesen- und Markiert-Status in die Kopfzeilen jeder Nachricht, anstelle vorhandener Angaben. E-Mails, die älter als der gewählte Zeitraum sind, fehlen darin. Um alles auf dem Server zu speichern, nutzen Sie „Alle E-Mails exportieren“.", + "folders_label": "Ordner", + "all_folders": "Alle Ordner", + "folder_kept": "{kept} von {total} hier", + "loading": "Daten auf diesem Gerät werden gelesen…", + "load_failed": "Die E-Mails auf diesem Gerät konnten nicht gelesen werden.", + "nothing_kept": "Auf diesem Gerät sind noch keine E-Mails gespeichert.", + "kept": "Originale auf diesem Gerät: {kept} von {total} Nachrichten", + "missing_online": "Vom Server zu laden: {count}", + "missing_offline": "Offline nicht exportierbar: {count}. Sie können es erneut versuchen, sobald Sie online sind.", + "reason_attachments_off": "Originalnachrichten werden nur aufbewahrt, wenn „Anhänge behalten“ eingeschaltet ist.", + "reason_not_kept": "Originalnachrichten werden zuletzt geladen, und nur, solange Platz dafür ist.", + "not_kept_attachments_off": "Kein Original hier, weil Anhänge auf diesem Gerät nicht aufbewahrt werden: {count}", + "not_kept_not_kept": "Noch kein Original auf diesem Gerät: {count}", + "retry": "Online erneut versuchen", + "retry_online": "Sie sind jetzt online, diese Nachrichten können also vom Server kommen.", + "retry_offline": "Sie können es erneut versuchen, sobald Sie online sind.", + "settings_button": "Exportieren…", + "message_failed": "Der Server hat diese Nachricht nicht geliefert.", + "failed_generic": "Der Export wurde wegen eines Fehlers beendet. Versuchen Sie es erneut.", + "stopped_limit": "Der Export wurde bei {limit} beendet, mehr kann dieser Browser nicht halten. Wählen Sie weniger Ordner aus, oder nutzen Sie Chromium, Firefox oder die Desktop-App.", + "over_limit": "Dieser Browser kann höchstens {limit} halten, und diese Auswahl hat etwa {size}. Wählen Sie weniger Ordner aus, oder nutzen Sie Chromium, Firefox oder die Desktop-App.", + "no_room": "Dieser Export braucht etwa {size}, und dieser Browser hat dafür noch {free} frei. Wählen Sie weniger Ordner aus, oder schaffen Sie Platz.", + "stopped_no_room": "Dem Browser ging der Platz für den Export aus. Wählen Sie weniger Ordner aus, oder schaffen Sie Platz.", + "stopped_offline_off": "Der Export wurde beendet, weil dieses Konto keine E-Mails mehr auf diesem Gerät aufbewahrt.", + "policy_unknown": "Die Regeln Ihrer Organisation für dieses Konto sind noch nicht bekannt, daher bleibt der Export vorerst aus.", + "leftover_file": "Die Datei {name} konnte nicht vom Speicherort entfernt werden. Sie ist unvollständig, Sie können sie löschen." + }, "deep_link": { "message_not_found": "Diese Nachricht ist nicht mehr verfügbar.", "thread_not_found": "Diese Unterhaltung ist nicht mehr verfügbar.", @@ -6728,6 +6767,53 @@ "revoke": "Zurücknehmen", "revoked": "Erlaubnis zurückgenommen" }, + "desktopOs": { + "section_title": "Desktop-App", + "section_description": "Wie die App mit Ihrem Computer zusammenarbeitet.", + "close_to_tray_label": "Beim Schließen im Infobereich weiterlaufen", + "close_to_tray_description": "Neue E-Mails und Erinnerungen kommen auch dann an, wenn Sie das Fenster schließen.", + "start_at_login_label": "Beim Anmelden am Computer starten", + "start_at_login_description": "Die App startet von selbst, wenn Sie sich anmelden.", + "start_minimized_label": "Im Infobereich starten", + "start_minimized_description": "Beim Anmelden öffnet sich kein Fenster. Öffnen Sie die App über das Symbol im Infobereich.", + "attention_label": "Taskleiste bei neuen E-Mails blinken lassen", + "attention_mac_label": "Dock-Symbol bei neuen E-Mails hüpfen lassen", + "attention_description": "Nur wenn die App nicht im Vordergrund ist.", + "quiet_hours_label": "Ruhezeiten", + "quiet_hours_description": "In dieser Zeit keine Benachrichtigungen und Töne. E-Mails kommen trotzdem an.", + "quiet_hours_from": "Von", + "quiet_hours_to": "bis", + "show_shortcut_label": "App mit einer Tastenkombination anzeigen", + "show_shortcut_description": "{keys} holt die App aus jeder anderen App nach vorne.", + "show_shortcut_taken": "Eine andere App verwendet {keys}. Schließen Sie diese App oder schalten Sie das hier aus und wieder ein.", + "default_app_label": "Standard-E-Mail-App", + "default_app_description": "Mail-Links und .eml-, .ics- und .vcf-Dateien mit dieser App öffnen.", + "default_app_is_default": "Diese App öffnet Ihre Mail-Links.", + "default_app_button": "Standard-App auswählen…", + "new_mail": "Neue E-Mail", + "no_subject": "(kein Betreff)", + "action_delete": "Löschen", + "copy_as_file": "Als Datei kopieren", + "copied": "Datei kopiert. Sie können sie in einen Ordner einfügen.", + "copy_failed": "Die Datei konnte nicht kopiert werden.", + "reply_lost": "Ihre eingegebene Antwort konnte nicht übernommen werden. Öffnen Sie die Nachricht und antworten Sie dort." + }, + "desktopReminders": { + "generic_title": "Erinnerung", + "snooze": "{count} Min. schlummern", + "snooze_short": "{count} Min.", + "open": "Öffnen", + "join": "Teilnehmen", + "missed_title": "{count, plural, one {# verpasste Erinnerung} other {# verpasste Erinnerungen}}", + "missed_more": "und {count} weitere", + "missed_show": "Anzeigen", + "missed_dialog_title": "Verpasste Erinnerungen", + "missed_dialog_description": "Diese wurden fällig, während die App geschlossen war, der Computer im Ruhezustand war oder Benachrichtigungen pausiert waren.", + "open_calendar": "Kalender öffnen", + "done": "Fertig", + "join_host": "Teilnehmen ({host})", + "join_named": "Teilnehmen: {title}" + }, "desktop_policy": { "server_refused": "Ihre Organisation erlaubt diesen Server auf diesem Gerät nicht.", "plugin_refused": "Ihre Organisation erlaubt dieses Plugin auf diesem Gerät nicht, daher läuft es nicht. Seine Einstellungen und Daten bleiben erhalten.", @@ -6736,5 +6822,30 @@ "plugins_off": "Ihre Organisation hat Plugins auf diesem Gerät ausgeschaltet.", "plugins_allowlist": "Ihre Organisation erlaubt auf diesem Gerät nur bestimmte Plugins.", "off": "Aus" + }, + "desktopTelemetry": { + "section_title": "Nutzungsdaten und Absturzberichte", + "section_description": "Beides bleibt aus, bis Sie es einschalten, und jedes hat einen eigenen Schalter.", + "telemetry_label": "Nutzungsdaten senden", + "telemetry_description": "Einmal am Tag: eine zufällige ID, die App-Version, die Node-Version, Ihr Betriebssystem, wie lange die App schon läuft und die Art der Verbindung. Bei einem eigenen Mailserver auch, welche Funktionen aktiv sind, eine ungefähre Zahl der Konten und wie viele Plugins und Designs Sie haben. Niemals E-Mails, Adressen oder Servernamen.", + "crash_label": "Absturzberichte senden", + "crash_description": "Fehler in der App werden mit einer zufälligen ID, Dateinamen aus dem eigenen Code der App und kurzen, bereinigten Meldungen gesendet. Stürzt die App selbst ab, bleibt eine Absturzdatei auf diesem Computer und verlässt ihn nur, wenn Sie sie senden.", + "crash_memory": "Eine Absturzdatei ist eine Kopie eines Teils des Arbeitsspeichers der App beim Absturz. Bulwark entfernt Benutzernamen, Adressen und Anmeldetokens, die es findet, aber eine Absturzdatei kann trotzdem den Text Ihrer E-Mails enthalten.", + "instance_note": "Bei einem Bulwark-Server entscheidet dessen Administrator getrennt davon, was seine Seiten an ihn melden.", + "forced_off": "Ihre Organisation hat das ausgeschaltet.", + "url_label": "Ziel der Absturzdateien", + "url_description": "Die https-Adresse, die eine Absturzdatei empfängt. Bulwark bringt keine mit.", + "url_invalid": "Geben Sie eine vollständige https-Adresse ein.", + "url_save": "Speichern", + "files_label": "Absturzdateien auf diesem Computer", + "files_description": "Bulwark behält die neuesten und löscht alles, was älter als einen Monat ist.", + "files_empty": "Keine Absturzdateien.", + "file_size": "{size} KB", + "send": "Absturzbericht senden…", + "delete_all": "Alle löschen", + "sent": "Absturzbericht gesendet.", + "send_no_consent": "Schalten Sie zuerst die Absturzberichte ein.", + "send_no_url": "Es ist keine Adresse eingetragen, daher gibt es kein Ziel. Geben Sie oben eine ein.", + "send_failed": "Der Absturzbericht konnte nicht gesendet werden." } } diff --git a/locales/en/common.json b/locales/en/common.json index c25010472..ab2f54b1b 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "{count, plural, one {# unsent message waits} other {# unsent messages wait}} for your new password, until {date}.", "refused_waiting_unknown": "Unsent messages may be waiting for your new password, until {date}.", "confirm_unsent_unknown": "Unsent messages may be waiting here. They will be lost.", - "confirm_templates": "Email templates saved on this device" + "confirm_templates": "Email templates saved on this device", + "session_waiting": "{count, plural, one {# unsent message waits} other {# unsent messages wait}} for you to sign in again, until {date}.", + "session_waiting_unknown": "Unsent messages may be waiting for you to sign in again, until {date}.", + "needs_sign_in": "Needs sign-in", + "switcher_waiting": "Unsent messages: {count}. Deleted from this device on {date}.", + "switcher_kept": "Its mail on this device is deleted on {date}.", + "sibling_waiting": "{name} was signed out. Unsent messages: {count}. Sign in by {date} or they're deleted from this device.", + "sibling_kept": "{name} was signed out. Sign in by {date} or its mail is deleted from this device." }, "offlineStorage": { "keep_description": "Keeps your mail, calendar, contacts and chosen files encrypted on this device. Offline you can read, flag and move mail, and what you write waits in the outbox. Signing out removes all of it.", @@ -5866,6 +5873,38 @@ "banner_refused": "Drafts the server refused: {count}", "banner_waiting": "Drafts waiting for the server: {count}" }, + "offlineExport": { + "settings_label": "Export kept mail", + "settings_description": "Save the mail on this device as mbox or .eml files. This works offline.", + "title": "Export kept mail", + "description": "This saves the originals this device keeps. .eml files are exact copies. An mbox file adds the markings its format needs and writes read and flag state into each message's headers, in place of any it had. Mail older than the period you chose isn't in it. To save everything on the server, use “Export all mail”.", + "folders_label": "Folders", + "all_folders": "All folders", + "folder_kept": "{kept} of {total} here", + "loading": "Reading what's on this device…", + "load_failed": "The mail on this device couldn't be read.", + "nothing_kept": "No mail is kept on this device yet.", + "kept": "Originals on this device: {kept} of {total} messages", + "missing_online": "To download from the server: {count}", + "missing_offline": "Can't be exported while you're offline: {count}. You can try again when you're online.", + "reason_attachments_off": "Original messages are only kept when “Keep attachments” is on.", + "reason_not_kept": "Original messages download last, and only while there's room for them.", + "not_kept_attachments_off": "No original here because attachments aren't kept on this device: {count}", + "not_kept_not_kept": "No original on this device yet: {count}", + "retry": "Try again online", + "retry_online": "You're online now, so these can come from the server.", + "retry_offline": "You can try again when you're online.", + "settings_button": "Export…", + "message_failed": "The server didn't give this message.", + "failed_generic": "The export stopped because of an error. Try again.", + "stopped_limit": "The export stopped at {limit}, the most this browser can hold. Choose fewer folders, or use Chromium, Firefox or the desktop app.", + "over_limit": "This browser can hold at most {limit}, and this choice is about {size}. Choose fewer folders, or use Chromium, Firefox or the desktop app.", + "no_room": "This export needs about {size}, and this browser has {free} left for it. Choose fewer folders or free up space.", + "stopped_no_room": "The browser ran out of room for the export. Choose fewer folders or free up space.", + "stopped_offline_off": "The export stopped because this account no longer keeps mail on this device.", + "policy_unknown": "Your organisation's rules for this account aren't known yet, so exporting stays off for now.", + "leftover_file": "The file {name} couldn't be removed from where you saved it. It's incomplete, so you can delete it." + }, "deep_link": { "message_not_found": "This message is no longer available.", "thread_not_found": "This conversation is no longer available.", @@ -6728,6 +6767,53 @@ "revoke": "Take back", "revoked": "Permission taken back" }, + "desktopOs": { + "section_title": "Desktop app", + "section_description": "How the app works with your computer.", + "close_to_tray_label": "Keep running in the tray when closed", + "close_to_tray_description": "New mail and reminders keep coming after you close the window.", + "start_at_login_label": "Start when you sign in to your computer", + "start_at_login_description": "The app starts by itself when you sign in.", + "start_minimized_label": "Start in the tray", + "start_minimized_description": "No window opens at sign-in. Use the tray icon to open it.", + "attention_label": "Flash the taskbar on new mail", + "attention_mac_label": "Bounce the Dock icon on new mail", + "attention_description": "Only when the app is not in front.", + "quiet_hours_label": "Quiet hours", + "quiet_hours_description": "No notifications or sounds at these times. Mail still arrives.", + "quiet_hours_from": "From", + "quiet_hours_to": "to", + "show_shortcut_label": "Show the app with a keyboard shortcut", + "show_shortcut_description": "{keys} brings the app to the front, from any other app.", + "show_shortcut_taken": "Another app is using {keys}. Close that app, or turn this off and on again.", + "default_app_label": "Default mail app", + "default_app_description": "Open mail links and .eml, .ics and .vcf files with this app.", + "default_app_is_default": "This app opens your mail links.", + "default_app_button": "Choose default app…", + "new_mail": "New mail", + "no_subject": "(no subject)", + "action_delete": "Delete", + "copy_as_file": "Copy as file", + "copied": "File copied. You can paste it into a folder.", + "copy_failed": "The file could not be copied.", + "reply_lost": "Your typed reply could not be kept. Open the message and reply there." + }, + "desktopReminders": { + "generic_title": "Reminder", + "snooze": "Snooze {count} min", + "snooze_short": "{count} min", + "open": "Open", + "join": "Join", + "missed_title": "{count, plural, one {# reminder you missed} other {# reminders you missed}}", + "missed_more": "and {count} more", + "missed_show": "Show", + "missed_dialog_title": "Reminders you missed", + "missed_dialog_description": "These came due while the app was closed, the computer was asleep or notifications were paused.", + "open_calendar": "Open calendar", + "done": "Done", + "join_host": "Join ({host})", + "join_named": "Join: {title}" + }, "desktop_policy": { "server_refused": "Your organisation doesn't allow this server on this device.", "plugin_refused": "Your organisation doesn't allow this plugin on this device, so it doesn't run. Its settings and data are kept.", @@ -6736,5 +6822,30 @@ "plugins_off": "Your organisation turned off plugins on this device.", "plugins_allowlist": "Your organisation only allows certain plugins on this device.", "off": "Off" + }, + "desktopTelemetry": { + "section_title": "Usage info and crash reports", + "section_description": "Both are off until you turn them on, and each has its own switch.", + "telemetry_label": "Send usage info", + "telemetry_description": "Once a day: a random ID, the app version, the Node version, your operating system, how long the app has been running and how it connects. With your own mail server, also which features are on, a rough number of accounts and how many plugins and themes you have. Never mail, addresses or server names.", + "crash_label": "Send crash reports", + "crash_description": "Errors in the app go out with a random ID, file names from the app's own code and short, cleaned-up messages. If the app itself crashes, a crash file stays on this computer and only leaves when you send it.", + "crash_memory": "A crash file is a copy of part of the app's memory when it crashed. Bulwark removes user names, addresses and sign-in tokens it can find, but a crash file can still contain the text of your mail.", + "instance_note": "On a Bulwark server, that server's admin decides separately what its pages report to it.", + "forced_off": "Your organisation turned this off.", + "url_label": "Where crash files go", + "url_description": "The https address that receives a crash file. Bulwark doesn't come with one.", + "url_invalid": "Enter a full https address.", + "url_save": "Save", + "files_label": "Crash files on this computer", + "files_description": "Bulwark keeps the newest few and deletes ones older than a month.", + "files_empty": "No crash files.", + "file_size": "{size} KB", + "send": "Send crash report…", + "delete_all": "Delete all", + "sent": "Crash report sent.", + "send_no_consent": "Turn on crash reports first.", + "send_no_url": "No address is set, so there's nowhere to send this. Enter one above.", + "send_failed": "The crash report couldn't be sent." } } diff --git a/locales/es/common.json b/locales/es/common.json index cc1f42603..8e565bf26 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Mensajes sin enviar que esperan tu nueva contraseña: {count}. Se conservan hasta el {date}.", "refused_waiting_unknown": "Puede que haya mensajes sin enviar esperando tu nueva contraseña. Se conservan hasta el {date}.", "confirm_unsent_unknown": "Puede que haya mensajes sin enviar esperando aquí. Se perderán.", - "confirm_templates": "Plantillas de correo guardadas en este dispositivo" + "confirm_templates": "Plantillas de correo guardadas en este dispositivo", + "session_waiting": "Mensajes sin enviar que esperan tu próximo inicio de sesión: {count}. Se conservan hasta el {date}.", + "session_waiting_unknown": "Puede que haya mensajes sin enviar esperando tu próximo inicio de sesión. Se conservan hasta el {date}.", + "needs_sign_in": "Requiere iniciar sesión", + "switcher_waiting": "Mensajes sin enviar: {count}. Se eliminarán de este dispositivo el {date}.", + "switcher_kept": "Su correo en este dispositivo se eliminará el {date}.", + "sibling_waiting": "Se ha cerrado la sesión de {name}. Mensajes sin enviar: {count}. Inicia sesión antes del {date} o se eliminarán de este dispositivo.", + "sibling_kept": "Se ha cerrado la sesión de {name}. Inicia sesión antes del {date} o su correo se eliminará de este dispositivo." }, "offlineStorage": { "keep_description": "Guarda tu correo, tu calendario, tus contactos y los archivos que elijas, cifrados en este dispositivo. Sin conexión puedes leer, marcar y mover correo, y lo que escribas espera en la bandeja de salida. Al cerrar sesión se borra todo.", @@ -5866,6 +5873,38 @@ "banner_refused": "Borradores rechazados por el servidor: {count}", "banner_waiting": "Borradores esperando al servidor: {count}" }, + "offlineExport": { + "settings_label": "Exportar el correo guardado", + "settings_description": "Guarda el correo de este dispositivo como archivos mbox o .eml. Funciona sin conexión.", + "title": "Exportar el correo guardado", + "description": "Guarda los originales que conserva este dispositivo. Los archivos .eml son copias exactas. Un archivo mbox añade las marcas que requiere su formato y escribe el estado de leído y marcado en los encabezados de cada mensaje, en lugar de los que tuviera. El correo anterior al periodo elegido no está incluido. Para guardar todo lo del servidor, usa «Exportar todo el correo».", + "folders_label": "Carpetas", + "all_folders": "Todas las carpetas", + "folder_kept": "{kept} de {total} aquí", + "loading": "Leyendo lo que hay en este dispositivo…", + "load_failed": "No se pudo leer el correo de este dispositivo.", + "nothing_kept": "Todavía no hay correo guardado en este dispositivo.", + "kept": "Originales en este dispositivo: {kept} de {total} mensajes", + "missing_online": "Por descargar del servidor: {count}", + "missing_offline": "No se pueden exportar sin conexión: {count}. Puedes volver a intentarlo cuando tengas conexión.", + "reason_attachments_off": "Los mensajes originales solo se guardan si «Guardar adjuntos» está activado.", + "reason_not_kept": "Los mensajes originales se descargan al final, y solo si queda espacio.", + "not_kept_attachments_off": "Sin original aquí porque este dispositivo no guarda adjuntos: {count}", + "not_kept_not_kept": "Todavía sin original en este dispositivo: {count}", + "retry": "Reintentar con conexión", + "retry_online": "Ya tienes conexión, así que estos pueden venir del servidor.", + "retry_offline": "Puedes volver a intentarlo cuando tengas conexión.", + "settings_button": "Exportar…", + "message_failed": "El servidor no entregó este mensaje.", + "failed_generic": "La exportación se detuvo por un error. Inténtalo de nuevo.", + "stopped_limit": "La exportación se detuvo en {limit}, lo máximo que puede contener este navegador. Elige menos carpetas o usa Chromium, Firefox o la aplicación de escritorio.", + "over_limit": "Este navegador puede contener como máximo {limit}, y esta selección ocupa unos {size}. Elige menos carpetas o usa Chromium, Firefox o la aplicación de escritorio.", + "no_room": "Esta exportación necesita unos {size}, y este navegador tiene {free} disponibles. Elige menos carpetas o libera espacio.", + "stopped_no_room": "El navegador se quedó sin espacio para la exportación. Elige menos carpetas o libera espacio.", + "stopped_offline_off": "La exportación se detuvo porque esta cuenta ya no guarda correo en este dispositivo.", + "policy_unknown": "Aún no se conocen las reglas de tu organización para esta cuenta, así que la exportación sigue desactivada por ahora.", + "leftover_file": "No se pudo quitar el archivo {name} de donde lo guardaste. Está incompleto, puedes borrarlo." + }, "deep_link": { "message_not_found": "Este mensaje ya no está disponible.", "thread_not_found": "Esta conversación ya no está disponible.", @@ -6728,6 +6767,53 @@ "revoke": "Retirar", "revoked": "Permiso retirado" }, + "desktopOs": { + "section_title": "Aplicación de escritorio", + "section_description": "Cómo funciona la aplicación con tu ordenador.", + "close_to_tray_label": "Seguir en la bandeja al cerrar", + "close_to_tray_description": "El correo nuevo y los recordatorios siguen llegando después de cerrar la ventana.", + "start_at_login_label": "Iniciar al entrar en el ordenador", + "start_at_login_description": "La aplicación se inicia sola cuando entras.", + "start_minimized_label": "Iniciar en la bandeja", + "start_minimized_description": "No se abre ninguna ventana al entrar. Ábrela con el icono de la bandeja.", + "attention_label": "Hacer parpadear la barra de tareas con correo nuevo", + "attention_mac_label": "Hacer rebotar el icono del Dock con correo nuevo", + "attention_description": "Solo cuando la aplicación no está delante.", + "quiet_hours_label": "Horas de silencio", + "quiet_hours_description": "Sin notificaciones ni sonidos en estas horas. El correo sigue llegando.", + "quiet_hours_from": "De", + "quiet_hours_to": "a", + "show_shortcut_label": "Mostrar la aplicación con un atajo de teclado", + "show_shortcut_description": "{keys} trae la aplicación al frente desde cualquier otra.", + "show_shortcut_taken": "Otra aplicación está usando {keys}. Ciérrala, o desactiva y vuelve a activar esta opción.", + "default_app_label": "Aplicación de correo predeterminada", + "default_app_description": "Abrir los enlaces de correo y los archivos .eml, .ics y .vcf con esta aplicación.", + "default_app_is_default": "Esta aplicación abre tus enlaces de correo.", + "default_app_button": "Elegir aplicación predeterminada…", + "new_mail": "Correo nuevo", + "no_subject": "(sin asunto)", + "action_delete": "Eliminar", + "copy_as_file": "Copiar como archivo", + "copied": "Archivo copiado. Puedes pegarlo en una carpeta.", + "copy_failed": "No se pudo copiar el archivo.", + "reply_lost": "No se pudo conservar la respuesta que escribiste. Abre el mensaje y responde desde allí." + }, + "desktopReminders": { + "generic_title": "Recordatorio", + "snooze": "Posponer {count} min", + "snooze_short": "{count} min", + "open": "Abrir", + "join": "Unirse", + "missed_title": "{count, plural, one {# recordatorio perdido} other {# recordatorios perdidos}}", + "missed_more": "y {count} más", + "missed_show": "Mostrar", + "missed_dialog_title": "Recordatorios perdidos", + "missed_dialog_description": "Vencieron mientras la aplicación estaba cerrada, el ordenador en reposo o las notificaciones en pausa.", + "open_calendar": "Abrir calendario", + "done": "Listo", + "join_host": "Unirse ({host})", + "join_named": "Unirse: {title}" + }, "desktop_policy": { "server_refused": "Tu organización no permite este servidor en este dispositivo.", "plugin_refused": "Tu organización no permite este complemento en este dispositivo, por lo que no se ejecuta. Se conservan sus ajustes y datos.", @@ -6736,5 +6822,30 @@ "plugins_off": "Tu organización ha desactivado los complementos en este dispositivo.", "plugins_allowlist": "Tu organización solo permite determinados complementos en este dispositivo.", "off": "Desactivado" + }, + "desktopTelemetry": { + "section_title": "Datos de uso e informes de fallos", + "section_description": "Ambos están desactivados hasta que los actives, y cada uno tiene su propio interruptor.", + "telemetry_label": "Enviar datos de uso", + "telemetry_description": "Una vez al día: un ID aleatorio, la versión de la aplicación, la versión de Node, tu sistema operativo, cuánto tiempo lleva abierta la aplicación y cómo se conecta. Con tu propio servidor de correo, también qué funciones están activas, un número aproximado de cuentas y cuántos plugins y temas tienes. Nunca correos, direcciones ni nombres de servidor.", + "crash_label": "Enviar informes de fallos", + "crash_description": "Los errores de la aplicación se envían con un ID aleatorio, nombres de archivo del propio código de la aplicación y mensajes breves y limpios. Si la propia aplicación falla, un archivo de fallo queda en este ordenador y solo sale de él si lo envías.", + "crash_memory": "Un archivo de fallo es una copia de parte de la memoria de la aplicación en el momento del fallo. Bulwark elimina los nombres de usuario, las direcciones y los tokens de inicio de sesión que encuentra, pero un archivo de fallo aún puede contener el texto de tus correos.", + "instance_note": "En un servidor Bulwark, su administrador decide por separado qué le comunican sus páginas.", + "forced_off": "Tu organización ha desactivado esto.", + "url_label": "Destino de los archivos de fallo", + "url_description": "La dirección https que recibe un archivo de fallo. Bulwark no incluye ninguna.", + "url_invalid": "Escribe una dirección https completa.", + "url_save": "Guardar", + "files_label": "Archivos de fallo en este ordenador", + "files_description": "Bulwark conserva los más recientes y elimina los de más de un mes.", + "files_empty": "No hay archivos de fallo.", + "file_size": "{size} KB", + "send": "Enviar informe de fallo…", + "delete_all": "Eliminar todo", + "sent": "Informe de fallo enviado.", + "send_no_consent": "Activa primero los informes de fallos.", + "send_no_url": "No hay ninguna dirección, así que no hay adónde enviarlo. Escribe una arriba.", + "send_failed": "No se ha podido enviar el informe de fallo." } } diff --git a/locales/et/common.json b/locales/et/common.json index 741317c50..7989bfcc7 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Saatmata sõnumid ootavad teie uut parooli: {count}. Need jäävad alles kuni {date}.", "refused_waiting_unknown": "Saatmata sõnumid võivad oodata teie uut parooli. Need jäävad alles kuni {date}.", "confirm_unsent_unknown": "Siin võivad oodata saatmata sõnumid. Need lähevad kaduma.", - "confirm_templates": "Selles seadmes salvestatud e-kirjade mallid" + "confirm_templates": "Selles seadmes salvestatud e-kirjade mallid", + "session_waiting": "Saatmata sõnumid ootavad järgmist sisselogimist: {count}. Need jäävad alles kuni {date}.", + "session_waiting_unknown": "Saatmata sõnumid võivad oodata järgmist sisselogimist. Need jäävad alles kuni {date}.", + "needs_sign_in": "Vajab sisselogimist", + "switcher_waiting": "Saatmata sõnumid: {count}. Kustutatakse sellest seadmest {date}.", + "switcher_kept": "Selle seadme e-kirjad kustutatakse {date}.", + "sibling_waiting": "{name} logiti välja. Saatmata sõnumid: {count}. Logige sisse hiljemalt {date}, muidu kustutatakse need sellest seadmest.", + "sibling_kept": "{name} logiti välja. Logige sisse hiljemalt {date}, muidu kustutatakse e-kirjad sellest seadmest." }, "offlineStorage": { "keep_description": "Hoiab sinu kirju, kalendrit, kontakte ja valitud faile krüptitult selles seadmes. Võrguta saad kirju lugeda, märgistada ja teisaldada ning see, mida kirjutad, ootab väljuvate kirjade kastis. Välja logides eemaldatakse kõik.", @@ -5866,6 +5873,38 @@ "banner_refused": "Serveri tagasi lükatud mustandid: {count}", "banner_waiting": "Serverit ootavad mustandid: {count}" }, + "offlineExport": { + "settings_label": "Hoitud kirjade eksport", + "settings_description": "Salvestage selle seadme kirjad mbox- või .eml-failidena. See töötab ka võrguühenduseta.", + "title": "Hoitud kirjade eksport", + "description": "Salvestab originaalid, mida see seade hoiab. .eml-failid on täpsed koopiad. mbox-fail lisab vormingu nõutud märgid ja kirjutab loetud ja märgistatud oleku iga kirja päistesse senise asemele. Valitud ajavahemikust vanemaid kirju selles pole. Kõige salvestamiseks serverist kasutage valikut „Ekspordi kõik kirjad”.", + "folders_label": "Kaustad", + "all_folders": "Kõik kaustad", + "folder_kept": "Siin: {kept} / {total}", + "loading": "Selle seadme andmete lugemine…", + "load_failed": "Selle seadme kirju ei õnnestunud lugeda.", + "nothing_kept": "Selles seadmes pole veel ühtegi kirja hoitud.", + "kept": "Originaalid selles seadmes: {kept} / {total} kirja", + "missing_online": "Serverist alla laadida: {count}", + "missing_offline": "Võrguühenduseta ei saa eksportida: {count}. Proovige uuesti, kui olete võrgus.", + "reason_attachments_off": "Originaalkirju hoitakse ainult siis, kui „Hoia manuseid” on sees.", + "reason_not_kept": "Originaalkirjad laaditakse alla viimasena ja ainult siis, kui ruumi on.", + "not_kept_attachments_off": "Originaali pole, sest see seade ei hoia manuseid: {count}", + "not_kept_not_kept": "Selles seadmes pole veel originaali: {count}", + "retry": "Proovi uuesti võrgus", + "retry_online": "Olete nüüd võrgus, nii et need võivad tulla serverist.", + "retry_offline": "Proovige uuesti, kui olete võrgus.", + "settings_button": "Ekspordi…", + "message_failed": "Server ei andnud seda kirja.", + "failed_generic": "Eksport peatus vea tõttu. Proovige uuesti.", + "stopped_limit": "Eksport peatus piiril {limit}, rohkem see brauser ei mahuta. Valige vähem kaustu või kasutage Chromiumi, Firefoxi või töölauarakendust.", + "over_limit": "See brauser mahutab kuni {limit}, see valik on umbes {size}. Valige vähem kaustu või kasutage Chromiumi, Firefoxi või töölauarakendust.", + "no_room": "See eksport vajab umbes {size}, brauseril on selleks {free}. Valige vähem kaustu või vabastage ruumi.", + "stopped_no_room": "Brauseril sai ekspordi jaoks ruum otsa. Valige vähem kaustu või vabastage ruumi.", + "stopped_offline_off": "Eksport peatus, sest see konto ei hoia enam selles seadmes kirju.", + "policy_unknown": "Teie organisatsiooni reeglid selle konto kohta pole veel teada, seega on eksport praegu väljas.", + "leftover_file": "Faili {name} ei saanud salvestuskohast eemaldada. See on poolik, võite selle kustutada." + }, "deep_link": { "message_not_found": "See sõnum ei ole enam saadaval.", "thread_not_found": "See vestlus ei ole enam saadaval.", @@ -6728,6 +6767,53 @@ "revoke": "Võta tagasi", "revoked": "Luba võeti tagasi" }, + "desktopOs": { + "section_title": "Töölauarakendus", + "section_description": "Kuidas rakendus sinu arvutiga koos töötab.", + "close_to_tray_label": "Sulgemisel jätka salves", + "close_to_tray_description": "Uus kiri ja meeldetuletused saabuvad ka pärast akna sulgemist.", + "start_at_login_label": "Käivita arvutisse sisselogimisel", + "start_at_login_description": "Rakendus käivitub sisselogimisel ise.", + "start_minimized_label": "Käivita salves", + "start_minimized_description": "Sisselogimisel akent ei avata. Ava rakendus salve ikoonist.", + "attention_label": "Vilguta tegumiriba uue kirja korral", + "attention_mac_label": "Põrgata Docki ikooni uue kirja korral", + "attention_description": "Ainult siis, kui rakendus pole esiplaanil.", + "quiet_hours_label": "Vaikne aeg", + "quiet_hours_description": "Sel ajal teavitusi ega helisid pole. Kirjad saabuvad ikka.", + "quiet_hours_from": "Alates", + "quiet_hours_to": "kuni", + "show_shortcut_label": "Näita rakendust kiirklahviga", + "show_shortcut_description": "{keys} toob rakenduse esiplaanile igast teisest rakendusest.", + "show_shortcut_taken": "{keys} on teise rakenduse kasutuses. Sulge see rakendus või lülita see valik välja ja uuesti sisse.", + "default_app_label": "Vaikimisi e-posti rakendus", + "default_app_description": "Ava e-posti lingid ning .eml-, .ics- ja .vcf-failid selle rakendusega.", + "default_app_is_default": "See rakendus avab sinu e-posti lingid.", + "default_app_button": "Vali vaikerakendus…", + "new_mail": "Uus kiri", + "no_subject": "(teema puudub)", + "action_delete": "Kustuta", + "copy_as_file": "Kopeeri failina", + "copied": "Fail on kopeeritud. Saad selle kausta kleepida.", + "copy_failed": "Faili ei saanud kopeerida.", + "reply_lost": "Kirjutatud vastust ei saanud säilitada. Ava kiri ja vasta seal." + }, + "desktopReminders": { + "generic_title": "Meeldetuletus", + "snooze": "Lükka {count} min edasi", + "snooze_short": "{count} min", + "open": "Ava", + "join": "Liitu", + "missed_title": "{count, plural, one {# kasutamata meeldetuletus} other {# kasutamata meeldetuletust}}", + "missed_more": "ja veel {count}", + "missed_show": "Näita", + "missed_dialog_title": "Kasutamata meeldetuletused", + "missed_dialog_description": "Need saabusid, kui rakendus oli suletud, arvuti puhkas või teavitused olid peatatud.", + "open_calendar": "Ava kalender", + "done": "Valmis", + "join_host": "Liitu ({host})", + "join_named": "Liitu: {title}" + }, "desktop_policy": { "server_refused": "Sinu organisatsioon ei luba seda serverit selles seadmes.", "plugin_refused": "Sinu organisatsioon ei luba seda pistikprogrammi selles seadmes, seega see ei tööta. Selle seaded ja andmed säilivad.", @@ -6736,5 +6822,30 @@ "plugins_off": "Sinu organisatsioon on selles seadmes pistikprogrammid välja lülitanud.", "plugins_allowlist": "Sinu organisatsioon lubab selles seadmes ainult teatud pistikprogramme.", "off": "Väljas" + }, + "desktopTelemetry": { + "section_title": "Kasutusandmed ja kokkujooksmise aruanded", + "section_description": "Mõlemad on välja lülitatud, kuni need sisse lülitate, ja kummalgi on oma lüliti.", + "telemetry_label": "Saada kasutusandmed", + "telemetry_description": "Kord päevas: juhuslik ID, rakenduse versioon, Node'i versioon, teie operatsioonisüsteem, kui kaua rakendus on töötanud ja kuidas see ühendub. Oma meiliserveri puhul ka see, millised funktsioonid on sees, kontode ligikaudne arv ning pistikprogrammide ja kujunduste arv. Mitte kunagi kirju, aadresse ega serverinimesid.", + "crash_label": "Saada kokkujooksmise aruanded", + "crash_description": "Rakenduse vead saadetakse koos juhusliku ID, rakenduse enda koodi failinimede ja lühikeste puhastatud teadetega. Kui rakendus ise kokku jookseb, jääb kokkujooksmise fail sellesse arvutisse ja lahkub sealt ainult siis, kui selle saadate.", + "crash_memory": "Kokkujooksmise fail on koopia osast rakenduse mälust kokkujooksmise hetkel. Bulwark eemaldab leitud kasutajanimed, aadressid ja sisselogimislubad, kuid kokkujooksmise fail võib siiski sisaldada teie kirjade teksti.", + "instance_note": "Bulwarki serveris otsustab selle administraator eraldi, mida lehed talle teatavad.", + "forced_off": "Teie organisatsioon on selle välja lülitanud.", + "url_label": "Kuhu kokkujooksmise failid lähevad", + "url_description": "Https-aadress, mis võtab kokkujooksmise faili vastu. Bulwarkil pole oma aadressi.", + "url_invalid": "Sisestage täielik https-aadress.", + "url_save": "Salvesta", + "files_label": "Kokkujooksmise failid selles arvutis", + "files_description": "Bulwark hoiab alles uusimad ja kustutab kuust vanemad.", + "files_empty": "Kokkujooksmise faile pole.", + "file_size": "{size} KB", + "send": "Saada kokkujooksmise aruanne…", + "delete_all": "Kustuta kõik", + "sent": "Kokkujooksmise aruanne saadetud.", + "send_no_consent": "Lülitage esmalt kokkujooksmise aruanded sisse.", + "send_no_url": "Aadressi pole määratud, seega pole seda kuhugi saata. Sisestage see ülal.", + "send_failed": "Kokkujooksmise aruannet ei õnnestunud saata." } } diff --git a/locales/fa/common.json b/locales/fa/common.json index ba4e47746..8c30d51a7 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "پیام‌های ارسال‌نشده در انتظار گذرواژه جدید شما: {count}. تا {date} نگه داشته می‌شوند.", "refused_waiting_unknown": "شاید پیام‌های ارسال‌نشده در انتظار گذرواژه جدید شما باشند. تا {date} نگه داشته می‌شوند.", "confirm_unsent_unknown": "شاید پیام‌های ارسال‌نشده اینجا منتظر باشند. از بین خواهند رفت.", - "confirm_templates": "الگوهای ایمیل ذخیره‌شده در این دستگاه" + "confirm_templates": "الگوهای ایمیل ذخیره‌شده در این دستگاه", + "session_waiting": "پیام‌های ارسال‌نشده در انتظار ورود بعدی شما: {count}. تا {date} نگه داشته می‌شوند.", + "session_waiting_unknown": "شاید پیام‌های ارسال‌نشده در انتظار ورود بعدی شما باشند. تا {date} نگه داشته می‌شوند.", + "needs_sign_in": "نیاز به ورود دارد", + "switcher_waiting": "پیام‌های ارسال‌نشده: {count}. در {date} از این دستگاه حذف می‌شوند.", + "switcher_kept": "ایمیل‌های این دستگاه در {date} حذف می‌شوند.", + "sibling_waiting": "{name} از حساب خارج شد. پیام‌های ارسال‌نشده: {count}. تا {date} وارد شوید، وگرنه از این دستگاه حذف می‌شوند.", + "sibling_kept": "{name} از حساب خارج شد. تا {date} وارد شوید، وگرنه ایمیل‌ها از این دستگاه حذف می‌شوند." }, "offlineStorage": { "keep_description": "ایمیل‌ها، تقویم، مخاطبان و فایل‌هایی را که انتخاب می‌کنید به‌صورت رمزگذاری‌شده در این دستگاه نگه می‌دارد. در حالت آفلاین می‌توانید ایمیل‌ها را بخوانید، پرچم بزنید و جابه‌جا کنید، و آنچه می‌نویسید در صندوق خروجی منتظر می‌ماند. با خروج از حساب همه پاک می‌شود.", @@ -5866,6 +5873,38 @@ "banner_refused": "پیش‌نویس‌های ردشده توسط سرور: {count}", "banner_waiting": "پیش‌نویس‌های در انتظار سرور: {count}" }, + "offlineExport": { + "settings_label": "برون‌بری نامه‌های نگه‌داشته‌شده", + "settings_description": "نامه‌های این دستگاه را به‌صورت فایل mbox یا ‎.eml ذخیره کنید. بدون اتصال هم کار می‌کند.", + "title": "برون‌بری نامه‌های نگه‌داشته‌شده", + "description": "نسخه‌های اصلی که این دستگاه نگه داشته است را ذخیره می‌کند. فایل‌های ‎.eml کپی دقیق هستند. فایل mbox نشانه‌هایی را که قالبش لازم دارد اضافه می‌کند و وضعیت خوانده‌شده و پرچم‌دار را به جای مقادیر قبلی در سرآیندهای هر پیام می‌نویسد. نامه‌های قدیمی‌تر از بازه‌ای که انتخاب کرده‌اید در آن نیستند. برای ذخیرهٔ همه‌چیز از سرور، از «برون‌بری همه نامه‌ها» استفاده کنید.", + "folders_label": "پوشه‌ها", + "all_folders": "همهٔ پوشه‌ها", + "folder_kept": "اینجا: {kept} از {total}", + "loading": "در حال خواندن داده‌های این دستگاه…", + "load_failed": "نامه‌های این دستگاه خوانده نشد.", + "nothing_kept": "هنوز نامه‌ای روی این دستگاه نگه داشته نشده است.", + "kept": "نسخه‌های اصلی روی این دستگاه: {kept} از {total} پیام", + "missing_online": "برای دانلود از سرور: {count}", + "missing_offline": "بدون اتصال برون‌بری نمی‌شود: {count}. وقتی آنلاین شدید دوباره امتحان کنید.", + "reason_attachments_off": "پیام‌های اصلی فقط وقتی نگه داشته می‌شوند که «نگه داشتن پیوست‌ها» روشن باشد.", + "reason_not_kept": "پیام‌های اصلی آخر از همه و فقط تا وقتی جا باشد دانلود می‌شوند.", + "not_kept_attachments_off": "نسخهٔ اصلی نیست چون این دستگاه پیوست‌ها را نگه نمی‌دارد: {count}", + "not_kept_not_kept": "هنوز نسخهٔ اصلی روی این دستگاه نیست: {count}", + "retry": "تلاش دوباره به‌صورت آنلاین", + "retry_online": "اکنون آنلاین هستید، پس می‌توانند از سرور بیایند.", + "retry_offline": "وقتی آنلاین شدید دوباره امتحان کنید.", + "settings_button": "برون‌بری…", + "message_failed": "سرور این پیام را تحویل نداد.", + "failed_generic": "برون‌بری به دلیل خطا متوقف شد. دوباره امتحان کنید.", + "stopped_limit": "برون‌بری در {limit} متوقف شد، بیشترین مقداری که این مرورگر می‌تواند نگه دارد. پوشه‌های کمتری انتخاب کنید یا از Chromium، Firefox یا برنامهٔ رومیزی استفاده کنید.", + "over_limit": "این مرورگر حداکثر {limit} را نگه می‌دارد و این انتخاب حدود {size} است. پوشه‌های کمتری انتخاب کنید یا از Chromium، Firefox یا برنامهٔ رومیزی استفاده کنید.", + "no_room": "این برون‌بری حدود {size} نیاز دارد و این مرورگر {free} جا دارد. پوشه‌های کمتری انتخاب کنید یا فضا آزاد کنید.", + "stopped_no_room": "جای مرورگر برای برون‌بری تمام شد. پوشه‌های کمتری انتخاب کنید یا فضا آزاد کنید.", + "stopped_offline_off": "برون‌بری متوقف شد چون این حساب دیگر نامه‌ای روی این دستگاه نگه نمی‌دارد.", + "policy_unknown": "قوانین سازمان شما برای این حساب هنوز مشخص نیست، پس برون‌بری فعلاً خاموش می‌ماند.", + "leftover_file": "فایل {name} از جایی که ذخیره کرده‌اید حذف نشد. ناقص است و می‌توانید پاکش کنید." + }, "deep_link": { "message_not_found": "این پیام دیگر در دسترس نیست.", "thread_not_found": "این گفتگو دیگر در دسترس نیست.", @@ -6728,6 +6767,53 @@ "revoke": "پس گرفتن", "revoked": "اجازه پس گرفته شد" }, + "desktopOs": { + "section_title": "برنامه دسکتاپ", + "section_description": "نحوه کار برنامه با رایانه شما.", + "close_to_tray_label": "هنگام بستن در سینی سیستم اجرا بماند", + "close_to_tray_description": "نامه‌های جدید و یادآوری‌ها پس از بستن پنجره هم می‌رسند.", + "start_at_login_label": "هنگام ورود به رایانه شروع شود", + "start_at_login_description": "برنامه هنگام ورود خودش شروع می‌شود.", + "start_minimized_label": "شروع در سینی سیستم", + "start_minimized_description": "هنگام ورود پنجره‌ای باز نمی‌شود. با نماد سینی سیستم آن را باز کنید.", + "attention_label": "چشمک نوار وظیفه هنگام نامه جدید", + "attention_mac_label": "جهش نماد Dock هنگام نامه جدید", + "attention_description": "فقط وقتی برنامه در جلو نیست.", + "quiet_hours_label": "ساعات سکوت", + "quiet_hours_description": "در این ساعات اعلان و صدایی نیست. نامه‌ها همچنان می‌رسند.", + "quiet_hours_from": "از", + "quiet_hours_to": "تا", + "show_shortcut_label": "نمایش برنامه با میان‌بر صفحه‌کلید", + "show_shortcut_description": "{keys} برنامه را از هر برنامه دیگری به جلو می‌آورد.", + "show_shortcut_taken": "برنامه دیگری از {keys} استفاده می‌کند. آن را ببندید، یا این گزینه را خاموش و دوباره روشن کنید.", + "default_app_label": "برنامه پیش‌فرض ایمیل", + "default_app_description": "باز کردن پیوندهای ایمیل و پرونده‌های ‎.eml، ‎.ics و ‎.vcf با این برنامه.", + "default_app_is_default": "این برنامه پیوندهای ایمیل شما را باز می‌کند.", + "default_app_button": "انتخاب برنامه پیش‌فرض…", + "new_mail": "نامه جدید", + "no_subject": "(بدون موضوع)", + "action_delete": "حذف", + "copy_as_file": "رونوشت به‌صورت پرونده", + "copied": "پرونده رونوشت شد. می‌توانید آن را در یک پوشه بچسبانید.", + "copy_failed": "رونوشت پرونده ممکن نشد.", + "reply_lost": "پاسخی که نوشتید نگه داشته نشد. پیام را باز کنید و از آنجا پاسخ دهید." + }, + "desktopReminders": { + "generic_title": "یادآوری", + "snooze": "تعویق {count} دقیقه", + "snooze_short": "{count} دقیقه", + "open": "باز کردن", + "join": "پیوستن", + "missed_title": "{count, plural, one {# یادآوری از دست‌رفته} other {# یادآوری از دست‌رفته}}", + "missed_more": "و {count} مورد دیگر", + "missed_show": "نمایش", + "missed_dialog_title": "یادآوری‌های از دست‌رفته", + "missed_dialog_description": "این یادآوری‌ها زمانی رسیدند که برنامه بسته بود، رایانه در خواب بود یا اعلان‌ها متوقف بودند.", + "open_calendar": "باز کردن تقویم", + "done": "تمام", + "join_host": "پیوستن ({host})", + "join_named": "پیوستن: {title}" + }, "desktop_policy": { "server_refused": "سازمان شما این سرور را در این دستگاه مجاز نمی‌داند.", "plugin_refused": "سازمان شما این افزونه را در این دستگاه مجاز نمی‌داند، بنابراین اجرا نمی‌شود. تنظیمات و داده‌های آن حفظ می‌شوند.", @@ -6736,5 +6822,30 @@ "plugins_off": "سازمان شما افزونه‌ها را در این دستگاه خاموش کرده است.", "plugins_allowlist": "سازمان شما در این دستگاه فقط برخی افزونه‌ها را مجاز می‌داند.", "off": "خاموش" + }, + "desktopTelemetry": { + "section_title": "اطلاعات استفاده و گزارش‌های خرابی", + "section_description": "هر دو خاموش هستند تا زمانی که روشنشان کنید و هرکدام کلید خودش را دارد.", + "telemetry_label": "ارسال اطلاعات استفاده", + "telemetry_description": "روزی یک بار: یک شناسهٔ تصادفی، نسخهٔ برنامه، نسخهٔ Node، سیستم‌عامل شما، مدت اجرای برنامه و نحوهٔ اتصال آن. با سرور ایمیل شخصی، همچنین قابلیت‌های روشن، تعداد تقریبی حساب‌ها و تعداد افزونه‌ها و پوسته‌ها. هرگز ایمیل، نشانی یا نام سرور ارسال نمی‌شود.", + "crash_label": "ارسال گزارش‌های خرابی", + "crash_description": "خطاهای برنامه همراه با یک شناسهٔ تصادفی، نام فایل‌های کد خود برنامه و پیام‌های کوتاه پاک‌سازی‌شده ارسال می‌شوند. اگر خود برنامه از کار بیفتد، یک فایل خرابی روی این رایانه می‌ماند و فقط وقتی شما ارسالش کنید خارج می‌شود.", + "crash_memory": "فایل خرابی کپی بخشی از حافظهٔ برنامه در لحظهٔ خرابی است. Bulwark نام‌های کاربری، نشانی‌ها و توکن‌های ورودی را که پیدا کند حذف می‌کند، اما فایل خرابی همچنان ممکن است متن ایمیل‌های شما را داشته باشد.", + "instance_note": "در سرور Bulwark، مدیر آن سرور جداگانه تصمیم می‌گیرد صفحه‌ها چه چیزی به آن گزارش دهند.", + "forced_off": "سازمان شما این را خاموش کرده است.", + "url_label": "مقصد فایل‌های خرابی", + "url_description": "نشانی https که فایل خرابی را دریافت می‌کند. Bulwark نشانی پیش‌فرضی ندارد.", + "url_invalid": "یک نشانی https کامل وارد کنید.", + "url_save": "ذخیره", + "files_label": "فایل‌های خرابی روی این رایانه", + "files_description": "Bulwark جدیدترین‌ها را نگه می‌دارد و قدیمی‌تر از یک ماه را حذف می‌کند.", + "files_empty": "فایل خرابی وجود ندارد.", + "file_size": "{size} کیلوبایت", + "send": "ارسال گزارش خرابی…", + "delete_all": "حذف همه", + "sent": "گزارش خرابی ارسال شد.", + "send_no_consent": "ابتدا گزارش‌های خرابی را روشن کنید.", + "send_no_url": "نشانی‌ای تنظیم نشده، پس جایی برای ارسال نیست. در بالا یکی وارد کنید.", + "send_failed": "گزارش خرابی ارسال نشد." } } diff --git a/locales/fr/common.json b/locales/fr/common.json index 899a7c4f4..90393ddbc 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Messages non envoyés qui attendent votre nouveau mot de passe : {count}. Ils restent jusqu'au {date}.", "refused_waiting_unknown": "Des messages non envoyés attendent peut-être votre nouveau mot de passe. Ils restent jusqu'au {date}.", "confirm_unsent_unknown": "Des messages non envoyés attendent peut-être ici. Ils seront perdus.", - "confirm_templates": "Modèles d'e-mail enregistrés sur cet appareil" + "confirm_templates": "Modèles d'e-mail enregistrés sur cet appareil", + "session_waiting": "Messages non envoyés qui attendent votre prochaine connexion : {count}. Ils restent jusqu'au {date}.", + "session_waiting_unknown": "Des messages non envoyés attendent peut-être votre prochaine connexion. Ils restent jusqu'au {date}.", + "needs_sign_in": "Connexion requise", + "switcher_waiting": "Messages non envoyés : {count}. Supprimés de cet appareil le {date}.", + "switcher_kept": "Ses e-mails sur cet appareil seront supprimés le {date}.", + "sibling_waiting": "{name} a été déconnecté. Messages non envoyés : {count}. Connectez-vous avant le {date}, sinon ils seront supprimés de cet appareil.", + "sibling_kept": "{name} a été déconnecté. Connectez-vous avant le {date}, sinon ses e-mails seront supprimés de cet appareil." }, "offlineStorage": { "keep_description": "Garde vos e-mails, votre agenda, vos contacts et les fichiers que vous choisissez, chiffrés sur cet appareil. Hors ligne, vous pouvez lire, signaler et déplacer des e-mails, et ce que vous écrivez attend dans la boîte d'envoi. La déconnexion supprime tout.", @@ -5866,6 +5873,38 @@ "banner_refused": "Brouillons refusés par le serveur : {count}", "banner_waiting": "Brouillons en attente du serveur : {count}" }, + "offlineExport": { + "settings_label": "Exporter le courrier conservé", + "settings_description": "Enregistrez le courrier de cet appareil en fichiers mbox ou .eml. Cela fonctionne hors ligne.", + "title": "Exporter le courrier conservé", + "description": "Enregistre les originaux que cet appareil conserve. Les fichiers .eml sont des copies exactes. Un fichier mbox ajoute les marques que son format exige et écrit l'état lu et suivi dans les en-têtes de chaque message, à la place de ceux qu'il avait. Le courrier plus ancien que la période choisie n'y figure pas. Pour tout enregistrer depuis le serveur, utilisez « Exporter tous les e-mails ».", + "folders_label": "Dossiers", + "all_folders": "Tous les dossiers", + "folder_kept": "{kept} sur {total} ici", + "loading": "Lecture des données de cet appareil…", + "load_failed": "Le courrier de cet appareil n'a pas pu être lu.", + "nothing_kept": "Aucun courrier n'est encore conservé sur cet appareil.", + "kept": "Originaux sur cet appareil : {kept} messages sur {total}", + "missing_online": "À télécharger depuis le serveur : {count}", + "missing_offline": "Exportation impossible hors ligne : {count}. Vous pourrez réessayer une fois en ligne.", + "reason_attachments_off": "Les messages originaux ne sont conservés que si « Garder les pièces jointes » est activé.", + "reason_not_kept": "Les messages originaux sont téléchargés en dernier, et seulement s'il reste de la place.", + "not_kept_attachments_off": "Pas d'original ici car les pièces jointes ne sont pas conservées sur cet appareil : {count}", + "not_kept_not_kept": "Pas encore d'original sur cet appareil : {count}", + "retry": "Réessayer en ligne", + "retry_online": "Vous êtes en ligne : ces messages peuvent venir du serveur.", + "retry_offline": "Vous pourrez réessayer une fois en ligne.", + "settings_button": "Exporter…", + "message_failed": "Le serveur n'a pas fourni ce message.", + "failed_generic": "L'exportation s'est arrêtée à cause d'une erreur. Réessayez.", + "stopped_limit": "L'exportation s'est arrêtée à {limit}, le maximum que ce navigateur peut contenir. Choisissez moins de dossiers, ou utilisez Chromium, Firefox ou l'application de bureau.", + "over_limit": "Ce navigateur peut contenir au plus {limit}, et ce choix fait environ {size}. Choisissez moins de dossiers, ou utilisez Chromium, Firefox ou l'application de bureau.", + "no_room": "Cette exportation demande environ {size}, et ce navigateur dispose de {free}. Choisissez moins de dossiers ou libérez de l'espace.", + "stopped_no_room": "Le navigateur n'a plus de place pour l'exportation. Choisissez moins de dossiers ou libérez de l'espace.", + "stopped_offline_off": "L'exportation s'est arrêtée car ce compte ne conserve plus de courrier sur cet appareil.", + "policy_unknown": "Les règles de votre organisation pour ce compte ne sont pas encore connues, l'exportation reste donc désactivée pour l'instant.", + "leftover_file": "Le fichier {name} n'a pas pu être retiré de l'endroit où vous l'avez enregistré. Il est incomplet, vous pouvez le supprimer." + }, "deep_link": { "message_not_found": "Ce message n'est plus disponible.", "thread_not_found": "Cette conversation n'est plus disponible.", @@ -6728,6 +6767,53 @@ "revoke": "Retirer", "revoked": "Autorisation retirée" }, + "desktopOs": { + "section_title": "Application de bureau", + "section_description": "Comment l'application fonctionne avec votre ordinateur.", + "close_to_tray_label": "Rester dans la zone de notification à la fermeture", + "close_to_tray_description": "Le courrier et les rappels continuent d'arriver après la fermeture de la fenêtre.", + "start_at_login_label": "Démarrer à l'ouverture de session", + "start_at_login_description": "L'application démarre seule quand vous ouvrez votre session.", + "start_minimized_label": "Démarrer dans la zone de notification", + "start_minimized_description": "Aucune fenêtre ne s'ouvre à l'ouverture de session. Ouvrez-la avec l'icône de la zone de notification.", + "attention_label": "Faire clignoter la barre des tâches à l'arrivée de courrier", + "attention_mac_label": "Faire rebondir l'icône du Dock à l'arrivée de courrier", + "attention_description": "Seulement quand l'application n'est pas au premier plan.", + "quiet_hours_label": "Heures calmes", + "quiet_hours_description": "Ni notifications ni sons pendant ces heures. Le courrier continue d'arriver.", + "quiet_hours_from": "De", + "quiet_hours_to": "à", + "show_shortcut_label": "Afficher l'application avec un raccourci clavier", + "show_shortcut_description": "{keys} amène l'application au premier plan depuis n'importe quelle autre.", + "show_shortcut_taken": "Une autre application utilise {keys}. Fermez-la, ou désactivez puis réactivez cette option.", + "default_app_label": "Application de messagerie par défaut", + "default_app_description": "Ouvrir les liens de messagerie et les fichiers .eml, .ics et .vcf avec cette application.", + "default_app_is_default": "Cette application ouvre vos liens de messagerie.", + "default_app_button": "Choisir l'application par défaut…", + "new_mail": "Nouveau courrier", + "no_subject": "(sans objet)", + "action_delete": "Supprimer", + "copy_as_file": "Copier en tant que fichier", + "copied": "Fichier copié. Vous pouvez le coller dans un dossier.", + "copy_failed": "Le fichier n'a pas pu être copié.", + "reply_lost": "Votre réponse saisie n'a pas pu être conservée. Ouvrez le message et répondez depuis celui-ci." + }, + "desktopReminders": { + "generic_title": "Rappel", + "snooze": "Reporter de {count} min", + "snooze_short": "{count} min", + "open": "Ouvrir", + "join": "Rejoindre", + "missed_title": "{count, plural, one {# rappel manqué} other {# rappels manqués}}", + "missed_more": "et {count} de plus", + "missed_show": "Afficher", + "missed_dialog_title": "Rappels manqués", + "missed_dialog_description": "Ils sont arrivés à échéance alors que l'application était fermée, l'ordinateur en veille ou les notifications en pause.", + "open_calendar": "Ouvrir le calendrier", + "done": "Terminé", + "join_host": "Rejoindre ({host})", + "join_named": "Rejoindre: {title}" + }, "desktop_policy": { "server_refused": "Votre organisation n'autorise pas ce serveur sur cet appareil.", "plugin_refused": "Votre organisation n'autorise pas ce plugin sur cet appareil, il ne s'exécute donc pas. Ses réglages et ses données sont conservés.", @@ -6736,5 +6822,30 @@ "plugins_off": "Votre organisation a désactivé les plugins sur cet appareil.", "plugins_allowlist": "Votre organisation n'autorise que certains plugins sur cet appareil.", "off": "Désactivé" + }, + "desktopTelemetry": { + "section_title": "Données d'utilisation et rapports de plantage", + "section_description": "Les deux restent désactivés tant que vous ne les activez pas, et chacun a son propre interrupteur.", + "telemetry_label": "Envoyer les données d'utilisation", + "telemetry_description": "Une fois par jour : un identifiant aléatoire, la version de l'application, la version de Node, votre système d'exploitation, depuis combien de temps l'application tourne et son mode de connexion. Avec votre propre serveur de messagerie, aussi les fonctions activées, un nombre approximatif de comptes et le nombre de plugins et de thèmes. Jamais de messages, d'adresses ni de noms de serveur.", + "crash_label": "Envoyer les rapports de plantage", + "crash_description": "Les erreurs de l'application partent avec un identifiant aléatoire, les noms de fichiers du code de l'application et de courts messages nettoyés. Si l'application elle-même plante, un fichier de plantage reste sur cet ordinateur et n'en sort que si vous l'envoyez.", + "crash_memory": "Un fichier de plantage est une copie d'une partie de la mémoire de l'application au moment du plantage. Bulwark retire les noms d'utilisateur, les adresses et les jetons de connexion qu'il trouve, mais un fichier de plantage peut quand même contenir le texte de vos messages.", + "instance_note": "Sur un serveur Bulwark, son administrateur décide séparément de ce que ses pages lui signalent.", + "forced_off": "Votre organisation a désactivé cette option.", + "url_label": "Destination des fichiers de plantage", + "url_description": "L'adresse https qui reçoit un fichier de plantage. Bulwark n'en fournit aucune.", + "url_invalid": "Saisissez une adresse https complète.", + "url_save": "Enregistrer", + "files_label": "Fichiers de plantage sur cet ordinateur", + "files_description": "Bulwark garde les plus récents et supprime ceux de plus d'un mois.", + "files_empty": "Aucun fichier de plantage.", + "file_size": "{size} Ko", + "send": "Envoyer le rapport de plantage…", + "delete_all": "Tout supprimer", + "sent": "Rapport de plantage envoyé.", + "send_no_consent": "Activez d'abord les rapports de plantage.", + "send_no_url": "Aucune adresse n'est définie, il n'y a donc nulle part où envoyer ce rapport. Saisissez-en une ci-dessus.", + "send_failed": "Le rapport de plantage n'a pas pu être envoyé." } } diff --git a/locales/he/common.json b/locales/he/common.json index 004e47e49..c628a794a 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5784,7 +5784,14 @@ "refused_waiting": "הודעות שלא נשלחו ממתינות לסיסמה החדשה שלך: {count}. הן יישמרו עד {date}.", "refused_waiting_unknown": "ייתכן שהודעות שלא נשלחו ממתינות לסיסמה החדשה שלך. הן יישמרו עד {date}.", "confirm_unsent_unknown": "ייתכן שיש כאן הודעות שלא נשלחו. הן יאבדו.", - "confirm_templates": "תבניות דוא\"ל שנשמרו במכשיר הזה" + "confirm_templates": "תבניות דוא\"ל שנשמרו במכשיר הזה", + "session_waiting": "הודעות שלא נשלחו ממתינות לכניסה הבאה שלך: {count}. הן יישמרו עד {date}.", + "session_waiting_unknown": "ייתכן שהודעות שלא נשלחו ממתינות לכניסה הבאה שלך. הן יישמרו עד {date}.", + "needs_sign_in": "נדרשת כניסה", + "switcher_waiting": "הודעות שלא נשלחו: {count}. יימחקו מהמכשיר הזה ב-{date}.", + "switcher_kept": "הדואר במכשיר הזה יימחק ב-{date}.", + "sibling_waiting": "{name} נותק. הודעות שלא נשלחו: {count}. יש להיכנס עד {date}, אחרת הן יימחקו מהמכשיר הזה.", + "sibling_kept": "{name} נותק. יש להיכנס עד {date}, אחרת הדואר יימחק מהמכשיר הזה." }, "offlineStorage": { "keep_description": "שומר את הדואר, היומן, אנשי הקשר והקבצים שבחרת, מוצפנים במכשיר הזה. במצב לא מקוון אפשר לקרוא, לסמן ולהעביר דואר, ומה שכותבים ממתין בתיבת הדואר היוצא. התנתקות מוחקת הכול.", @@ -5867,6 +5874,38 @@ "banner_refused": "טיוטות שהשרת דחה: {count}", "banner_waiting": "טיוטות שממתינות לשרת: {count}" }, + "offlineExport": { + "settings_label": "ייצוא דואר שנשמר", + "settings_description": "שמרו את הדואר שבמכשיר הזה כקובצי mbox או ‎.eml. זה עובד גם בלי חיבור.", + "title": "ייצוא דואר שנשמר", + "description": "שומר את המקוריים שהמכשיר הזה מחזיק. קובצי ‎.eml הם העתקים מדויקים. קובץ mbox מוסיף את הסימונים שהפורמט שלו דורש וכותב את מצב הקריאה והסימון בכותרות של כל הודעה, במקום מה שהיה בהן. דואר ישן יותר מהתקופה שבחרתם לא נכלל. כדי לשמור הכול מהשרת, השתמשו ב„ייצוא כל הדואר”.", + "folders_label": "תיקיות", + "all_folders": "כל התיקיות", + "folder_kept": "כאן: {kept} מתוך {total}", + "loading": "קורא את מה שיש במכשיר הזה…", + "load_failed": "לא ניתן היה לקרוא את הדואר במכשיר הזה.", + "nothing_kept": "עדיין לא נשמר דואר במכשיר הזה.", + "kept": "מקוריים במכשיר הזה: {kept} מתוך {total} הודעות", + "missing_online": "להורדה מהשרת: {count}", + "missing_offline": "לא ניתן לייצא בלי חיבור: {count}. אפשר לנסות שוב כשיהיה חיבור.", + "reason_attachments_off": "הודעות מקוריות נשמרות רק כש„שמירת קבצים מצורפים” פעיל.", + "reason_not_kept": "הודעות מקוריות יורדות אחרונות, ורק כל עוד יש מקום.", + "not_kept_attachments_off": "אין מקור כי המכשיר הזה לא שומר קבצים מצורפים: {count}", + "not_kept_not_kept": "עדיין אין מקור במכשיר הזה: {count}", + "retry": "לנסות שוב עם חיבור", + "retry_online": "יש חיבור עכשיו, אז הן יכולות להגיע מהשרת.", + "retry_offline": "אפשר לנסות שוב כשיהיה חיבור.", + "settings_button": "ייצוא…", + "message_failed": "השרת לא מסר את ההודעה הזו.", + "failed_generic": "הייצוא נעצר בגלל שגיאה. נסו שוב.", + "stopped_limit": "הייצוא נעצר ב־{limit}, המקסימום שהדפדפן הזה יכול להחזיק. בחרו פחות תיקיות, או השתמשו ב־Chromium, ב־Firefox או באפליקציה למחשב.", + "over_limit": "הדפדפן הזה יכול להחזיק עד {limit}, והבחירה הזו היא בערך {size}. בחרו פחות תיקיות, או השתמשו ב־Chromium, ב־Firefox או באפליקציה למחשב.", + "no_room": "הייצוא הזה צריך בערך {size}, ולדפדפן הזה נשארו {free}. בחרו פחות תיקיות או פנו מקום.", + "stopped_no_room": "לדפדפן נגמר המקום לייצוא. בחרו פחות תיקיות או פנו מקום.", + "stopped_offline_off": "הייצוא נעצר כי החשבון הזה כבר לא שומר דואר במכשיר הזה.", + "policy_unknown": "הכללים של הארגון שלכם לחשבון הזה עדיין לא ידועים, ולכן הייצוא כבוי בינתיים.", + "leftover_file": "לא ניתן היה להסיר את הקובץ {name} מהמקום שבו שמרתם אותו. הוא חלקי, ואפשר למחוק אותו." + }, "deep_link": { "message_not_found": "הודעה זו אינה זמינה עוד.", "thread_not_found": "שיחה זו אינה זמינה עוד.", @@ -6729,6 +6768,53 @@ "revoke": "ביטול", "revoked": "ההרשאה בוטלה" }, + "desktopOs": { + "section_title": "יישום שולחן עבודה", + "section_description": "איך היישום עובד עם המחשב שלך.", + "close_to_tray_label": "להמשיך לפעול במגש המערכת בסגירה", + "close_to_tray_description": "דואר חדש ותזכורות ממשיכים להגיע אחרי סגירת החלון.", + "start_at_login_label": "להפעיל בכניסה למחשב", + "start_at_login_description": "היישום מופעל מעצמו כשנכנסים.", + "start_minimized_label": "להפעיל במגש המערכת", + "start_minimized_description": "בכניסה לא נפתח חלון. פותחים אותו מהסמל במגש המערכת.", + "attention_label": "הבהוב בשורת המשימות בדואר חדש", + "attention_mac_label": "קפיצת סמל ה-Dock בדואר חדש", + "attention_description": "רק כשהיישום לא בחזית.", + "quiet_hours_label": "שעות שקט", + "quiet_hours_description": "בשעות אלה אין התראות וצלילים. הדואר ממשיך להגיע.", + "quiet_hours_from": "מ-", + "quiet_hours_to": "עד", + "show_shortcut_label": "הצגת היישום בקיצור מקשים", + "show_shortcut_description": "{keys} מביא את היישום לחזית מכל יישום אחר.", + "show_shortcut_taken": "יישום אחר משתמש ב-{keys}. סגרו אותו, או כבו והפעילו מחדש את האפשרות הזו.", + "default_app_label": "יישום הדואר המוגדר כברירת מחדל", + "default_app_description": "פתיחת קישורי דואר וקובצי ‎.eml,‏ ‎.ics ו-‎.vcf ביישום זה.", + "default_app_is_default": "יישום זה פותח את קישורי הדואר שלך.", + "default_app_button": "בחירת יישום ברירת מחדל…", + "new_mail": "דואר חדש", + "no_subject": "(ללא נושא)", + "action_delete": "מחיקה", + "copy_as_file": "העתקה כקובץ", + "copied": "הקובץ הועתק. אפשר להדביק אותו בתיקייה.", + "copy_failed": "לא ניתן להעתיק את הקובץ.", + "reply_lost": "לא ניתן היה לשמור את התשובה שהקלדת. פתחו את ההודעה והשיבו משם." + }, + "desktopReminders": { + "generic_title": "תזכורת", + "snooze": "דחייה ב-{count} דק'", + "snooze_short": "{count} דק'", + "open": "פתיחה", + "join": "הצטרפות", + "missed_title": "{count, plural, one {תזכורת אחת שהוחמצה} two {# תזכורות שהוחמצו} other {# תזכורות שהוחמצו}}", + "missed_more": "ועוד {count}", + "missed_show": "הצגה", + "missed_dialog_title": "תזכורות שהוחמצו", + "missed_dialog_description": "הן הגיעו כשהאפליקציה הייתה סגורה, המחשב היה במצב שינה או שההתראות הושהו.", + "open_calendar": "פתיחת היומן", + "done": "סיום", + "join_host": "הצטרפות ({host})", + "join_named": "הצטרפות: {title}" + }, "desktop_policy": { "server_refused": "הארגון שלך לא מאפשר את השרת הזה במכשיר הזה.", "plugin_refused": "הארגון שלך לא מאפשר את התוסף הזה במכשיר הזה, ולכן הוא לא פועל. ההגדרות והנתונים שלו נשמרים.", @@ -6737,5 +6823,30 @@ "plugins_off": "הארגון שלך כיבה תוספים במכשיר הזה.", "plugins_allowlist": "הארגון שלך מאפשר במכשיר הזה רק תוספים מסוימים.", "off": "כבוי" + }, + "desktopTelemetry": { + "section_title": "מידע על שימוש ודוחות קריסה", + "section_description": "שניהם כבויים עד שתפעילו אותם, ולכל אחד מתג משלו.", + "telemetry_label": "שליחת מידע על שימוש", + "telemetry_description": "פעם ביום: מזהה אקראי, גרסת האפליקציה, גרסת Node, מערכת ההפעלה, כמה זמן האפליקציה פועלת ואופן החיבור שלה. עם שרת דואר משלכם, גם אילו תכונות פעילות, מספר משוער של חשבונות וכמה תוספים וערכות נושא יש לכם. אף פעם לא דואר, כתובות או שמות שרתים.", + "crash_label": "שליחת דוחות קריסה", + "crash_description": "שגיאות באפליקציה נשלחות עם מזהה אקראי, שמות קבצים מהקוד של האפליקציה עצמה והודעות קצרות ומנוקות. אם האפליקציה עצמה קורסת, קובץ קריסה נשאר במחשב הזה ויוצא ממנו רק אם תשלחו אותו.", + "crash_memory": "קובץ קריסה הוא העתק של חלק מזיכרון האפליקציה ברגע הקריסה. Bulwark מסיר שמות משתמש, כתובות ואסימוני התחברות שהוא מוצא, אבל קובץ קריסה עדיין עשוי להכיל את הטקסט של הדואר שלכם.", + "instance_note": "בשרת Bulwark, מנהל השרת מחליט בנפרד מה הדפים מדווחים לו.", + "forced_off": "הארגון שלכם כיבה את זה.", + "url_label": "לאן קובצי הקריסה הולכים", + "url_description": "כתובת ה-https שמקבלת קובץ קריסה. Bulwark לא מגיע עם כתובת.", + "url_invalid": "הזינו כתובת https מלאה.", + "url_save": "שמירה", + "files_label": "קובצי קריסה במחשב הזה", + "files_description": "Bulwark שומר את החדשים ביותר ומוחק קבצים בני יותר מחודש.", + "files_empty": "אין קובצי קריסה.", + "file_size": "{size} ק״ב", + "send": "שליחת דוח קריסה…", + "delete_all": "מחיקת הכול", + "sent": "דוח הקריסה נשלח.", + "send_no_consent": "הפעילו קודם את דוחות הקריסה.", + "send_no_url": "לא הוגדרה כתובת, ולכן אין לאן לשלוח. הזינו כתובת למעלה.", + "send_failed": "לא ניתן היה לשלוח את דוח הקריסה." } } diff --git a/locales/hu/common.json b/locales/hu/common.json index 708d32c1e..fc474247a 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Az új jelszóra váró, el nem küldött üzenetek: {count}. Eddig maradnak: {date}.", "refused_waiting_unknown": "Lehet, hogy el nem küldött üzenetek várnak az új jelszóra. Eddig maradnak: {date}.", "confirm_unsent_unknown": "Lehet, hogy el nem küldött üzenetek várakoznak itt. Elvesznek.", - "confirm_templates": "Ezen az eszközön mentett e-mail-sablonok" + "confirm_templates": "Ezen az eszközön mentett e-mail-sablonok", + "session_waiting": "A következő bejelentkezésre váró, el nem küldött üzenetek: {count}. Eddig maradnak: {date}.", + "session_waiting_unknown": "Lehet, hogy el nem küldött üzenetek várnak a következő bejelentkezésre. Eddig maradnak: {date}.", + "needs_sign_in": "Bejelentkezés szükséges", + "switcher_waiting": "El nem küldött üzenetek: {count}. Törlés erről az eszközről: {date}.", + "switcher_kept": "Az eszközön lévő levelek törlése: {date}.", + "sibling_waiting": "{name} kijelentkezett. El nem küldött üzenetek: {count}. Jelentkezzen be eddig: {date}, különben törlődnek erről az eszközről.", + "sibling_kept": "{name} kijelentkezett. Jelentkezzen be eddig: {date}, különben a levelek törlődnek erről az eszközről." }, "offlineStorage": { "keep_description": "Megtartja a leveleit, a naptárát, a névjegyeit és a kiválasztott fájlokat, titkosítva ezen az eszközön. Offline olvashat, megjelölhet és áthelyezhet leveleket, a megírt levelek pedig a postázandók között várnak. Kijelentkezéskor minden törlődik.", @@ -5866,6 +5873,38 @@ "banner_refused": "A szerver által elutasított piszkozatok: {count}", "banner_waiting": "A szerverre váró piszkozatok: {count}" }, + "offlineExport": { + "settings_label": "Megőrzött levelek exportálása", + "settings_description": "Mentse az eszközön lévő leveleket mbox vagy .eml fájlként. Offline is működik.", + "title": "Megőrzött levelek exportálása", + "description": "Elmenti az eszközön megőrzött eredeti üzeneteket. Az .eml fájlok pontos másolatok. Az mbox fájl hozzáadja a formátumához szükséges jelöléseket, és az olvasott és megjelölt állapotot minden üzenet fejlécébe írja a korábbiak helyett. A választott időszaknál régebbi levelek nincsenek benne. A szerveren lévő összes levél mentéséhez használja ezt: „Az összes levél exportálása”.", + "folders_label": "Mappák", + "all_folders": "Minden mappa", + "folder_kept": "{kept} / {total} itt", + "loading": "Az eszközön lévő adatok olvasása…", + "load_failed": "Az eszközön lévő leveleket nem sikerült beolvasni.", + "nothing_kept": "Ezen az eszközön még nincsenek megőrzött levelek.", + "kept": "Eredetik ezen az eszközön: {kept} / {total} üzenet", + "missing_online": "A szerverről letöltendő: {count}", + "missing_offline": "Offline nem exportálható: {count}. Ha újra online lesz, megpróbálhatja újra.", + "reason_attachments_off": "Az eredeti üzeneteket csak akkor őrzi meg az eszköz, ha be van kapcsolva ez: „Mellékletek megtartása”.", + "reason_not_kept": "Az eredeti üzenetek utoljára töltődnek le, és csak addig, amíg van hely.", + "not_kept_attachments_off": "Nincs itt eredeti, mert ez az eszköz nem őrzi meg a mellékleteket: {count}", + "not_kept_not_kept": "Még nincs eredeti ezen az eszközön: {count}", + "retry": "Újra online", + "retry_online": "Most online van, így ezek jöhetnek a szerverről.", + "retry_offline": "Ha újra online lesz, megpróbálhatja újra.", + "settings_button": "Exportálás…", + "message_failed": "A szerver nem adta át ezt az üzenetet.", + "failed_generic": "Az exportálás hiba miatt leállt. Próbálja újra.", + "stopped_limit": "Az exportálás {limit} méretnél leállt, ennél többet ez a böngésző nem tud tárolni. Válasszon kevesebb mappát, vagy használja a Chromiumot, a Firefoxot vagy az asztali alkalmazást.", + "over_limit": "Ez a böngésző legfeljebb {limit} méretet tud tárolni, ez a választás pedig körülbelül {size}. Válasszon kevesebb mappát, vagy használja a Chromiumot, a Firefoxot vagy az asztali alkalmazást.", + "no_room": "Ehhez az exportáláshoz körülbelül {size} kell, a böngészőnek pedig {free} áll rendelkezésére. Válasszon kevesebb mappát, vagy szabadítson fel helyet.", + "stopped_no_room": "A böngészőnek elfogyott a helye az exportáláshoz. Válasszon kevesebb mappát, vagy szabadítson fel helyet.", + "stopped_offline_off": "Az exportálás leállt, mert ez a fiók már nem őriz meg leveleket ezen az eszközön.", + "policy_unknown": "A szervezet erre a fiókra vonatkozó szabályai még nem ismertek, ezért az exportálás egyelőre ki van kapcsolva.", + "leftover_file": "A(z) {name} fájlt nem sikerült eltávolítani a mentés helyéről. Hiányos, ezért törölheti." + }, "deep_link": { "message_not_found": "Ez az üzenet már nem érhető el.", "thread_not_found": "Ez a beszélgetés már nem érhető el.", @@ -6728,6 +6767,53 @@ "revoke": "Visszavonás", "revoked": "Engedély visszavonva" }, + "desktopOs": { + "section_title": "Asztali alkalmazás", + "section_description": "Hogyan működik az alkalmazás a számítógépével.", + "close_to_tray_label": "Bezáráskor fusson tovább a tálcán", + "close_to_tray_description": "Az új levelek és emlékeztetők az ablak bezárása után is megérkeznek.", + "start_at_login_label": "Indítás a számítógépre való bejelentkezéskor", + "start_at_login_description": "Az alkalmazás bejelentkezéskor magától elindul.", + "start_minimized_label": "Indítás a tálcán", + "start_minimized_description": "Bejelentkezéskor nem nyílik ablak. A tálcaikonnal nyithatja meg.", + "attention_label": "Tálca villogtatása új levélnél", + "attention_mac_label": "Dock-ikon ugráltatása új levélnél", + "attention_description": "Csak ha az alkalmazás nincs előtérben.", + "quiet_hours_label": "Csendes órák", + "quiet_hours_description": "Ekkor nincs értesítés és hang. A levelek továbbra is megérkeznek.", + "quiet_hours_from": "Ettől", + "quiet_hours_to": "eddig", + "show_shortcut_label": "Az alkalmazás megjelenítése billentyűparanccsal", + "show_shortcut_description": "{keys} bármely más alkalmazásból előtérbe hozza az alkalmazást.", + "show_shortcut_taken": "Egy másik alkalmazás használja ezt: {keys}. Zárja be azt, vagy kapcsolja ki és be ezt a beállítást.", + "default_app_label": "Alapértelmezett levelezőalkalmazás", + "default_app_description": "A levélhivatkozások és a .eml, .ics és .vcf fájlok megnyitása ezzel az alkalmazással.", + "default_app_is_default": "Ez az alkalmazás nyitja meg a levélhivatkozásait.", + "default_app_button": "Alapértelmezett alkalmazás kiválasztása…", + "new_mail": "Új levél", + "no_subject": "(nincs tárgy)", + "action_delete": "Törlés", + "copy_as_file": "Másolás fájlként", + "copied": "Fájl másolva. Beillesztheti egy mappába.", + "copy_failed": "A fájlt nem sikerült másolni.", + "reply_lost": "A beírt válasz nem maradt meg. Nyissa meg az üzenetet, és válaszoljon ott." + }, + "desktopReminders": { + "generic_title": "Emlékeztető", + "snooze": "Halasztás {count} perccel", + "snooze_short": "{count} perc", + "open": "Megnyitás", + "join": "Csatlakozás", + "missed_title": "{count, plural, one {# elmulasztott emlékeztető} other {# elmulasztott emlékeztető}}", + "missed_more": "és még {count}", + "missed_show": "Megjelenítés", + "missed_dialog_title": "Elmulasztott emlékeztetők", + "missed_dialog_description": "Akkor jártak le, amikor az alkalmazás be volt zárva, a számítógép alvó állapotban volt, vagy az értesítések szüneteltek.", + "open_calendar": "Naptár megnyitása", + "done": "Kész", + "join_host": "Csatlakozás ({host})", + "join_named": "Csatlakozás: {title}" + }, "desktop_policy": { "server_refused": "A szervezete ezen az eszközön nem engedélyezi ezt a kiszolgálót.", "plugin_refused": "A szervezete ezen az eszközön nem engedélyezi ezt a bővítményt, ezért nem fut. A beállításai és adatai megmaradnak.", @@ -6736,5 +6822,30 @@ "plugins_off": "A szervezete ezen az eszközön kikapcsolta a bővítményeket.", "plugins_allowlist": "A szervezete ezen az eszközön csak bizonyos bővítményeket engedélyez.", "off": "Ki" + }, + "desktopTelemetry": { + "section_title": "Használati adatok és összeomlási jelentések", + "section_description": "Mindkettő ki van kapcsolva, amíg be nem kapcsolja, és mindegyiknek saját kapcsolója van.", + "telemetry_label": "Használati adatok küldése", + "telemetry_description": "Naponta egyszer: egy véletlenszerű azonosító, az alkalmazás verziója, a Node verziója, az operációs rendszer, az alkalmazás futási ideje és a kapcsolódás módja. Saját levelezőszervernél azt is, mely funkciók vannak bekapcsolva, a fiókok hozzávetőleges számát, valamint a bővítmények és témák számát. Soha nem küld leveleket, címeket vagy szerverneveket.", + "crash_label": "Összeomlási jelentések küldése", + "crash_description": "Az alkalmazás hibái egy véletlenszerű azonosítóval, az alkalmazás saját kódjának fájlneveivel és rövid, megtisztított üzenetekkel mennek ki. Ha maga az alkalmazás omlik össze, egy összeomlási fájl marad ezen a számítógépen, és csak akkor kerül ki, ha elküldi.", + "crash_memory": "Az összeomlási fájl az alkalmazás memóriájának egy részéről készült másolat az összeomlás pillanatából. A Bulwark eltávolítja a talált felhasználóneveket, címeket és bejelentkezési tokeneket, de az összeomlási fájl így is tartalmazhatja a levelei szövegét.", + "instance_note": "Bulwark-szerveren annak rendszergazdája külön dönt arról, mit jelentenek neki az oldalak.", + "forced_off": "A szervezete ezt kikapcsolta.", + "url_label": "Hová kerülnek az összeomlási fájlok", + "url_description": "A https-cím, amely fogadja az összeomlási fájlt. A Bulwark nem ad ilyet.", + "url_invalid": "Adjon meg egy teljes https-címet.", + "url_save": "Mentés", + "files_label": "Összeomlási fájlok ezen a számítógépen", + "files_description": "A Bulwark megtartja a legújabbakat, és törli az egy hónapnál régebbieket.", + "files_empty": "Nincsenek összeomlási fájlok.", + "file_size": "{size} KB", + "send": "Összeomlási jelentés küldése…", + "delete_all": "Összes törlése", + "sent": "Összeomlási jelentés elküldve.", + "send_no_consent": "Először kapcsolja be az összeomlási jelentéseket.", + "send_no_url": "Nincs megadva cím, így nincs hová küldeni. Adjon meg egyet fent.", + "send_failed": "Az összeomlási jelentést nem sikerült elküldeni." } } diff --git a/locales/it/common.json b/locales/it/common.json index b72f76d9b..838af33a0 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Messaggi non inviati in attesa della nuova password: {count}. Restano fino al {date}.", "refused_waiting_unknown": "Alcuni messaggi non inviati potrebbero essere in attesa della nuova password. Restano fino al {date}.", "confirm_unsent_unknown": "Qui potrebbero esserci messaggi non inviati in attesa. Andranno persi.", - "confirm_templates": "Modelli di email salvati su questo dispositivo" + "confirm_templates": "Modelli di email salvati su questo dispositivo", + "session_waiting": "Messaggi non inviati in attesa del prossimo accesso: {count}. Restano fino al {date}.", + "session_waiting_unknown": "Alcuni messaggi non inviati potrebbero essere in attesa del prossimo accesso. Restano fino al {date}.", + "needs_sign_in": "Accesso richiesto", + "switcher_waiting": "Messaggi non inviati: {count}. Eliminati da questo dispositivo il {date}.", + "switcher_kept": "Le sue email su questo dispositivo verranno eliminate il {date}.", + "sibling_waiting": "{name} è stato disconnesso. Messaggi non inviati: {count}. Accedi entro il {date} o verranno eliminati da questo dispositivo.", + "sibling_kept": "{name} è stato disconnesso. Accedi entro il {date} o le sue email verranno eliminate da questo dispositivo." }, "offlineStorage": { "keep_description": "Conserva la tua posta, il calendario, i contatti e i file che scegli, cifrati su questo dispositivo. Offline puoi leggere, contrassegnare e spostare la posta, e ciò che scrivi aspetta nella posta in uscita. Uscendo dall'account viene rimosso tutto.", @@ -5866,6 +5873,38 @@ "banner_refused": "Bozze rifiutate dal server: {count}", "banner_waiting": "Bozze in attesa del server: {count}" }, + "offlineExport": { + "settings_label": "Esporta la posta conservata", + "settings_description": "Salva la posta di questo dispositivo come file mbox o .eml. Funziona anche offline.", + "title": "Esporta la posta conservata", + "description": "Salva gli originali che questo dispositivo conserva. I file .eml sono copie esatte. Un file mbox aggiunge i segni richiesti dal suo formato e scrive lo stato di letto e contrassegnato nelle intestazioni di ogni messaggio, al posto di quelle presenti. La posta più vecchia del periodo scelto non è inclusa. Per salvare tutto dal server, usa «Esporta tutta la posta».", + "folders_label": "Cartelle", + "all_folders": "Tutte le cartelle", + "folder_kept": "{kept} di {total} qui", + "loading": "Lettura dei dati su questo dispositivo…", + "load_failed": "Impossibile leggere la posta su questo dispositivo.", + "nothing_kept": "Su questo dispositivo non è ancora conservata posta.", + "kept": "Originali su questo dispositivo: {kept} di {total} messaggi", + "missing_online": "Da scaricare dal server: {count}", + "missing_offline": "Non esportabili offline: {count}. Puoi riprovare quando sei online.", + "reason_attachments_off": "I messaggi originali vengono conservati solo se «Conserva gli allegati» è attivo.", + "reason_not_kept": "I messaggi originali vengono scaricati per ultimi e solo se c'è spazio.", + "not_kept_attachments_off": "Nessun originale qui perché su questo dispositivo gli allegati non vengono conservati: {count}", + "not_kept_not_kept": "Ancora nessun originale su questo dispositivo: {count}", + "retry": "Riprova online", + "retry_online": "Ora sei online, quindi possono arrivare dal server.", + "retry_offline": "Puoi riprovare quando sei online.", + "settings_button": "Esporta…", + "message_failed": "Il server non ha fornito questo messaggio.", + "failed_generic": "L'esportazione si è interrotta per un errore. Riprova.", + "stopped_limit": "L'esportazione si è interrotta a {limit}, il massimo che questo browser può contenere. Scegli meno cartelle, oppure usa Chromium, Firefox o l'app desktop.", + "over_limit": "Questo browser può contenere al massimo {limit}, e questa scelta occupa circa {size}. Scegli meno cartelle, oppure usa Chromium, Firefox o l'app desktop.", + "no_room": "Questa esportazione richiede circa {size}, e questo browser ha {free} disponibili. Scegli meno cartelle o libera spazio.", + "stopped_no_room": "Il browser ha esaurito lo spazio per l'esportazione. Scegli meno cartelle o libera spazio.", + "stopped_offline_off": "L'esportazione si è interrotta perché questo account non conserva più posta su questo dispositivo.", + "policy_unknown": "Le regole della tua organizzazione per questo account non sono ancora note, quindi l'esportazione resta disattivata per ora.", + "leftover_file": "Impossibile rimuovere il file {name} dal punto in cui l'hai salvato. È incompleto, puoi eliminarlo." + }, "deep_link": { "message_not_found": "Questo messaggio non è più disponibile.", "thread_not_found": "Questa conversazione non è più disponibile.", @@ -6728,6 +6767,53 @@ "revoke": "Ritira", "revoked": "Permesso ritirato" }, + "desktopOs": { + "section_title": "App desktop", + "section_description": "Come l'app funziona con il tuo computer.", + "close_to_tray_label": "Resta nell'area di notifica alla chiusura", + "close_to_tray_description": "La nuova posta e i promemoria continuano ad arrivare dopo la chiusura della finestra.", + "start_at_login_label": "Avvia all'accesso al computer", + "start_at_login_description": "L'app si avvia da sola quando accedi.", + "start_minimized_label": "Avvia nell'area di notifica", + "start_minimized_description": "All'accesso non si apre alcuna finestra. Aprila con l'icona nell'area di notifica.", + "attention_label": "Fai lampeggiare la barra delle applicazioni con nuova posta", + "attention_mac_label": "Fai rimbalzare l'icona del Dock con nuova posta", + "attention_description": "Solo quando l'app non è in primo piano.", + "quiet_hours_label": "Ore di silenzio", + "quiet_hours_description": "Nessuna notifica o suono in queste ore. La posta continua ad arrivare.", + "quiet_hours_from": "Da", + "quiet_hours_to": "a", + "show_shortcut_label": "Mostra l'app con una scorciatoia da tastiera", + "show_shortcut_description": "{keys} porta l'app in primo piano da qualsiasi altra app.", + "show_shortcut_taken": "Un'altra app usa {keys}. Chiudila, oppure disattiva e riattiva questa opzione.", + "default_app_label": "App di posta predefinita", + "default_app_description": "Apri i link di posta e i file .eml, .ics e .vcf con questa app.", + "default_app_is_default": "Questa app apre i tuoi link di posta.", + "default_app_button": "Scegli app predefinita…", + "new_mail": "Nuova posta", + "no_subject": "(nessun oggetto)", + "action_delete": "Elimina", + "copy_as_file": "Copia come file", + "copied": "File copiato. Puoi incollarlo in una cartella.", + "copy_failed": "Impossibile copiare il file.", + "reply_lost": "Non è stato possibile conservare la risposta digitata. Apri il messaggio e rispondi da lì." + }, + "desktopReminders": { + "generic_title": "Promemoria", + "snooze": "Rimanda di {count} min", + "snooze_short": "{count} min", + "open": "Apri", + "join": "Partecipa", + "missed_title": "{count, plural, one {# promemoria perso} other {# promemoria persi}}", + "missed_more": "e altri {count}", + "missed_show": "Mostra", + "missed_dialog_title": "Promemoria persi", + "missed_dialog_description": "Sono scattati mentre l'app era chiusa, il computer in sospensione o le notifiche in pausa.", + "open_calendar": "Apri calendario", + "done": "Fatto", + "join_host": "Partecipa ({host})", + "join_named": "Partecipa: {title}" + }, "desktop_policy": { "server_refused": "La tua organizzazione non consente questo server su questo dispositivo.", "plugin_refused": "La tua organizzazione non consente questo plugin su questo dispositivo, quindi non viene eseguito. Le sue impostazioni e i suoi dati restano conservati.", @@ -6736,5 +6822,30 @@ "plugins_off": "La tua organizzazione ha disattivato i plugin su questo dispositivo.", "plugins_allowlist": "La tua organizzazione consente solo alcuni plugin su questo dispositivo.", "off": "Disattivato" + }, + "desktopTelemetry": { + "section_title": "Dati di utilizzo e segnalazioni di arresto anomalo", + "section_description": "Entrambi restano disattivati finché non li attivi, e ognuno ha il proprio interruttore.", + "telemetry_label": "Invia dati di utilizzo", + "telemetry_description": "Una volta al giorno: un ID casuale, la versione dell'app, la versione di Node, il tuo sistema operativo, da quanto tempo è in esecuzione l'app e come si connette. Con un tuo server di posta, anche quali funzioni sono attive, un numero approssimativo di account e quanti plugin e temi hai. Mai email, indirizzi o nomi di server.", + "crash_label": "Invia segnalazioni di arresto anomalo", + "crash_description": "Gli errori dell'app vengono inviati con un ID casuale, i nomi dei file del codice dell'app e brevi messaggi ripuliti. Se l'app stessa si arresta, un file di arresto resta su questo computer e ne esce solo se lo invii.", + "crash_memory": "Un file di arresto è una copia di parte della memoria dell'app al momento dell'arresto. Bulwark rimuove i nomi utente, gli indirizzi e i token di accesso che trova, ma un file di arresto può comunque contenere il testo delle tue email.", + "instance_note": "Su un server Bulwark, il suo amministratore decide separatamente cosa le sue pagine gli segnalano.", + "forced_off": "La tua organizzazione ha disattivato questa opzione.", + "url_label": "Destinazione dei file di arresto", + "url_description": "L'indirizzo https che riceve un file di arresto. Bulwark non ne include nessuno.", + "url_invalid": "Inserisci un indirizzo https completo.", + "url_save": "Salva", + "files_label": "File di arresto su questo computer", + "files_description": "Bulwark conserva i più recenti ed elimina quelli più vecchi di un mese.", + "files_empty": "Nessun file di arresto.", + "file_size": "{size} KB", + "send": "Invia segnalazione di arresto…", + "delete_all": "Elimina tutti", + "sent": "Segnalazione di arresto inviata.", + "send_no_consent": "Attiva prima le segnalazioni di arresto anomalo.", + "send_no_url": "Non è impostato alcun indirizzo, quindi non c'è dove inviarla. Inseriscine uno qui sopra.", + "send_failed": "Impossibile inviare la segnalazione di arresto." } } diff --git a/locales/ja/common.json b/locales/ja/common.json index ecdfad4b3..88228f10d 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "新しいパスワードを待っている未送信メッセージ: {count} 件。{date} まで保持されます。", "refused_waiting_unknown": "未送信のメッセージが新しいパスワードを待っている可能性があります。{date} まで保持されます。", "confirm_unsent_unknown": "ここに未送信のメッセージがある可能性があります。失われます。", - "confirm_templates": "このデバイスに保存されたメールテンプレート" + "confirm_templates": "このデバイスに保存されたメールテンプレート", + "session_waiting": "次回のサインインを待っている未送信メッセージ: {count} 件。{date} まで保持されます。", + "session_waiting_unknown": "未送信のメッセージが次回のサインインを待っている可能性があります。{date} まで保持されます。", + "needs_sign_in": "サインインが必要", + "switcher_waiting": "未送信メッセージ: {count} 件。{date} にこのデバイスから削除されます。", + "switcher_kept": "このデバイス上のメールは {date} に削除されます。", + "sibling_waiting": "{name} はサインアウトされました。未送信メッセージ: {count} 件。{date} までにサインインしないと、このデバイスから削除されます。", + "sibling_kept": "{name} はサインアウトされました。{date} までにサインインしないと、メールはこのデバイスから削除されます。" }, "offlineStorage": { "keep_description": "メール、カレンダー、連絡先、選んだファイルを暗号化してこのデバイスに保存します。オフラインでもメールを読む、フラグを付ける、移動することができ、書いたメールは送信トレイで待機します。サインアウトするとすべて削除されます。", @@ -5866,6 +5873,38 @@ "banner_refused": "サーバーが拒否した下書き: {count}", "banner_waiting": "サーバーを待っている下書き: {count}" }, + "offlineExport": { + "settings_label": "保存済みメールをエクスポート", + "settings_description": "このデバイスのメールを mbox または .eml ファイルとして保存します。オフラインでも使えます。", + "title": "保存済みメールをエクスポート", + "description": "このデバイスに保存されている原本を書き出します。.eml ファイルは完全なコピーです。mbox ファイルでは、形式に必要な印が加わり、既読とフラグの状態が各メッセージのヘッダーに、元の値に代えて書き込まれます。選んだ期間より古いメールは含まれません。サーバーのすべてを保存するには「すべてのメールをエクスポート」を使ってください。", + "folders_label": "フォルダー", + "all_folders": "すべてのフォルダー", + "folder_kept": "ここに {kept} / {total}", + "loading": "このデバイスのデータを読み込んでいます…", + "load_failed": "このデバイスのメールを読み込めませんでした。", + "nothing_kept": "このデバイスにはまだメールが保存されていません。", + "kept": "このデバイスにある原本: {kept} / {total} 件", + "missing_online": "サーバーからダウンロード: {count}", + "missing_offline": "オフラインではエクスポートできません: {count}。オンラインになったらもう一度お試しください。", + "reason_attachments_off": "元のメッセージは「添付ファイルを保存」がオンのときだけ保存されます。", + "reason_not_kept": "元のメッセージは最後に、空きがある間だけダウンロードされます。", + "not_kept_attachments_off": "このデバイスに添付ファイルを保存していないため原本なし: {count}", + "not_kept_not_kept": "このデバイスにまだ原本なし: {count}", + "retry": "オンラインでもう一度試す", + "retry_online": "オンラインなので、サーバーから取得できます。", + "retry_offline": "オンラインになったらもう一度お試しください。", + "settings_button": "エクスポート…", + "message_failed": "サーバーからこのメッセージを取得できませんでした。", + "failed_generic": "エラーのためエクスポートが止まりました。もう一度お試しください。", + "stopped_limit": "このブラウザーが保持できる上限の {limit} でエクスポートが止まりました。フォルダーを減らすか、Chromium、Firefox、またはデスクトップアプリをお使いください。", + "over_limit": "このブラウザーが保持できるのは最大 {limit} で、この選択は約 {size} です。フォルダーを減らすか、Chromium、Firefox、またはデスクトップアプリをお使いください。", + "no_room": "このエクスポートには約 {size} 必要ですが、このブラウザーの空きは {free} です。フォルダーを減らすか、空き容量を増やしてください。", + "stopped_no_room": "ブラウザーの空き容量が足りなくなりました。フォルダーを減らすか、空き容量を増やしてください。", + "stopped_offline_off": "このアカウントがこのデバイスにメールを保存しなくなったため、エクスポートが止まりました。", + "policy_unknown": "このアカウントに対する組織のルールがまだ分からないため、エクスポートは今のところオフです。", + "leftover_file": "保存先からファイル {name} を削除できませんでした。不完全なファイルなので削除してかまいません。" + }, "deep_link": { "message_not_found": "このメッセージは利用できなくなりました。", "thread_not_found": "この会話は利用できなくなりました。", @@ -6728,6 +6767,53 @@ "revoke": "取り消す", "revoked": "許可を取り消しました" }, + "desktopOs": { + "section_title": "デスクトップアプリ", + "section_description": "アプリとパソコンの連携方法です。", + "close_to_tray_label": "閉じてもトレイで実行を続ける", + "close_to_tray_description": "ウィンドウを閉じても新着メールとリマインダーは届きます。", + "start_at_login_label": "パソコンへのサインイン時に起動", + "start_at_login_description": "サインインするとアプリが自動で起動します。", + "start_minimized_label": "トレイで起動", + "start_minimized_description": "サインイン時にウィンドウは開きません。トレイのアイコンから開いてください。", + "attention_label": "新着メールでタスクバーを点滅", + "attention_mac_label": "新着メールで Dock アイコンを跳ねさせる", + "attention_description": "アプリが前面にないときのみ。", + "quiet_hours_label": "おやすみ時間", + "quiet_hours_description": "この時間は通知も音も出ません。メールは届きます。", + "quiet_hours_from": "開始", + "quiet_hours_to": "終了", + "show_shortcut_label": "キーボードショートカットでアプリを表示", + "show_shortcut_description": "{keys} で、ほかのアプリからこのアプリを前面に表示します。", + "show_shortcut_taken": "別のアプリが {keys} を使っています。そのアプリを閉じるか、この設定をオフにしてからオンに戻してください。", + "default_app_label": "既定のメールアプリ", + "default_app_description": "メールのリンクと .eml、.ics、.vcf ファイルをこのアプリで開きます。", + "default_app_is_default": "メールのリンクはこのアプリで開きます。", + "default_app_button": "既定のアプリを選択…", + "new_mail": "新着メール", + "no_subject": "(件名なし)", + "action_delete": "削除", + "copy_as_file": "ファイルとしてコピー", + "copied": "ファイルをコピーしました。フォルダーに貼り付けられます。", + "copy_failed": "ファイルをコピーできませんでした。", + "reply_lost": "入力した返信を保持できませんでした。メッセージを開いて返信してください。" + }, + "desktopReminders": { + "generic_title": "リマインダー", + "snooze": "{count} 分後に再通知", + "snooze_short": "{count} 分", + "open": "開く", + "join": "参加", + "missed_title": "{count, plural, other {見逃したリマインダー # 件}}", + "missed_more": "ほか {count} 件", + "missed_show": "表示", + "missed_dialog_title": "見逃したリマインダー", + "missed_dialog_description": "アプリが閉じていた間、コンピューターがスリープしていた間、または通知を一時停止していた間に期限が来たものです。", + "open_calendar": "カレンダーを開く", + "done": "完了", + "join_host": "参加 ({host})", + "join_named": "参加: {title}" + }, "desktop_policy": { "server_refused": "組織はこのデバイスでこのサーバーを許可していません。", "plugin_refused": "組織はこのデバイスでこのプラグインを許可していないため、実行されません。設定とデータは保持されます。", @@ -6736,5 +6822,30 @@ "plugins_off": "組織はこのデバイスのプラグインをオフにしています。", "plugins_allowlist": "組織はこのデバイスで特定のプラグインのみを許可しています。", "off": "オフ" + }, + "desktopTelemetry": { + "section_title": "利用情報とクラッシュレポート", + "section_description": "どちらもオンにするまでオフのままで、それぞれに専用のスイッチがあります。", + "telemetry_label": "利用情報を送信", + "telemetry_description": "1日に1回、ランダムなID、アプリのバージョン、Node のバージョン、OS、アプリの稼働時間、接続方法を送信します。自分のメールサーバーを使う場合は、有効な機能、おおよそのアカウント数、プラグインとテーマの数も送信します。メール、アドレス、サーバー名は送信しません。", + "crash_label": "クラッシュレポートを送信", + "crash_description": "アプリのエラーは、ランダムなID、アプリ自身のコードのファイル名、整理された短いメッセージとともに送信されます。アプリ自体がクラッシュした場合、クラッシュファイルはこのコンピューターに残り、あなたが送信したときだけ外に出ます。", + "crash_memory": "クラッシュファイルはクラッシュ時のアプリのメモリの一部のコピーです。Bulwark は見つけたユーザー名、アドレス、サインイントークンを削除しますが、クラッシュファイルにはメールの本文が残っていることがあります。", + "instance_note": "Bulwark サーバーでは、ページがサーバーに何を報告するかはそのサーバーの管理者が別途決めます。", + "forced_off": "組織によってオフにされています。", + "url_label": "クラッシュファイルの送信先", + "url_description": "クラッシュファイルを受け取る https アドレスです。Bulwark には用意されていません。", + "url_invalid": "完全な https アドレスを入力してください。", + "url_save": "保存", + "files_label": "このコンピューター上のクラッシュファイル", + "files_description": "Bulwark は新しいものを保持し、1か月より古いものを削除します。", + "files_empty": "クラッシュファイルはありません。", + "file_size": "{size} KB", + "send": "クラッシュレポートを送信…", + "delete_all": "すべて削除", + "sent": "クラッシュレポートを送信しました。", + "send_no_consent": "先にクラッシュレポートをオンにしてください。", + "send_no_url": "アドレスが設定されていないため、送信先がありません。上で入力してください。", + "send_failed": "クラッシュレポートを送信できませんでした。" } } diff --git a/locales/ko/common.json b/locales/ko/common.json index be79dba02..26b597eef 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "새 비밀번호를 기다리는 보내지 않은 메시지: {count}개. {date}까지 보관됩니다.", "refused_waiting_unknown": "보내지 않은 메시지가 새 비밀번호를 기다리고 있을 수 있습니다. {date}까지 보관됩니다.", "confirm_unsent_unknown": "여기에 보내지 않은 메시지가 있을 수 있습니다. 사라지게 됩니다.", - "confirm_templates": "이 기기에 저장된 이메일 템플릿" + "confirm_templates": "이 기기에 저장된 이메일 템플릿", + "session_waiting": "다음 로그인을 기다리는 보내지 않은 메시지: {count}개. {date}까지 보관됩니다.", + "session_waiting_unknown": "보내지 않은 메시지가 다음 로그인을 기다리고 있을 수 있습니다. {date}까지 보관됩니다.", + "needs_sign_in": "로그인 필요", + "switcher_waiting": "보내지 않은 메시지: {count}개. {date}에 이 기기에서 삭제됩니다.", + "switcher_kept": "이 기기의 메일은 {date}에 삭제됩니다.", + "sibling_waiting": "{name} 계정이 로그아웃되었습니다. 보내지 않은 메시지: {count}개. {date}까지 로그인하지 않으면 이 기기에서 삭제됩니다.", + "sibling_kept": "{name} 계정이 로그아웃되었습니다. {date}까지 로그인하지 않으면 메일이 이 기기에서 삭제됩니다." }, "offlineStorage": { "keep_description": "메일, 캘린더, 연락처, 선택한 파일을 이 기기에 암호화해 보관합니다. 오프라인에서도 메일을 읽고, 플래그를 지정하고, 이동할 수 있으며, 작성한 메일은 보낼편지함에서 대기합니다. 로그아웃하면 모두 삭제됩니다.", @@ -5866,6 +5873,38 @@ "banner_refused": "서버가 거부한 임시 보관 메일: {count}", "banner_waiting": "서버를 기다리는 임시 보관 메일: {count}" }, + "offlineExport": { + "settings_label": "보관된 메일 내보내기", + "settings_description": "이 기기의 메일을 mbox 또는 .eml 파일로 저장합니다. 오프라인에서도 됩니다.", + "title": "보관된 메일 내보내기", + "description": "이 기기에 보관된 원본을 저장합니다. .eml 파일은 정확한 사본입니다. mbox 파일은 형식에 필요한 표시를 추가하고, 읽음 및 플래그 상태를 각 메시지의 헤더에 기존 값 대신 기록합니다. 선택한 기간보다 오래된 메일은 포함되지 않습니다. 서버의 모든 메일을 저장하려면 “모든 메일 내보내기”을(를) 사용하세요.", + "folders_label": "폴더", + "all_folders": "모든 폴더", + "folder_kept": "여기에 {kept}/{total}", + "loading": "이 기기의 데이터를 읽는 중…", + "load_failed": "이 기기의 메일을 읽지 못했습니다.", + "nothing_kept": "이 기기에 아직 보관된 메일이 없습니다.", + "kept": "이 기기의 원본: {kept}/{total}개 메시지", + "missing_online": "서버에서 다운로드: {count}", + "missing_offline": "오프라인에서는 내보낼 수 없음: {count}. 온라인이 되면 다시 시도할 수 있습니다.", + "reason_attachments_off": "원본 메시지는 “첨부 파일 보관”이(가) 켜져 있을 때만 보관됩니다.", + "reason_not_kept": "원본 메시지는 맨 마지막에, 공간이 있을 때만 다운로드됩니다.", + "not_kept_attachments_off": "이 기기에 첨부 파일을 보관하지 않아 원본 없음: {count}", + "not_kept_not_kept": "이 기기에 아직 원본 없음: {count}", + "retry": "온라인에서 다시 시도", + "retry_online": "지금 온라인이므로 서버에서 가져올 수 있습니다.", + "retry_offline": "온라인이 되면 다시 시도할 수 있습니다.", + "settings_button": "내보내기…", + "message_failed": "서버가 이 메시지를 주지 않았습니다.", + "failed_generic": "오류로 내보내기가 중단되었습니다. 다시 시도하세요.", + "stopped_limit": "이 브라우저가 담을 수 있는 최대치인 {limit}에서 내보내기가 중단되었습니다. 폴더를 줄이거나 Chromium, Firefox 또는 데스크톱 앱을 사용하세요.", + "over_limit": "이 브라우저는 최대 {limit}까지 담을 수 있는데 이 선택은 약 {size}입니다. 폴더를 줄이거나 Chromium, Firefox 또는 데스크톱 앱을 사용하세요.", + "no_room": "이 내보내기에는 약 {size}이(가) 필요한데 이 브라우저에는 {free} 남아 있습니다. 폴더를 줄이거나 공간을 확보하세요.", + "stopped_no_room": "브라우저에 내보내기 공간이 부족해졌습니다. 폴더를 줄이거나 공간을 확보하세요.", + "stopped_offline_off": "이 계정이 더 이상 이 기기에 메일을 보관하지 않아 내보내기가 중단되었습니다.", + "policy_unknown": "이 계정에 대한 조직의 규칙을 아직 알 수 없어 내보내기가 당분간 꺼져 있습니다.", + "leftover_file": "저장한 위치에서 {name} 파일을 제거하지 못했습니다. 불완전한 파일이므로 삭제해도 됩니다." + }, "deep_link": { "message_not_found": "이 메시지를 더 이상 사용할 수 없습니다.", "thread_not_found": "이 대화를 더 이상 사용할 수 없습니다.", @@ -6728,6 +6767,53 @@ "revoke": "취소", "revoked": "허용을 취소했습니다" }, + "desktopOs": { + "section_title": "데스크톱 앱", + "section_description": "앱이 컴퓨터와 함께 작동하는 방식입니다.", + "close_to_tray_label": "닫을 때 트레이에서 계속 실행", + "close_to_tray_description": "창을 닫아도 새 메일과 리마인더가 계속 도착합니다.", + "start_at_login_label": "컴퓨터에 로그인할 때 시작", + "start_at_login_description": "로그인하면 앱이 자동으로 시작됩니다.", + "start_minimized_label": "트레이에서 시작", + "start_minimized_description": "로그인할 때 창이 열리지 않습니다. 트레이 아이콘으로 여세요.", + "attention_label": "새 메일이 오면 작업 표시줄 깜박이기", + "attention_mac_label": "새 메일이 오면 Dock 아이콘 튀기기", + "attention_description": "앱이 앞에 있지 않을 때만 작동합니다.", + "quiet_hours_label": "방해 금지 시간", + "quiet_hours_description": "이 시간에는 알림과 소리가 없습니다. 메일은 계속 도착합니다.", + "quiet_hours_from": "시작", + "quiet_hours_to": "종료", + "show_shortcut_label": "키보드 단축키로 앱 표시", + "show_shortcut_description": "{keys} 키로 다른 앱에서도 이 앱을 앞으로 가져옵니다.", + "show_shortcut_taken": "다른 앱이 {keys} 키를 사용 중입니다. 그 앱을 닫거나 이 옵션을 껐다가 다시 켜세요.", + "default_app_label": "기본 메일 앱", + "default_app_description": "메일 링크와 .eml, .ics, .vcf 파일을 이 앱으로 엽니다.", + "default_app_is_default": "이 앱이 메일 링크를 엽니다.", + "default_app_button": "기본 앱 선택…", + "new_mail": "새 메일", + "no_subject": "(제목 없음)", + "action_delete": "삭제", + "copy_as_file": "파일로 복사", + "copied": "파일을 복사했습니다. 폴더에 붙여넣을 수 있습니다.", + "copy_failed": "파일을 복사할 수 없습니다.", + "reply_lost": "입력한 답장을 유지할 수 없습니다. 메시지를 열어 답장하세요." + }, + "desktopReminders": { + "generic_title": "알림", + "snooze": "{count}분 후 다시 알림", + "snooze_short": "{count}분", + "open": "열기", + "join": "참가", + "missed_title": "{count, plural, other {놓친 알림 #개}}", + "missed_more": "외 {count}개", + "missed_show": "보기", + "missed_dialog_title": "놓친 알림", + "missed_dialog_description": "앱이 닫혀 있거나 컴퓨터가 절전 상태이거나 알림이 일시 중지된 동안 시간이 된 알림입니다.", + "open_calendar": "캘린더 열기", + "done": "완료", + "join_host": "참가 ({host})", + "join_named": "참가: {title}" + }, "desktop_policy": { "server_refused": "조직에서 이 기기의 이 서버 사용을 허용하지 않습니다.", "plugin_refused": "조직에서 이 기기의 이 플러그인 사용을 허용하지 않아 실행되지 않습니다. 설정과 데이터는 유지됩니다.", @@ -6736,5 +6822,30 @@ "plugins_off": "조직에서 이 기기의 플러그인을 껐습니다.", "plugins_allowlist": "조직에서 이 기기에 특정 플러그인만 허용합니다.", "off": "끔" + }, + "desktopTelemetry": { + "section_title": "사용 정보 및 충돌 보고서", + "section_description": "둘 다 켤 때까지 꺼져 있으며, 각각 별도의 스위치가 있습니다.", + "telemetry_label": "사용 정보 보내기", + "telemetry_description": "하루에 한 번: 임의의 ID, 앱 버전, Node 버전, 운영 체제, 앱 실행 시간, 앱의 연결 방식을 보냅니다. 자체 메일 서버를 쓰면 켜진 기능, 대략적인 계정 수, 플러그인과 테마 수도 보냅니다. 메일, 주소, 서버 이름은 절대 보내지 않습니다.", + "crash_label": "충돌 보고서 보내기", + "crash_description": "앱 오류는 임의의 ID, 앱 자체 코드의 파일 이름, 정리된 짧은 메시지와 함께 전송됩니다. 앱 자체가 충돌하면 충돌 파일이 이 컴퓨터에 남고, 직접 보낼 때만 밖으로 나갑니다.", + "crash_memory": "충돌 파일은 충돌 당시 앱 메모리 일부의 복사본입니다. Bulwark는 찾아낸 사용자 이름, 주소, 로그인 토큰을 제거하지만 충돌 파일에 메일 본문이 남아 있을 수 있습니다.", + "instance_note": "Bulwark 서버에서는 페이지가 서버에 무엇을 보고할지 해당 서버의 관리자가 별도로 정합니다.", + "forced_off": "조직에서 이 기능을 껐습니다.", + "url_label": "충돌 파일을 보낼 곳", + "url_description": "충돌 파일을 받는 https 주소입니다. Bulwark에는 기본 주소가 없습니다.", + "url_invalid": "전체 https 주소를 입력하세요.", + "url_save": "저장", + "files_label": "이 컴퓨터의 충돌 파일", + "files_description": "Bulwark는 최신 파일을 보관하고 한 달이 지난 파일은 삭제합니다.", + "files_empty": "충돌 파일이 없습니다.", + "file_size": "{size} KB", + "send": "충돌 보고서 보내기…", + "delete_all": "모두 삭제", + "sent": "충돌 보고서를 보냈습니다.", + "send_no_consent": "먼저 충돌 보고서를 켜세요.", + "send_no_url": "주소가 설정되지 않아 보낼 곳이 없습니다. 위에 입력하세요.", + "send_failed": "충돌 보고서를 보낼 수 없습니다." } } diff --git a/locales/lv/common.json b/locales/lv/common.json index cf37185f9..dec70b2de 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Nenosūtītie ziņojumi gaida jūsu jauno paroli: {count}. Tie paliek līdz {date}.", "refused_waiting_unknown": "Iespējams, nenosūtītie ziņojumi gaida jūsu jauno paroli. Tie paliek līdz {date}.", "confirm_unsent_unknown": "Šeit var būt nenosūtīti ziņojumi. Tie tiks zaudēti.", - "confirm_templates": "Šajā ierīcē saglabātās e-pasta veidnes" + "confirm_templates": "Šajā ierīcē saglabātās e-pasta veidnes", + "session_waiting": "Nenosūtītie ziņojumi gaida nākamo pieteikšanos: {count}. Tie paliek līdz {date}.", + "session_waiting_unknown": "Iespējams, nenosūtītie ziņojumi gaida nākamo pieteikšanos. Tie paliek līdz {date}.", + "needs_sign_in": "Nepieciešama pieteikšanās", + "switcher_waiting": "Nenosūtītie ziņojumi: {count}. Tiks izdzēsti no šīs ierīces {date}.", + "switcher_kept": "E-pasts šajā ierīcē tiks izdzēsts {date}.", + "sibling_waiting": "{name} tika izrakstīts. Nenosūtītie ziņojumi: {count}. Piesakieties līdz {date}, citādi tie tiks izdzēsti no šīs ierīces.", + "sibling_kept": "{name} tika izrakstīts. Piesakieties līdz {date}, citādi e-pasts tiks izdzēsts no šīs ierīces." }, "offlineStorage": { "keep_description": "Glabā jūsu e-pastu, kalendāru, kontaktpersonas un izvēlētos failus šifrētus šajā ierīcē. Bezsaistē varat lasīt, atzīmēt un pārvietot vēstules, un tas, ko rakstāt, gaida izsūtnē. Izrakstoties viss tiek noņemts.", @@ -5866,6 +5873,38 @@ "banner_refused": "Servera noraidītie melnraksti: {count}", "banner_waiting": "Melnraksti, kas gaida serveri: {count}" }, + "offlineExport": { + "settings_label": "Eksportēt saglabāto pastu", + "settings_description": "Saglabājiet šīs ierīces pastu kā mbox vai .eml failus. Darbojas arī bezsaistē.", + "title": "Eksportēt saglabāto pastu", + "description": "Saglabā oriģinālus, ko glabā šī ierīce. .eml faili ir precīzas kopijas. mbox fails pievieno formātam vajadzīgās atzīmes un ieraksta izlasīts un atzīmēts statusu katra ziņojuma galvenēs iepriekšējo vietā. Pasta, kas vecāks par izvēlēto periodu, tajā nav. Lai saglabātu visu no servera, izmantojiet “Eksportēt visu pastu”.", + "folders_label": "Mapes", + "all_folders": "Visas mapes", + "folder_kept": "Šeit: {kept} no {total}", + "loading": "Tiek lasīti šīs ierīces dati…", + "load_failed": "Neizdevās nolasīt šīs ierīces pastu.", + "nothing_kept": "Šajā ierīcē vēl nav saglabāta pasta.", + "kept": "Oriģināli šajā ierīcē: {kept} no {total} ziņojumiem", + "missing_online": "Jālejupielādē no servera: {count}", + "missing_offline": "Bezsaistē nevar eksportēt: {count}. Varat mēģināt vēlreiz, kad būsiet tiešsaistē.", + "reason_attachments_off": "Oriģinālie ziņojumi tiek glabāti tikai tad, ja ir ieslēgts “Glabāt pielikumus”.", + "reason_not_kept": "Oriģinālie ziņojumi tiek lejupielādēti pēdējie un tikai tad, ja ir vieta.", + "not_kept_attachments_off": "Oriģināla nav, jo šī ierīce neglabā pielikumus: {count}", + "not_kept_not_kept": "Šajā ierīcē vēl nav oriģināla: {count}", + "retry": "Mēģināt vēlreiz tiešsaistē", + "retry_online": "Tagad esat tiešsaistē, tāpēc tie var nākt no servera.", + "retry_offline": "Varat mēģināt vēlreiz, kad būsiet tiešsaistē.", + "settings_button": "Eksportēt…", + "message_failed": "Serveris nedeva šo ziņojumu.", + "failed_generic": "Eksports apstājās kļūdas dēļ. Mēģiniet vēlreiz.", + "stopped_limit": "Eksports apstājās pie {limit}, vairāk šī pārlūkprogramma nevar turēt. Izvēlieties mazāk mapju vai izmantojiet Chromium, Firefox vai datora lietotni.", + "over_limit": "Šī pārlūkprogramma var turēt ne vairāk kā {limit}, un šī izvēle ir aptuveni {size}. Izvēlieties mazāk mapju vai izmantojiet Chromium, Firefox vai datora lietotni.", + "no_room": "Šim eksportam vajag aptuveni {size}, un šai pārlūkprogrammai ir pieejami {free}. Izvēlieties mazāk mapju vai atbrīvojiet vietu.", + "stopped_no_room": "Pārlūkprogrammai eksportam pietrūka vietas. Izvēlieties mazāk mapju vai atbrīvojiet vietu.", + "stopped_offline_off": "Eksports apstājās, jo šis konts vairs neglabā pastu šajā ierīcē.", + "policy_unknown": "Jūsu organizācijas noteikumi šim kontam vēl nav zināmi, tāpēc eksports pagaidām ir izslēgts.", + "leftover_file": "Failu {name} neizdevās noņemt no saglabāšanas vietas. Tas ir nepilnīgs, varat to izdzēst." + }, "deep_link": { "message_not_found": "Šī ziņa vairs nav pieejama.", "thread_not_found": "Šī sarakste vairs nav pieejama.", @@ -6728,6 +6767,53 @@ "revoke": "Atsaukt", "revoked": "Atļauja atsaukta" }, + "desktopOs": { + "section_title": "Darbvirsmas lietotne", + "section_description": "Kā lietotne darbojas ar jūsu datoru.", + "close_to_tray_label": "Aizverot turpināt darbu teknē", + "close_to_tray_description": "Jauns pasts un atgādinājumi turpina pienākt arī pēc loga aizvēršanas.", + "start_at_login_label": "Sākt, piesakoties datorā", + "start_at_login_description": "Lietotne sākas pati, kad piesakāties.", + "start_minimized_label": "Sākt teknē", + "start_minimized_description": "Piesakoties logs neatveras. Atveriet to ar teknes ikonu.", + "attention_label": "Mirgot uzdevumjoslā, pienākot pastam", + "attention_mac_label": "Lēkāt Dock ikonai, pienākot pastam", + "attention_description": "Tikai tad, ja lietotne nav priekšplānā.", + "quiet_hours_label": "Klusuma stundas", + "quiet_hours_description": "Šajā laikā nav paziņojumu un skaņu. Pasts turpina pienākt.", + "quiet_hours_from": "No", + "quiet_hours_to": "līdz", + "show_shortcut_label": "Rādīt lietotni ar īsinājumtaustiņu", + "show_shortcut_description": "{keys} parāda lietotni priekšplānā no jebkuras citas lietotnes.", + "show_shortcut_taken": "{keys} izmanto cita lietotne. Aizveriet to vai izslēdziet un atkal ieslēdziet šo iestatījumu.", + "default_app_label": "Noklusējuma pasta lietotne", + "default_app_description": "Atvērt pasta saites un .eml, .ics un .vcf failus ar šo lietotni.", + "default_app_is_default": "Šī lietotne atver jūsu pasta saites.", + "default_app_button": "Izvēlēties noklusējuma lietotni…", + "new_mail": "Jauns pasts", + "no_subject": "(nav tēmas)", + "action_delete": "Dzēst", + "copy_as_file": "Kopēt kā failu", + "copied": "Fails nokopēts. Varat to ielīmēt mapē.", + "copy_failed": "Failu neizdevās nokopēt.", + "reply_lost": "Ievadīto atbildi neizdevās saglabāt. Atveriet ziņojumu un atbildiet tur." + }, + "desktopReminders": { + "generic_title": "Atgādinājums", + "snooze": "Atlikt par {count} min", + "snooze_short": "{count} min", + "open": "Atvērt", + "join": "Pievienoties", + "missed_title": "{count, plural, zero {# nokavētu atgādinājumu} one {# nokavēts atgādinājums} other {# nokavēti atgādinājumi}}", + "missed_more": "un vēl {count}", + "missed_show": "Rādīt", + "missed_dialog_title": "Nokavētie atgādinājumi", + "missed_dialog_description": "Tie pienāca, kamēr lietotne bija aizvērta, dators bija miega režīmā vai paziņojumi bija apturēti.", + "open_calendar": "Atvērt kalendāru", + "done": "Gatavs", + "join_host": "Pievienoties ({host})", + "join_named": "Pievienoties: {title}" + }, "desktop_policy": { "server_refused": "Jūsu organizācija šajā ierīcē neatļauj šo serveri.", "plugin_refused": "Jūsu organizācija šajā ierīcē neatļauj šo spraudni, tāpēc tas nedarbojas. Tā iestatījumi un dati tiek saglabāti.", @@ -6736,5 +6822,30 @@ "plugins_off": "Jūsu organizācija šajā ierīcē ir izslēgusi spraudņus.", "plugins_allowlist": "Jūsu organizācija šajā ierīcē atļauj tikai noteiktus spraudņus.", "off": "Izslēgts" + }, + "desktopTelemetry": { + "section_title": "Lietošanas dati un avāriju atskaites", + "section_description": "Abi ir izslēgti, līdz tos ieslēdzat, un katram ir savs slēdzis.", + "telemetry_label": "Sūtīt lietošanas datus", + "telemetry_description": "Reizi dienā: nejaušs ID, lietotnes versija, Node versija, jūsu operētājsistēma, cik ilgi lietotne darbojas un kā tā savienojas. Ar savu pasta serveri arī tas, kuras funkcijas ir ieslēgtas, aptuvens kontu skaits un cik spraudņu un motīvu jums ir. Nekad vēstules, adreses vai serveru nosaukumi.", + "crash_label": "Sūtīt avāriju atskaites", + "crash_description": "Lietotnes kļūdas tiek sūtītas ar nejaušu ID, failu nosaukumiem no pašas lietotnes koda un īsiem, attīrītiem ziņojumiem. Ja avarē pati lietotne, avārijas fails paliek šajā datorā un to atstāj tikai tad, ja to nosūtāt.", + "crash_memory": "Avārijas fails ir daļas lietotnes atmiņas kopija avārijas brīdī. Bulwark noņem atrastos lietotājvārdus, adreses un pierakstīšanās marķierus, bet avārijas failā joprojām var būt jūsu vēstuļu teksts.", + "instance_note": "Bulwark serverī tā administrators atsevišķi izlemj, ko lapas tam ziņo.", + "forced_off": "Jūsu organizācija to ir izslēgusi.", + "url_label": "Kurp nonāk avārijas faili", + "url_description": "Https adrese, kas saņem avārijas failu. Bulwark tādu nenodrošina.", + "url_invalid": "Ievadiet pilnu https adresi.", + "url_save": "Saglabāt", + "files_label": "Avārijas faili šajā datorā", + "files_description": "Bulwark patur jaunākos un dzēš tos, kas vecāki par mēnesi.", + "files_empty": "Nav avārijas failu.", + "file_size": "{size} KB", + "send": "Sūtīt avārijas atskaiti…", + "delete_all": "Dzēst visus", + "sent": "Avārijas atskaite nosūtīta.", + "send_no_consent": "Vispirms ieslēdziet avāriju atskaites.", + "send_no_url": "Adrese nav iestatīta, tāpēc to nav kur sūtīt. Ievadiet to augstāk.", + "send_failed": "Avārijas atskaiti neizdevās nosūtīt." } } diff --git a/locales/mn/common.json b/locales/mn/common.json index 49d159c2f..bc4c5bcf8 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Шинэ нууц үгийг тань хүлээж буй илгээгээгүй зурвас: {count}. {date} хүртэл хадгалагдана.", "refused_waiting_unknown": "Илгээгээгүй зурвасууд шинэ нууц үгийг тань хүлээж байж магадгүй. {date} хүртэл хадгалагдана.", "confirm_unsent_unknown": "Энд илгээгээгүй зурвас хүлээж байж магадгүй. Тэдгээр нь алдагдана.", - "confirm_templates": "Энэ төхөөрөмжид хадгалсан имэйлийн загварууд" + "confirm_templates": "Энэ төхөөрөмжид хадгалсан имэйлийн загварууд", + "session_waiting": "Дараагийн нэвтрэлтийг хүлээж буй илгээгээгүй зурвас: {count}. {date} хүртэл хадгалагдана.", + "session_waiting_unknown": "Илгээгээгүй зурвасууд дараагийн нэвтрэлтийг хүлээж байж магадгүй. {date} хүртэл хадгалагдана.", + "needs_sign_in": "Нэвтрэх шаардлагатай", + "switcher_waiting": "Илгээгээгүй зурвас: {count}. {date}-нд энэ төхөөрөмжөөс устгагдана.", + "switcher_kept": "Энэ төхөөрөмж дээрх имэйл {date}-нд устгагдана.", + "sibling_waiting": "{name} системээс гарсан. Илгээгээгүй зурвас: {count}. {date} хүртэл нэвтэрнэ үү, эс бөгөөс энэ төхөөрөмжөөс устгагдана.", + "sibling_kept": "{name} системээс гарсан. {date} хүртэл нэвтэрнэ үү, эс бөгөөс имэйл энэ төхөөрөмжөөс устгагдана." }, "offlineStorage": { "keep_description": "Таны имэйл, хуанли, харилцагчид болон сонгосон файлуудыг энэ төхөөрөмж дээр шифрлэн хадгална. Офлайн үед имэйл уншиж, тэмдэглэж, зөөж болох ба бичсэн зүйл тань гарах хайрцагт хүлээнэ. Гарахад бүгд устна.", @@ -5866,6 +5873,38 @@ "banner_refused": "Серверийн татгалзсан ноорог: {count}", "banner_waiting": "Серверийг хүлээж буй ноорог: {count}" }, + "offlineExport": { + "settings_label": "Хадгалсан захидлыг экспортлох", + "settings_description": "Энэ төхөөрөмжийн захидлыг mbox эсвэл .eml файлаар хадгална. Офлайн үед ч ажиллана.", + "title": "Хадгалсан захидлыг экспортлох", + "description": "Энэ төхөөрөмжид хадгалсан эх хувиудыг хадгална. .eml файлууд яг хуулбар байна. mbox файл нь форматад шаардлагатай тэмдгүүдийг нэмж, уншсан болон тэмдэглэсэн төлөвийг захидал бүрийн толгойд өмнөхийнх нь оронд бичнэ. Сонгосон хугацаанаас хуучин захидал орохгүй. Серверт байгаа бүгдийг хадгалахын тулд “Бүх шуудан экспортлох”-г ашиглана уу.", + "folders_label": "Хавтаснууд", + "all_folders": "Бүх хавтас", + "folder_kept": "Энд: {total}-с {kept}", + "loading": "Энэ төхөөрөмжийн өгөгдлийг уншиж байна…", + "load_failed": "Энэ төхөөрөмжийн захидлыг уншиж чадсангүй.", + "nothing_kept": "Энэ төхөөрөмжид хадгалсан захидал одоогоор алга.", + "kept": "Энэ төхөөрөмж дээрх эх хувь: {total}-с {kept}", + "missing_online": "Серверээс татах: {count}", + "missing_offline": "Офлайн үед экспортлох боломжгүй: {count}. Онлайн болсны дараа дахин оролдоно уу.", + "reason_attachments_off": "Эх захидлуудыг зөвхөн “Хавсралтуудыг хадгалах” асаалттай үед хадгална.", + "reason_not_kept": "Эх захидлууд хамгийн сүүлд, зөвхөн зай байвал татагдана.", + "not_kept_attachments_off": "Энэ төхөөрөмж хавсралт хадгалдаггүй тул эх хувь алга: {count}", + "not_kept_not_kept": "Энэ төхөөрөмжид эх хувь одоогоор алга: {count}", + "retry": "Онлайнаар дахин оролдох", + "retry_online": "Та одоо онлайн байгаа тул серверээс авч болно.", + "retry_offline": "Онлайн болсны дараа дахин оролдоно уу.", + "settings_button": "Экспортлох…", + "message_failed": "Сервер энэ захидлыг өгсөнгүй.", + "failed_generic": "Алдааны улмаас экспорт зогслоо. Дахин оролдоно уу.", + "stopped_limit": "Экспорт {limit} дээр зогслоо, энэ хөтчийн багтаах дээд хэмжээ. Цөөн хавтас сонгох, эсвэл Chromium, Firefox, ширээний аппыг ашиглана уу.", + "over_limit": "Энэ хөтөч дээд тал нь {limit} багтаана, энэ сонголт ойролцоогоор {size}. Цөөн хавтас сонгох, эсвэл Chromium, Firefox, ширээний аппыг ашиглана уу.", + "no_room": "Энэ экспортод ойролцоогоор {size} хэрэгтэй, энэ хөтөчид {free} үлдсэн. Цөөн хавтас сонгох эсвэл зай чөлөөлнө үү.", + "stopped_no_room": "Хөтчид экспортын зай дууслаа. Цөөн хавтас сонгох эсвэл зай чөлөөлнө үү.", + "stopped_offline_off": "Энэ бүртгэл энэ төхөөрөмжид захидал хадгалахаа больсон тул экспорт зогслоо.", + "policy_unknown": "Танай байгууллагын энэ бүртгэлд хамаарах дүрэм одоогоор тодорхойгүй тул экспорт түр унтраалттай байна.", + "leftover_file": "{name} файлыг хадгалсан газраас нь устгаж чадсангүй. Энэ нь бүрэн бус тул та устгаж болно." + }, "deep_link": { "message_not_found": "Энэ зурвас дахин ашиглах боломжгүй болсон.", "thread_not_found": "Энэ харилцан яриа дахин ашиглах боломжгүй болсон.", @@ -6728,6 +6767,53 @@ "revoke": "Буцааж авах", "revoked": "Зөвшөөрлийг буцааж авлаа" }, + "desktopOs": { + "section_title": "Ширээний апп", + "section_description": "Апп таны компьютертэй хэрхэн ажиллах.", + "close_to_tray_label": "Хаахад тавиур дээр үргэлжлүүлэн ажиллах", + "close_to_tray_description": "Цонхыг хаасны дараа ч шинэ захиа, сануулга ирсээр байна.", + "start_at_login_label": "Компьютерт нэвтрэхэд эхлүүлэх", + "start_at_login_description": "Нэвтрэхэд апп өөрөө эхэлнэ.", + "start_minimized_label": "Тавиур дээр эхлүүлэх", + "start_minimized_description": "Нэвтрэхэд цонх нээгдэхгүй. Тавиурын дүрсээр нээнэ үү.", + "attention_label": "Шинэ захиа ирэхэд ажлын самбарыг анивчуулах", + "attention_mac_label": "Шинэ захиа ирэхэд Dock дүрсийг үсрүүлэх", + "attention_description": "Зөвхөн апп урд талд байхгүй үед.", + "quiet_hours_label": "Чимээгүй цаг", + "quiet_hours_description": "Энэ үед мэдэгдэл, дуу гарахгүй. Захиа ирсээр байна.", + "quiet_hours_from": "Эхлэх", + "quiet_hours_to": "хүртэл", + "show_shortcut_label": "Гарын товчлолоор аппыг харуулах", + "show_shortcut_description": "{keys} нь аль ч аппаас энэ аппыг урд гаргана.", + "show_shortcut_taken": "{keys}-г өөр апп ашиглаж байна. Тэр аппыг хаах, эсвэл үүнийг унтраагаад дахин асаана уу.", + "default_app_label": "Үндсэн шуудангийн апп", + "default_app_description": "Шуудангийн холбоос болон .eml, .ics, .vcf файлыг энэ аппаар нээх.", + "default_app_is_default": "Энэ апп таны шуудангийн холбоосыг нээдэг.", + "default_app_button": "Үндсэн апп сонгох…", + "new_mail": "Шинэ захиа", + "no_subject": "(гарчиггүй)", + "action_delete": "Устгах", + "copy_as_file": "Файл болгож хуулах", + "copied": "Файлыг хууллаа. Хавтсанд буулгаж болно.", + "copy_failed": "Файлыг хуулж чадсангүй.", + "reply_lost": "Бичсэн хариуг хадгалж чадсангүй. Захиаг нээгээд тэндээс хариулна уу." + }, + "desktopReminders": { + "generic_title": "Сануулга", + "snooze": "{count} минут хойшлуулах", + "snooze_short": "{count} мин", + "open": "Нээх", + "join": "Нэгдэх", + "missed_title": "{count, plural, one {Алдсан # сануулга} other {Алдсан # сануулга}}", + "missed_more": "бас {count}", + "missed_show": "Харуулах", + "missed_dialog_title": "Алдсан сануулгууд", + "missed_dialog_description": "Апп хаалттай, компьютер унтсан эсвэл мэдэгдэл түр зогссон үед хугацаа нь болсон.", + "open_calendar": "Хуанли нээх", + "done": "Болсон", + "join_host": "Нэгдэх ({host})", + "join_named": "Нэгдэх: {title}" + }, "desktop_policy": { "server_refused": "Танай байгууллага энэ төхөөрөмж дээр энэ серверийг зөвшөөрөхгүй байна.", "plugin_refused": "Танай байгууллага энэ төхөөрөмж дээр энэ залгаасыг зөвшөөрөхгүй тул ажиллахгүй. Тохиргоо, өгөгдөл нь хадгалагдана.", @@ -6736,5 +6822,30 @@ "plugins_off": "Танай байгууллага энэ төхөөрөмж дээр залгаасуудыг унтраасан.", "plugins_allowlist": "Танай байгууллага энэ төхөөрөмж дээр зөвхөн тодорхой залгаасуудыг зөвшөөрдөг.", "off": "Унтраалттай" + }, + "desktopTelemetry": { + "section_title": "Хэрэглээний мэдээлэл ба гэмтлийн тайлан", + "section_description": "Хоёулаа таныг асаах хүртэл унтраалттай бөгөөд тус бүр өөрийн унтраалгатай.", + "telemetry_label": "Хэрэглээний мэдээлэл илгээх", + "telemetry_description": "Өдөрт нэг удаа: санамсаргүй ID, аппын хувилбар, Node-ийн хувилбар, таны үйлдлийн систем, апп хэр удаан ажиллаж байгаа, хэрхэн холбогддог. Өөрийн шуудангийн сервертэй бол мөн ямар боломжууд асаалттай, бүртгэлийн ойролцоо тоо, хэдэн залгаас ба загвартай. Захидал, хаяг, серверийн нэрийг хэзээ ч илгээхгүй.", + "crash_label": "Гэмтлийн тайлан илгээх", + "crash_description": "Аппын алдаанууд санамсаргүй ID, аппын өөрийн кодын файлын нэрс болон богино, цэвэрлэсэн мессежтэй хамт илгээгдэнэ. Апп өөрөө гэмтвэл гэмтлийн файл энэ компьютерт үлдэх бөгөөд та илгээсэн үед л гарна.", + "crash_memory": "Гэмтлийн файл нь гэмтэх үеийн аппын санах ойн хэсгийн хуулбар юм. Bulwark олсон хэрэглэгчийн нэр, хаяг, нэвтрэх токенуудыг устгадаг ч гэмтлийн файлд таны захидлын текст үлдэж болно.", + "instance_note": "Bulwark сервер дээр хуудсууд түүнд юу мэдээлэхийг админ нь тусад нь шийддэг.", + "forced_off": "Танай байгууллага үүнийг унтраасан.", + "url_label": "Гэмтлийн файлууд хаашаа очих", + "url_description": "Гэмтлийн файлыг хүлээн авах https хаяг. Bulwark-д ийм хаяг байхгүй.", + "url_invalid": "Бүтэн https хаяг оруулна уу.", + "url_save": "Хадгалах", + "files_label": "Энэ компьютер дээрх гэмтлийн файлууд", + "files_description": "Bulwark хамгийн сүүлийнхийг хадгалж, сараас хуучныг устгадаг.", + "files_empty": "Гэмтлийн файл алга.", + "file_size": "{size} КБ", + "send": "Гэмтлийн тайлан илгээх…", + "delete_all": "Бүгдийг устгах", + "sent": "Гэмтлийн тайлан илгээгдлээ.", + "send_no_consent": "Эхлээд гэмтлийн тайланг асаана уу.", + "send_no_url": "Хаяг тохируулаагүй тул илгээх газаргүй. Дээр нэгийг оруулна уу.", + "send_failed": "Гэмтлийн тайланг илгээж чадсангүй." } } diff --git a/locales/nb/common.json b/locales/nb/common.json index 2e74c4d09..4d0ef1f35 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Usendte meldinger som venter på det nye passordet: {count}. De blir til {date}.", "refused_waiting_unknown": "Usendte meldinger venter kanskje på det nye passordet. De blir til {date}.", "confirm_unsent_unknown": "Det venter kanskje usendte meldinger her. De går tapt.", - "confirm_templates": "E-postmaler lagret på denne enheten" + "confirm_templates": "E-postmaler lagret på denne enheten", + "session_waiting": "Usendte meldinger som venter på neste pålogging: {count}. De blir til {date}.", + "session_waiting_unknown": "Usendte meldinger venter kanskje på neste pålogging. De blir til {date}.", + "needs_sign_in": "Krever pålogging", + "switcher_waiting": "Usendte meldinger: {count}. Slettes fra denne enheten {date}.", + "switcher_kept": "E-posten på denne enheten slettes {date}.", + "sibling_waiting": "{name} ble logget ut. Usendte meldinger: {count}. Logg på innen {date}, ellers slettes de fra denne enheten.", + "sibling_kept": "{name} ble logget ut. Logg på innen {date}, ellers slettes e-posten fra denne enheten." }, "offlineStorage": { "keep_description": "Beholder e-posten, kalenderen, kontaktene og filene du velger, kryptert på denne enheten. Frakoblet kan du lese, flagge og flytte e-post, og det du skriver, venter i utboksen. Når du logger av, fjernes alt.", @@ -5866,6 +5873,38 @@ "banner_refused": "Utkast avvist av serveren: {count}", "banner_waiting": "Utkast som venter på serveren: {count}" }, + "offlineExport": { + "settings_label": "Eksporter lagret e-post", + "settings_description": "Lagre e-posten på denne enheten som mbox- eller .eml-filer. Dette virker også frakoblet.", + "title": "Eksporter lagret e-post", + "description": "Dette lagrer originalene denne enheten beholder. .eml-filer er nøyaktige kopier. En mbox-fil legger til merkene formatet trenger, og skriver status for lest og flagget i hver meldings overskrifter, i stedet for dem den hadde. E-post eldre enn perioden du valgte, er ikke med. Bruk «Eksporter all e-post» for å lagre alt fra serveren.", + "folders_label": "Mapper", + "all_folders": "Alle mapper", + "folder_kept": "{kept} av {total} her", + "loading": "Leser det som ligger på denne enheten…", + "load_failed": "Kunne ikke lese e-posten på denne enheten.", + "nothing_kept": "Ingen e-post er lagret på denne enheten ennå.", + "kept": "Originaler på denne enheten: {kept} av {total} meldinger", + "missing_online": "Skal lastes ned fra serveren: {count}", + "missing_offline": "Kan ikke eksporteres frakoblet: {count}. Du kan prøve igjen når du er tilkoblet.", + "reason_attachments_off": "Originale meldinger beholdes bare når «Behold vedlegg» er på.", + "reason_not_kept": "Originale meldinger lastes ned sist, og bare så lenge det er plass.", + "not_kept_attachments_off": "Ingen original her fordi vedlegg ikke beholdes på denne enheten: {count}", + "not_kept_not_kept": "Ingen original på denne enheten ennå: {count}", + "retry": "Prøv igjen tilkoblet", + "retry_online": "Du er tilkoblet nå, så disse kan komme fra serveren.", + "retry_offline": "Du kan prøve igjen når du er tilkoblet.", + "settings_button": "Eksporter…", + "message_failed": "Serveren leverte ikke denne meldingen.", + "failed_generic": "Eksporten stoppet på grunn av en feil. Prøv igjen.", + "stopped_limit": "Eksporten stoppet ved {limit}, det meste denne nettleseren kan holde. Velg færre mapper, eller bruk Chromium, Firefox eller skrivebordsappen.", + "over_limit": "Denne nettleseren kan holde høyst {limit}, og dette valget er omtrent {size}. Velg færre mapper, eller bruk Chromium, Firefox eller skrivebordsappen.", + "no_room": "Denne eksporten trenger omtrent {size}, og denne nettleseren har {free} igjen. Velg færre mapper, eller frigjør plass.", + "stopped_no_room": "Nettleseren gikk tom for plass til eksporten. Velg færre mapper, eller frigjør plass.", + "stopped_offline_off": "Eksporten stoppet fordi denne kontoen ikke lenger beholder e-post på denne enheten.", + "policy_unknown": "Organisasjonens regler for denne kontoen er ikke kjent ennå, så eksport er av inntil videre.", + "leftover_file": "Filen {name} kunne ikke fjernes fra stedet du lagret den. Den er ufullstendig, så du kan slette den." + }, "deep_link": { "message_not_found": "Denne meldingen er ikke lenger tilgjengelig.", "thread_not_found": "Denne samtalen er ikke lenger tilgjengelig.", @@ -6728,6 +6767,53 @@ "revoke": "Trekk tilbake", "revoked": "Tillatelsen er trukket tilbake" }, + "desktopOs": { + "section_title": "Skrivebordsapp", + "section_description": "Slik samarbeider appen med datamaskinen din.", + "close_to_tray_label": "Fortsett i systemstatusfeltet ved lukking", + "close_to_tray_description": "Ny e-post og påminnelser fortsetter å komme etter at du lukker vinduet.", + "start_at_login_label": "Start når du logger på datamaskinen", + "start_at_login_description": "Appen starter av seg selv når du logger på.", + "start_minimized_label": "Start i systemstatusfeltet", + "start_minimized_description": "Det åpnes ikke noe vindu ved pålogging. Åpne appen med ikonet i systemstatusfeltet.", + "attention_label": "Blink i oppgavelinjen ved ny e-post", + "attention_mac_label": "La Dock-ikonet sprette ved ny e-post", + "attention_description": "Bare når appen ikke er foran.", + "quiet_hours_label": "Stille timer", + "quiet_hours_description": "Ingen varsler eller lyder i dette tidsrommet. E-post kommer fortsatt.", + "quiet_hours_from": "Fra", + "quiet_hours_to": "til", + "show_shortcut_label": "Vis appen med en hurtigtast", + "show_shortcut_description": "{keys} henter appen frem fra hvilken som helst annen app.", + "show_shortcut_taken": "En annen app bruker {keys}. Lukk den appen, eller slå dette av og på igjen.", + "default_app_label": "Standard e-postapp", + "default_app_description": "Åpne e-postlenker og .eml-, .ics- og .vcf-filer med denne appen.", + "default_app_is_default": "Denne appen åpner e-postlenkene dine.", + "default_app_button": "Velg standardapp…", + "new_mail": "Ny e-post", + "no_subject": "(uten emne)", + "action_delete": "Slett", + "copy_as_file": "Kopier som fil", + "copied": "Filen er kopiert. Du kan lime den inn i en mappe.", + "copy_failed": "Filen kunne ikke kopieres.", + "reply_lost": "Svaret du skrev, kunne ikke beholdes. Åpne meldingen og svar der." + }, + "desktopReminders": { + "generic_title": "Påminnelse", + "snooze": "Utsett {count} min", + "snooze_short": "{count} min", + "open": "Åpne", + "join": "Bli med", + "missed_title": "{count, plural, one {# tapt påminnelse} other {# tapte påminnelser}}", + "missed_more": "og {count} til", + "missed_show": "Vis", + "missed_dialog_title": "Tapte påminnelser", + "missed_dialog_description": "De forfalt mens appen var lukket, datamaskinen sov eller varsler var satt på pause.", + "open_calendar": "Åpne kalender", + "done": "Ferdig", + "join_host": "Bli med ({host})", + "join_named": "Bli med: {title}" + }, "desktop_policy": { "server_refused": "Organisasjonen din tillater ikke denne serveren på denne enheten.", "plugin_refused": "Organisasjonen din tillater ikke dette programtillegget på denne enheten, så det kjører ikke. Innstillingene og dataene beholdes.", @@ -6736,5 +6822,30 @@ "plugins_off": "Organisasjonen din har slått av programtillegg på denne enheten.", "plugins_allowlist": "Organisasjonen din tillater bare bestemte programtillegg på denne enheten.", "off": "Av" + }, + "desktopTelemetry": { + "section_title": "Bruksdata og krasjrapporter", + "section_description": "Begge er av til du slår dem på, og hver har sin egen bryter.", + "telemetry_label": "Send bruksdata", + "telemetry_description": "Én gang om dagen: en tilfeldig ID, appversjonen, Node-versjonen, operativsystemet ditt, hvor lenge appen har kjørt og hvordan den kobler til. Med egen e-postserver også hvilke funksjoner som er på, et omtrentlig antall kontoer og hvor mange programtillegg og temaer du har. Aldri e-post, adresser eller servernavn.", + "crash_label": "Send krasjrapporter", + "crash_description": "Feil i appen sendes med en tilfeldig ID, filnavn fra appens egen kode og korte, rensede meldinger. Hvis selve appen krasjer, blir en krasjfil liggende på denne datamaskinen og forlater den bare hvis du sender den.", + "crash_memory": "En krasjfil er en kopi av en del av appens minne da den krasjet. Bulwark fjerner brukernavn, adresser og påloggingstokener det finner, men en krasjfil kan likevel inneholde teksten i e-posten din.", + "instance_note": "På en Bulwark-server bestemmer administratoren separat hva sidene rapporterer til den.", + "forced_off": "Organisasjonen din har slått av dette.", + "url_label": "Hvor krasjfiler sendes", + "url_description": "Https-adressen som mottar en krasjfil. Bulwark har ingen med.", + "url_invalid": "Skriv inn en fullstendig https-adresse.", + "url_save": "Lagre", + "files_label": "Krasjfiler på denne datamaskinen", + "files_description": "Bulwark beholder de nyeste og sletter dem som er eldre enn en måned.", + "files_empty": "Ingen krasjfiler.", + "file_size": "{size} KB", + "send": "Send krasjrapport…", + "delete_all": "Slett alle", + "sent": "Krasjrapport sendt.", + "send_no_consent": "Slå på krasjrapporter først.", + "send_no_url": "Ingen adresse er angitt, så den kan ikke sendes noe sted. Skriv inn en ovenfor.", + "send_failed": "Krasjrapporten kunne ikke sendes." } } diff --git a/locales/nl/common.json b/locales/nl/common.json index 4a458bcdc..885b1b0df 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Niet-verzonden berichten die op uw nieuwe wachtwoord wachten: {count}. Ze blijven tot {date}.", "refused_waiting_unknown": "Mogelijk wachten niet-verzonden berichten op uw nieuwe wachtwoord. Ze blijven tot {date}.", "confirm_unsent_unknown": "Hier wachten mogelijk niet-verzonden berichten. Ze gaan verloren.", - "confirm_templates": "E-mailsjablonen die op dit apparaat zijn opgeslagen" + "confirm_templates": "E-mailsjablonen die op dit apparaat zijn opgeslagen", + "session_waiting": "Niet-verzonden berichten die op uw volgende aanmelding wachten: {count}. Ze blijven tot {date}.", + "session_waiting_unknown": "Mogelijk wachten niet-verzonden berichten op uw volgende aanmelding. Ze blijven tot {date}.", + "needs_sign_in": "Aanmelden nodig", + "switcher_waiting": "Niet-verzonden berichten: {count}. Op {date} van dit apparaat verwijderd.", + "switcher_kept": "De e-mail op dit apparaat wordt op {date} verwijderd.", + "sibling_waiting": "{name} is afgemeld. Niet-verzonden berichten: {count}. Meld u uiterlijk {date} aan, anders worden ze van dit apparaat verwijderd.", + "sibling_kept": "{name} is afgemeld. Meld u uiterlijk {date} aan, anders wordt de e-mail van dit apparaat verwijderd." }, "offlineStorage": { "keep_description": "Bewaart je e-mail, agenda, contacten en gekozen bestanden versleuteld op dit apparaat. Offline kun je e-mail lezen, markeren en verplaatsen, en wat je schrijft wacht in Postvak UIT. Afmelden verwijdert alles.", @@ -5866,6 +5873,38 @@ "banner_refused": "Door de server geweigerde concepten: {count}", "banner_waiting": "Concepten die op de server wachten: {count}" }, + "offlineExport": { + "settings_label": "Bewaarde e-mail exporteren", + "settings_description": "Sla de e-mail op dit apparaat op als mbox- of .eml-bestanden. Dit werkt ook offline.", + "title": "Bewaarde e-mail exporteren", + "description": "Dit slaat de originelen op die dit apparaat bewaart. .eml-bestanden zijn exacte kopieën. Een mbox-bestand voegt de markeringen toe die het formaat nodig heeft en schrijft de status gelezen en gemarkeerd in de kopregels van elk bericht, in plaats van wat daar stond. E-mail ouder dan de gekozen periode zit er niet bij. Gebruik “Alle e-mail exporteren” om alles van de server op te slaan.", + "folders_label": "Mappen", + "all_folders": "Alle mappen", + "folder_kept": "{kept} van {total} hier", + "loading": "Gegevens op dit apparaat lezen…", + "load_failed": "De e-mail op dit apparaat kon niet worden gelezen.", + "nothing_kept": "Op dit apparaat is nog geen e-mail bewaard.", + "kept": "Originelen op dit apparaat: {kept} van {total} berichten", + "missing_online": "Te downloaden van de server: {count}", + "missing_offline": "Niet te exporteren terwijl u offline bent: {count}. U kunt het opnieuw proberen als u online bent.", + "reason_attachments_off": "Originele berichten worden alleen bewaard als “Bijlagen bewaren” aan staat.", + "reason_not_kept": "Originele berichten worden als laatste gedownload, en alleen als er ruimte voor is.", + "not_kept_attachments_off": "Geen origineel hier omdat bijlagen niet op dit apparaat worden bewaard: {count}", + "not_kept_not_kept": "Nog geen origineel op dit apparaat: {count}", + "retry": "Opnieuw proberen online", + "retry_online": "U bent nu online, dus deze kunnen van de server komen.", + "retry_offline": "U kunt het opnieuw proberen als u online bent.", + "settings_button": "Exporteren…", + "message_failed": "De server heeft dit bericht niet geleverd.", + "failed_generic": "De export is gestopt door een fout. Probeer het opnieuw.", + "stopped_limit": "De export is gestopt bij {limit}, het maximum dat deze browser kan bevatten. Kies minder mappen, of gebruik Chromium, Firefox of de desktop-app.", + "over_limit": "Deze browser kan maximaal {limit} bevatten, en deze keuze is ongeveer {size}. Kies minder mappen, of gebruik Chromium, Firefox of de desktop-app.", + "no_room": "Deze export heeft ongeveer {size} nodig, en deze browser heeft nog {free} beschikbaar. Kies minder mappen of maak ruimte vrij.", + "stopped_no_room": "De browser had geen ruimte meer voor de export. Kies minder mappen of maak ruimte vrij.", + "stopped_offline_off": "De export is gestopt omdat dit account geen e-mail meer op dit apparaat bewaart.", + "policy_unknown": "De regels van uw organisatie voor dit account zijn nog niet bekend, dus exporteren blijft voorlopig uit.", + "leftover_file": "Het bestand {name} kon niet worden verwijderd van de plek waar u het hebt opgeslagen. Het is onvolledig, u kunt het verwijderen." + }, "deep_link": { "message_not_found": "Dit bericht is niet meer beschikbaar.", "thread_not_found": "Dit gesprek is niet meer beschikbaar.", @@ -6728,6 +6767,53 @@ "revoke": "Intrekken", "revoked": "Toestemming ingetrokken" }, + "desktopOs": { + "section_title": "Desktop-app", + "section_description": "Hoe de app samenwerkt met je computer.", + "close_to_tray_label": "Bij sluiten doorgaan in het systeemvak", + "close_to_tray_description": "Nieuwe mail en herinneringen blijven komen nadat je het venster sluit.", + "start_at_login_label": "Starten als je je aanmeldt op de computer", + "start_at_login_description": "De app start vanzelf als je je aanmeldt.", + "start_minimized_label": "Starten in het systeemvak", + "start_minimized_description": "Bij het aanmelden opent geen venster. Open de app met het pictogram in het systeemvak.", + "attention_label": "Taakbalk laten knipperen bij nieuwe mail", + "attention_mac_label": "Dock-pictogram laten stuiteren bij nieuwe mail", + "attention_description": "Alleen als de app niet vooraan staat.", + "quiet_hours_label": "Stille uren", + "quiet_hours_description": "Geen meldingen of geluiden op deze tijden. Mail blijft binnenkomen.", + "quiet_hours_from": "Van", + "quiet_hours_to": "tot", + "show_shortcut_label": "App tonen met een sneltoets", + "show_shortcut_description": "{keys} haalt de app naar voren vanuit elke andere app.", + "show_shortcut_taken": "Een andere app gebruikt {keys}. Sluit die app, of zet dit uit en weer aan.", + "default_app_label": "Standaard-mailapp", + "default_app_description": "Maillinks en .eml-, .ics- en .vcf-bestanden openen met deze app.", + "default_app_is_default": "Deze app opent je maillinks.", + "default_app_button": "Standaard-app kiezen…", + "new_mail": "Nieuwe mail", + "no_subject": "(geen onderwerp)", + "action_delete": "Verwijderen", + "copy_as_file": "Kopiëren als bestand", + "copied": "Bestand gekopieerd. Je kunt het in een map plakken.", + "copy_failed": "Het bestand kon niet worden gekopieerd.", + "reply_lost": "Je getypte antwoord kon niet worden bewaard. Open het bericht en antwoord daar." + }, + "desktopReminders": { + "generic_title": "Herinnering", + "snooze": "{count} min uitstellen", + "snooze_short": "{count} min", + "open": "Openen", + "join": "Deelnemen", + "missed_title": "{count, plural, one {# gemiste herinnering} other {# gemiste herinneringen}}", + "missed_more": "en nog {count}", + "missed_show": "Tonen", + "missed_dialog_title": "Gemiste herinneringen", + "missed_dialog_description": "Deze kwamen op terwijl de app gesloten was, de computer sliep of meldingen gepauzeerd waren.", + "open_calendar": "Agenda openen", + "done": "Klaar", + "join_host": "Deelnemen ({host})", + "join_named": "Deelnemen: {title}" + }, "desktop_policy": { "server_refused": "Je organisatie staat deze server niet toe op dit apparaat.", "plugin_refused": "Je organisatie staat deze plug-in niet toe op dit apparaat, dus hij draait niet. De instellingen en gegevens blijven bewaard.", @@ -6736,5 +6822,30 @@ "plugins_off": "Je organisatie heeft plug-ins op dit apparaat uitgeschakeld.", "plugins_allowlist": "Je organisatie staat op dit apparaat alleen bepaalde plug-ins toe.", "off": "Uit" + }, + "desktopTelemetry": { + "section_title": "Gebruiksgegevens en crashrapporten", + "section_description": "Beide staan uit totdat je ze aanzet, en elk heeft een eigen schakelaar.", + "telemetry_label": "Gebruiksgegevens verzenden", + "telemetry_description": "Eén keer per dag: een willekeurige ID, de appversie, de Node-versie, je besturingssysteem, hoe lang de app al draait en hoe hij verbinding maakt. Met je eigen mailserver ook welke functies aan staan, een ruw aantal accounts en hoeveel plug-ins en thema's je hebt. Nooit e-mail, adressen of servernamen.", + "crash_label": "Crashrapporten verzenden", + "crash_description": "Fouten in de app gaan mee met een willekeurige ID, bestandsnamen uit de eigen code van de app en korte, opgeschoonde meldingen. Als de app zelf crasht, blijft er een crashbestand op deze computer staan dat alleen weggaat als jij het verzendt.", + "crash_memory": "Een crashbestand is een kopie van een deel van het geheugen van de app op het moment van de crash. Bulwark verwijdert gebruikersnamen, adressen en aanmeldtokens die het vindt, maar een crashbestand kan nog steeds de tekst van je e-mail bevatten.", + "instance_note": "Op een Bulwark-server bepaalt de beheerder daarvan apart wat de pagina's aan die server melden.", + "forced_off": "Je organisatie heeft dit uitgezet.", + "url_label": "Bestemming van crashbestanden", + "url_description": "Het https-adres dat een crashbestand ontvangt. Bulwark heeft er zelf geen.", + "url_invalid": "Vul een volledig https-adres in.", + "url_save": "Opslaan", + "files_label": "Crashbestanden op deze computer", + "files_description": "Bulwark bewaart de nieuwste en verwijdert bestanden die ouder zijn dan een maand.", + "files_empty": "Geen crashbestanden.", + "file_size": "{size} KB", + "send": "Crashrapport verzenden…", + "delete_all": "Alles verwijderen", + "sent": "Crashrapport verzonden.", + "send_no_consent": "Zet eerst crashrapporten aan.", + "send_no_url": "Er is geen adres ingesteld, dus dit kan nergens heen. Vul hierboven een adres in.", + "send_failed": "Het crashrapport kon niet worden verzonden." } } diff --git a/locales/pl/common.json b/locales/pl/common.json index df7763dd6..c06183d07 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Niewysłane wiadomości czekające na nowe hasło: {count}. Pozostaną do {date}.", "refused_waiting_unknown": "Niewysłane wiadomości mogą czekać na nowe hasło. Pozostaną do {date}.", "confirm_unsent_unknown": "Tutaj mogą czekać niewysłane wiadomości. Zostaną utracone.", - "confirm_templates": "Szablony e-maili zapisane na tym urządzeniu" + "confirm_templates": "Szablony e-maili zapisane na tym urządzeniu", + "session_waiting": "Niewysłane wiadomości czekające na następne logowanie: {count}. Pozostaną do {date}.", + "session_waiting_unknown": "Niewysłane wiadomości mogą czekać na następne logowanie. Pozostaną do {date}.", + "needs_sign_in": "Wymaga zalogowania", + "switcher_waiting": "Niewysłane wiadomości: {count}. Zostaną usunięte z tego urządzenia {date}.", + "switcher_kept": "Poczta na tym urządzeniu zostanie usunięta {date}.", + "sibling_waiting": "Konto {name} zostało wylogowane. Niewysłane wiadomości: {count}. Zaloguj się do {date}, inaczej zostaną usunięte z tego urządzenia.", + "sibling_kept": "Konto {name} zostało wylogowane. Zaloguj się do {date}, inaczej poczta zostanie usunięta z tego urządzenia." }, "offlineStorage": { "keep_description": "Przechowuje Twoją pocztę, kalendarz, kontakty i wybrane pliki zaszyfrowane na tym urządzeniu. Offline możesz czytać, oznaczać i przenosić wiadomości, a to, co napiszesz, czeka w skrzynce nadawczej. Wylogowanie usuwa wszystko.", @@ -5866,6 +5873,38 @@ "banner_refused": "Wersje robocze odrzucone przez serwer: {count}", "banner_waiting": "Wersje robocze czekające na serwer: {count}" }, + "offlineExport": { + "settings_label": "Eksportuj zachowaną pocztę", + "settings_description": "Zapisz pocztę z tego urządzenia jako pliki mbox lub .eml. Działa to też offline.", + "title": "Eksportuj zachowaną pocztę", + "description": "Zapisuje oryginały przechowywane na tym urządzeniu. Pliki .eml są dokładnymi kopiami. Plik mbox dodaje oznaczenia wymagane przez ten format i zapisuje stan przeczytania i oflagowania w nagłówkach każdej wiadomości, zamiast dotychczasowych. Poczty starszej niż wybrany okres tu nie ma. Aby zapisać wszystko z serwera, użyj opcji „Eksportuj całą pocztę”.", + "folders_label": "Foldery", + "all_folders": "Wszystkie foldery", + "folder_kept": "{kept} z {total} tutaj", + "loading": "Odczytywanie danych z tego urządzenia…", + "load_failed": "Nie udało się odczytać poczty na tym urządzeniu.", + "nothing_kept": "Na tym urządzeniu nie ma jeszcze zachowanej poczty.", + "kept": "Oryginały na tym urządzeniu: {kept} z {total} wiadomości", + "missing_online": "Do pobrania z serwera: {count}", + "missing_offline": "Nie można wyeksportować offline: {count}. Spróbuj ponownie, gdy będziesz online.", + "reason_attachments_off": "Oryginalne wiadomości są zachowywane tylko wtedy, gdy opcja „Przechowuj załączniki” jest włączona.", + "reason_not_kept": "Oryginalne wiadomości są pobierane na końcu i tylko wtedy, gdy jest na nie miejsce.", + "not_kept_attachments_off": "Brak oryginału, bo załączniki nie są zachowywane na tym urządzeniu: {count}", + "not_kept_not_kept": "Jeszcze bez oryginału na tym urządzeniu: {count}", + "retry": "Spróbuj ponownie online", + "retry_online": "Masz połączenie, więc mogą przyjść z serwera.", + "retry_offline": "Spróbuj ponownie, gdy będziesz online.", + "settings_button": "Eksportuj…", + "message_failed": "Serwer nie dostarczył tej wiadomości.", + "failed_generic": "Eksport zatrzymał się z powodu błędu. Spróbuj ponownie.", + "stopped_limit": "Eksport zatrzymał się przy {limit}, to najwięcej, ile ta przeglądarka może pomieścić. Wybierz mniej folderów albo użyj Chromium, Firefoksa lub aplikacji na komputer.", + "over_limit": "Ta przeglądarka może pomieścić najwyżej {limit}, a ten wybór to około {size}. Wybierz mniej folderów albo użyj Chromium, Firefoksa lub aplikacji na komputer.", + "no_room": "Ten eksport wymaga około {size}, a ta przeglądarka ma do dyspozycji {free}. Wybierz mniej folderów lub zwolnij miejsce.", + "stopped_no_room": "W przeglądarce zabrakło miejsca na eksport. Wybierz mniej folderów lub zwolnij miejsce.", + "stopped_offline_off": "Eksport zatrzymał się, bo to konto nie przechowuje już poczty na tym urządzeniu.", + "policy_unknown": "Zasady Twojej organizacji dla tego konta nie są jeszcze znane, więc eksport pozostaje na razie wyłączony.", + "leftover_file": "Nie udało się usunąć pliku {name} z miejsca zapisu. Jest niekompletny, możesz go usunąć." + }, "deep_link": { "message_not_found": "Ta wiadomość nie jest już dostępna.", "thread_not_found": "Ta rozmowa nie jest już dostępna.", @@ -6728,6 +6767,53 @@ "revoke": "Cofnij", "revoked": "Zgoda została cofnięta" }, + "desktopOs": { + "section_title": "Aplikacja na komputer", + "section_description": "Jak aplikacja współpracuje z Twoim komputerem.", + "close_to_tray_label": "Po zamknięciu działaj dalej w zasobniku", + "close_to_tray_description": "Nowa poczta i przypomnienia docierają też po zamknięciu okna.", + "start_at_login_label": "Uruchamiaj po zalogowaniu do komputera", + "start_at_login_description": "Aplikacja uruchamia się sama po zalogowaniu.", + "start_minimized_label": "Uruchamiaj w zasobniku", + "start_minimized_description": "Po zalogowaniu nie otwiera się okno. Otwórz je ikoną w zasobniku.", + "attention_label": "Migaj na pasku zadań przy nowej poczcie", + "attention_mac_label": "Podskakuj ikoną w Docku przy nowej poczcie", + "attention_description": "Tylko gdy aplikacja nie jest na wierzchu.", + "quiet_hours_label": "Godziny ciszy", + "quiet_hours_description": "W tym czasie bez powiadomień i dźwięków. Poczta nadal dociera.", + "quiet_hours_from": "Od", + "quiet_hours_to": "do", + "show_shortcut_label": "Pokazuj aplikację skrótem klawiszowym", + "show_shortcut_description": "{keys} przywołuje aplikację z każdej innej aplikacji.", + "show_shortcut_taken": "Skrót {keys} jest używany przez inną aplikację. Zamknij ją albo wyłącz i włącz tę opcję.", + "default_app_label": "Domyślna aplikacja poczty", + "default_app_description": "Otwieraj linki pocztowe i pliki .eml, .ics i .vcf w tej aplikacji.", + "default_app_is_default": "Ta aplikacja otwiera Twoje linki pocztowe.", + "default_app_button": "Wybierz domyślną aplikację…", + "new_mail": "Nowa poczta", + "no_subject": "(bez tematu)", + "action_delete": "Usuń", + "copy_as_file": "Kopiuj jako plik", + "copied": "Plik skopiowany. Możesz wkleić go do folderu.", + "copy_failed": "Nie udało się skopiować pliku.", + "reply_lost": "Nie udało się zachować wpisanej odpowiedzi. Otwórz wiadomość i odpowiedz w niej." + }, + "desktopReminders": { + "generic_title": "Przypomnienie", + "snooze": "Odłóż o {count} min", + "snooze_short": "{count} min", + "open": "Otwórz", + "join": "Dołącz", + "missed_title": "{count, plural, one {# pominięte przypomnienie} few {# pominięte przypomnienia} many {# pominiętych przypomnień} other {# pominiętego przypomnienia}}", + "missed_more": "i jeszcze {count}", + "missed_show": "Pokaż", + "missed_dialog_title": "Pominięte przypomnienia", + "missed_dialog_description": "Pojawiły się, gdy aplikacja była zamknięta, komputer był uśpiony lub powiadomienia były wstrzymane.", + "open_calendar": "Otwórz kalendarz", + "done": "Gotowe", + "join_host": "Dołącz ({host})", + "join_named": "Dołącz: {title}" + }, "desktop_policy": { "server_refused": "Twoja organizacja nie zezwala na ten serwer na tym urządzeniu.", "plugin_refused": "Twoja organizacja nie zezwala na tę wtyczkę na tym urządzeniu, więc nie działa. Jej ustawienia i dane zostają zachowane.", @@ -6736,5 +6822,30 @@ "plugins_off": "Twoja organizacja wyłączyła wtyczki na tym urządzeniu.", "plugins_allowlist": "Twoja organizacja zezwala na tym urządzeniu tylko na wybrane wtyczki.", "off": "Wyłączone" + }, + "desktopTelemetry": { + "section_title": "Dane o użyciu i raporty o awariach", + "section_description": "Oba są wyłączone, dopóki ich nie włączysz, a każdy ma własny przełącznik.", + "telemetry_label": "Wysyłaj dane o użyciu", + "telemetry_description": "Raz dziennie: losowy identyfikator, wersja aplikacji, wersja Node, system operacyjny, jak długo działa aplikacja i jak się łączy. Przy własnym serwerze poczty także włączone funkcje, przybliżona liczba kont oraz liczba wtyczek i motywów. Nigdy poczta, adresy ani nazwy serwerów.", + "crash_label": "Wysyłaj raporty o awariach", + "crash_description": "Błędy aplikacji są wysyłane z losowym identyfikatorem, nazwami plików z kodu samej aplikacji i krótkimi, oczyszczonymi komunikatami. Jeśli sama aplikacja ulegnie awarii, plik awarii zostaje na tym komputerze i opuszcza go tylko wtedy, gdy go wyślesz.", + "crash_memory": "Plik awarii to kopia części pamięci aplikacji z chwili awarii. Bulwark usuwa nazwy użytkowników, adresy i tokeny logowania, które znajdzie, ale plik awarii nadal może zawierać tekst Twojej poczty.", + "instance_note": "Na serwerze Bulwark jego administrator osobno decyduje, co strony mu zgłaszają.", + "forced_off": "Twoja organizacja to wyłączyła.", + "url_label": "Dokąd trafiają pliki awarii", + "url_description": "Adres https, który odbiera plik awarii. Bulwark nie ma własnego.", + "url_invalid": "Wpisz pełny adres https.", + "url_save": "Zapisz", + "files_label": "Pliki awarii na tym komputerze", + "files_description": "Bulwark zachowuje kilka najnowszych i usuwa starsze niż miesiąc.", + "files_empty": "Brak plików awarii.", + "file_size": "{size} KB", + "send": "Wyślij raport o awarii…", + "delete_all": "Usuń wszystkie", + "sent": "Raport o awarii wysłany.", + "send_no_consent": "Najpierw włącz raporty o awariach.", + "send_no_url": "Nie ustawiono adresu, więc nie ma dokąd tego wysłać. Wpisz go powyżej.", + "send_failed": "Nie udało się wysłać raportu o awarii." } } diff --git a/locales/pt/common.json b/locales/pt/common.json index 65421997d..be56a248d 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Mensagens por enviar à espera da nova palavra-passe: {count}. Ficam até {date}.", "refused_waiting_unknown": "Podem existir mensagens por enviar à espera da nova palavra-passe. Ficam até {date}.", "confirm_unsent_unknown": "Podem existir mensagens por enviar aqui. Serão perdidas.", - "confirm_templates": "Modelos de e-mail guardados neste dispositivo" + "confirm_templates": "Modelos de e-mail guardados neste dispositivo", + "session_waiting": "Mensagens por enviar à espera do próximo início de sessão: {count}. Ficam até {date}.", + "session_waiting_unknown": "Podem existir mensagens por enviar à espera do próximo início de sessão. Ficam até {date}.", + "needs_sign_in": "Requer início de sessão", + "switcher_waiting": "Mensagens por enviar: {count}. Eliminadas deste dispositivo em {date}.", + "switcher_kept": "O e-mail neste dispositivo será eliminado em {date}.", + "sibling_waiting": "A sessão de {name} foi terminada. Mensagens por enviar: {count}. Inicie sessão até {date} ou serão eliminadas deste dispositivo.", + "sibling_kept": "A sessão de {name} foi terminada. Inicie sessão até {date} ou o e-mail será eliminado deste dispositivo." }, "offlineStorage": { "keep_description": "Mantém o seu correio, calendário, contactos e os ficheiros que escolher, cifrados neste dispositivo. Offline pode ler, sinalizar e mover e-mails, e o que escrever espera na caixa de saída. Terminar a sessão remove tudo.", @@ -5866,6 +5873,38 @@ "banner_refused": "Rascunhos recusados pelo servidor: {count}", "banner_waiting": "Rascunhos a aguardar o servidor: {count}" }, + "offlineExport": { + "settings_label": "Exportar correio guardado", + "settings_description": "Guarde o correio deste dispositivo em ficheiros mbox ou .eml. Funciona sem ligação.", + "title": "Exportar correio guardado", + "description": "Guarda os originais que este dispositivo conserva. Os ficheiros .eml são cópias exatas. Um ficheiro mbox acrescenta as marcas que o seu formato exige e escreve o estado de lido e assinalado nos cabeçalhos de cada mensagem, em vez dos que tinha. O correio mais antigo do que o período escolhido não está incluído. Para guardar tudo do servidor, use “Exportar todos os e-mails”.", + "folders_label": "Pastas", + "all_folders": "Todas as pastas", + "folder_kept": "{kept} de {total} aqui", + "loading": "A ler o que está neste dispositivo…", + "load_failed": "Não foi possível ler o correio deste dispositivo.", + "nothing_kept": "Ainda não há correio guardado neste dispositivo.", + "kept": "Originais neste dispositivo: {kept} de {total} mensagens", + "missing_online": "A transferir do servidor: {count}", + "missing_offline": "Não podem ser exportadas sem ligação: {count}. Pode tentar de novo quando tiver ligação.", + "reason_attachments_off": "As mensagens originais só são guardadas quando “Manter anexos” está ativo.", + "reason_not_kept": "As mensagens originais são transferidas por último, e só enquanto houver espaço.", + "not_kept_attachments_off": "Sem original aqui porque este dispositivo não guarda anexos: {count}", + "not_kept_not_kept": "Ainda sem original neste dispositivo: {count}", + "retry": "Tentar de novo com ligação", + "retry_online": "Já tem ligação, por isso estas podem vir do servidor.", + "retry_offline": "Pode tentar de novo quando tiver ligação.", + "settings_button": "Exportar…", + "message_failed": "O servidor não entregou esta mensagem.", + "failed_generic": "A exportação parou devido a um erro. Tente de novo.", + "stopped_limit": "A exportação parou em {limit}, o máximo que este navegador consegue conter. Escolha menos pastas ou use o Chromium, o Firefox ou a aplicação de ambiente de trabalho.", + "over_limit": "Este navegador consegue conter no máximo {limit}, e esta escolha tem cerca de {size}. Escolha menos pastas ou use o Chromium, o Firefox ou a aplicação de ambiente de trabalho.", + "no_room": "Esta exportação precisa de cerca de {size}, e este navegador tem {free} disponíveis. Escolha menos pastas ou liberte espaço.", + "stopped_no_room": "O navegador ficou sem espaço para a exportação. Escolha menos pastas ou liberte espaço.", + "stopped_offline_off": "A exportação parou porque esta conta já não guarda correio neste dispositivo.", + "policy_unknown": "As regras da sua organização para esta conta ainda não são conhecidas, por isso a exportação fica desativada por agora.", + "leftover_file": "Não foi possível remover o ficheiro {name} do local onde o guardou. Está incompleto, pode apagá-lo." + }, "deep_link": { "message_not_found": "Esta mensagem não está mais disponível.", "thread_not_found": "Esta conversa não está mais disponível.", @@ -6728,6 +6767,53 @@ "revoke": "Retirar", "revoked": "Permissão retirada" }, + "desktopOs": { + "section_title": "Aplicação de ambiente de trabalho", + "section_description": "Como a aplicação funciona com o seu computador.", + "close_to_tray_label": "Continuar na bandeja ao fechar", + "close_to_tray_description": "O correio novo e os lembretes continuam a chegar depois de fechar a janela.", + "start_at_login_label": "Iniciar ao entrar no computador", + "start_at_login_description": "A aplicação inicia sozinha quando entra.", + "start_minimized_label": "Iniciar na bandeja", + "start_minimized_description": "Não abre nenhuma janela ao entrar. Abra-a com o ícone da bandeja.", + "attention_label": "Piscar a barra de tarefas com correio novo", + "attention_mac_label": "Saltar o ícone da Dock com correio novo", + "attention_description": "Só quando a aplicação não está à frente.", + "quiet_hours_label": "Horas de silêncio", + "quiet_hours_description": "Sem notificações nem sons nestas horas. O correio continua a chegar.", + "quiet_hours_from": "De", + "quiet_hours_to": "até", + "show_shortcut_label": "Mostrar a aplicação com um atalho de teclado", + "show_shortcut_description": "{keys} traz a aplicação para a frente a partir de qualquer outra.", + "show_shortcut_taken": "Outra aplicação está a usar {keys}. Feche-a, ou desative e volte a ativar esta opção.", + "default_app_label": "Aplicação de correio predefinida", + "default_app_description": "Abrir ligações de correio e ficheiros .eml, .ics e .vcf com esta aplicação.", + "default_app_is_default": "Esta aplicação abre as suas ligações de correio.", + "default_app_button": "Escolher aplicação predefinida…", + "new_mail": "Correio novo", + "no_subject": "(sem assunto)", + "action_delete": "Eliminar", + "copy_as_file": "Copiar como ficheiro", + "copied": "Ficheiro copiado. Pode colá-lo numa pasta.", + "copy_failed": "Não foi possível copiar o ficheiro.", + "reply_lost": "Não foi possível guardar a resposta escrita. Abra a mensagem e responda a partir dela." + }, + "desktopReminders": { + "generic_title": "Lembrete", + "snooze": "Adiar {count} min", + "snooze_short": "{count} min", + "open": "Abrir", + "join": "Entrar", + "missed_title": "{count, plural, one {# lembrete perdido} other {# lembretes perdidos}}", + "missed_more": "e mais {count}", + "missed_show": "Mostrar", + "missed_dialog_title": "Lembretes perdidos", + "missed_dialog_description": "Venceram enquanto a aplicação estava fechada, o computador em suspensão ou as notificações em pausa.", + "open_calendar": "Abrir calendário", + "done": "Concluído", + "join_host": "Entrar ({host})", + "join_named": "Entrar: {title}" + }, "desktop_policy": { "server_refused": "A sua organização não permite este servidor neste dispositivo.", "plugin_refused": "A sua organização não permite este plugin neste dispositivo, por isso não é executado. As suas definições e dados são mantidos.", @@ -6736,5 +6822,30 @@ "plugins_off": "A sua organização desativou os plugins neste dispositivo.", "plugins_allowlist": "A sua organização só permite determinados plugins neste dispositivo.", "off": "Desativado" + }, + "desktopTelemetry": { + "section_title": "Dados de utilização e relatórios de falhas", + "section_description": "Ambos ficam desligados até os ligar, e cada um tem o seu próprio interruptor.", + "telemetry_label": "Enviar dados de utilização", + "telemetry_description": "Uma vez por dia: um ID aleatório, a versão da aplicação, a versão do Node, o seu sistema operativo, há quanto tempo a aplicação está a correr e como se liga. Com o seu próprio servidor de correio, também que funções estão ativas, um número aproximado de contas e quantos plugins e temas tem. Nunca mensagens, endereços ou nomes de servidor.", + "crash_label": "Enviar relatórios de falhas", + "crash_description": "Os erros da aplicação são enviados com um ID aleatório, nomes de ficheiros do próprio código da aplicação e mensagens curtas e limpas. Se a própria aplicação falhar, um ficheiro de falha fica neste computador e só sai se o enviar.", + "crash_memory": "Um ficheiro de falha é uma cópia de parte da memória da aplicação no momento da falha. O Bulwark remove os nomes de utilizador, endereços e tokens de início de sessão que encontra, mas um ficheiro de falha pode ainda conter o texto das suas mensagens.", + "instance_note": "Num servidor Bulwark, o seu administrador decide separadamente o que as páginas lhe comunicam.", + "forced_off": "A sua organização desligou esta opção.", + "url_label": "Destino dos ficheiros de falha", + "url_description": "O endereço https que recebe um ficheiro de falha. O Bulwark não inclui nenhum.", + "url_invalid": "Introduza um endereço https completo.", + "url_save": "Guardar", + "files_label": "Ficheiros de falha neste computador", + "files_description": "O Bulwark mantém os mais recentes e elimina os que têm mais de um mês.", + "files_empty": "Sem ficheiros de falha.", + "file_size": "{size} KB", + "send": "Enviar relatório de falha…", + "delete_all": "Eliminar tudo", + "sent": "Relatório de falha enviado.", + "send_no_consent": "Ligue primeiro os relatórios de falhas.", + "send_no_url": "Não há nenhum endereço definido, por isso não há para onde enviar. Introduza um acima.", + "send_failed": "Não foi possível enviar o relatório de falha." } } diff --git a/locales/ro/common.json b/locales/ro/common.json index 810047d16..d7ad1c1cf 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Mesaje netrimise care așteaptă parola nouă: {count}. Rămân până la {date}.", "refused_waiting_unknown": "Este posibil ca mesaje netrimise să aștepte parola nouă. Rămân până la {date}.", "confirm_unsent_unknown": "Este posibil să existe aici mesaje netrimise. Se vor pierde.", - "confirm_templates": "Șabloane de e-mail salvate pe acest dispozitiv" + "confirm_templates": "Șabloane de e-mail salvate pe acest dispozitiv", + "session_waiting": "Mesaje netrimise care așteaptă următoarea conectare: {count}. Rămân până la {date}.", + "session_waiting_unknown": "Este posibil ca mesaje netrimise să aștepte următoarea conectare. Rămân până la {date}.", + "needs_sign_in": "Necesită conectare", + "switcher_waiting": "Mesaje netrimise: {count}. Vor fi șterse de pe acest dispozitiv la {date}.", + "switcher_kept": "E-mailurile de pe acest dispozitiv vor fi șterse la {date}.", + "sibling_waiting": "{name} a fost deconectat. Mesaje netrimise: {count}. Conectați-vă până la {date}, altfel vor fi șterse de pe acest dispozitiv.", + "sibling_kept": "{name} a fost deconectat. Conectați-vă până la {date}, altfel e-mailurile vor fi șterse de pe acest dispozitiv." }, "offlineStorage": { "keep_description": "Păstrează e-mailurile, calendarul, contactele și fișierele alese de dvs., criptate pe acest dispozitiv. Offline puteți citi, marca și muta e-mailuri, iar ce scrieți așteaptă în Mesaje de trimis. La deconectare se șterge totul.", @@ -5866,6 +5873,38 @@ "banner_refused": "Ciorne refuzate de server: {count}", "banner_waiting": "Ciorne care așteaptă serverul: {count}" }, + "offlineExport": { + "settings_label": "Exportă mesajele păstrate", + "settings_description": "Salvați mesajele de pe acest dispozitiv ca fișiere mbox sau .eml. Funcționează și offline.", + "title": "Exportă mesajele păstrate", + "description": "Salvează originalele păstrate pe acest dispozitiv. Fișierele .eml sunt copii exacte. Un fișier mbox adaugă marcajele cerute de format și scrie starea de citit și marcat în antetele fiecărui mesaj, în locul celor existente. Mesajele mai vechi decât perioada aleasă nu sunt incluse. Pentru a salva tot de pe server, folosiți „Exportă toate e-mailurile”.", + "folders_label": "Dosare", + "all_folders": "Toate dosarele", + "folder_kept": "{kept} din {total} aici", + "loading": "Se citesc datele de pe acest dispozitiv…", + "load_failed": "Mesajele de pe acest dispozitiv nu au putut fi citite.", + "nothing_kept": "Pe acest dispozitiv nu sunt încă mesaje păstrate.", + "kept": "Originale pe acest dispozitiv: {kept} din {total} mesaje", + "missing_online": "De descărcat de pe server: {count}", + "missing_offline": "Nu pot fi exportate offline: {count}. Puteți încerca din nou când sunteți online.", + "reason_attachments_off": "Mesajele originale sunt păstrate doar când „Păstrează atașamentele” este activat.", + "reason_not_kept": "Mesajele originale se descarcă ultimele și doar cât timp este loc.", + "not_kept_attachments_off": "Fără original aici, deoarece atașamentele nu sunt păstrate pe acest dispozitiv: {count}", + "not_kept_not_kept": "Încă fără original pe acest dispozitiv: {count}", + "retry": "Încearcă din nou online", + "retry_online": "Sunteți online acum, deci pot veni de pe server.", + "retry_offline": "Puteți încerca din nou când sunteți online.", + "settings_button": "Exportă…", + "message_failed": "Serverul nu a livrat acest mesaj.", + "failed_generic": "Exportul s-a oprit din cauza unei erori. Încercați din nou.", + "stopped_limit": "Exportul s-a oprit la {limit}, maximul pe care îl poate păstra acest browser. Alegeți mai puține dosare sau folosiți Chromium, Firefox ori aplicația desktop.", + "over_limit": "Acest browser poate păstra cel mult {limit}, iar această alegere are aproximativ {size}. Alegeți mai puține dosare sau folosiți Chromium, Firefox ori aplicația desktop.", + "no_room": "Acest export are nevoie de aproximativ {size}, iar acest browser mai are {free}. Alegeți mai puține dosare sau eliberați spațiu.", + "stopped_no_room": "Browserul a rămas fără spațiu pentru export. Alegeți mai puține dosare sau eliberați spațiu.", + "stopped_offline_off": "Exportul s-a oprit deoarece acest cont nu mai păstrează mesaje pe acest dispozitiv.", + "policy_unknown": "Regulile organizației dvs. pentru acest cont nu se cunosc încă, așa că exportul rămâne oprit deocamdată.", + "leftover_file": "Fișierul {name} nu a putut fi eliminat din locul unde l-ați salvat. Este incomplet, îl puteți șterge." + }, "deep_link": { "message_not_found": "Acest mesaj nu mai este disponibil.", "thread_not_found": "Această conversație nu mai este disponibilă.", @@ -6728,6 +6767,53 @@ "revoke": "Retrage", "revoked": "Permisiune retrasă" }, + "desktopOs": { + "section_title": "Aplicație desktop", + "section_description": "Cum funcționează aplicația cu computerul tău.", + "close_to_tray_label": "Rămâne în zona de notificări la închidere", + "close_to_tray_description": "E-mailurile noi și mementourile continuă să sosească după închiderea ferestrei.", + "start_at_login_label": "Pornește la conectarea în computer", + "start_at_login_description": "Aplicația pornește singură când te conectezi.", + "start_minimized_label": "Pornește în zona de notificări", + "start_minimized_description": "La conectare nu se deschide nicio fereastră. Deschide-o din pictograma din zona de notificări.", + "attention_label": "Clipește în bara de activități la e-mail nou", + "attention_mac_label": "Sare pictograma din Dock la e-mail nou", + "attention_description": "Doar când aplicația nu este în față.", + "quiet_hours_label": "Ore de liniște", + "quiet_hours_description": "Fără notificări sau sunete în aceste ore. E-mailurile sosesc în continuare.", + "quiet_hours_from": "De la", + "quiet_hours_to": "până la", + "show_shortcut_label": "Arată aplicația cu o comandă rapidă de la tastatură", + "show_shortcut_description": "{keys} aduce aplicația în față din orice altă aplicație.", + "show_shortcut_taken": "Altă aplicație folosește {keys}. Închide-o sau dezactivează și reactivează această opțiune.", + "default_app_label": "Aplicația de e-mail implicită", + "default_app_description": "Deschide linkurile de e-mail și fișierele .eml, .ics și .vcf cu această aplicație.", + "default_app_is_default": "Această aplicație îți deschide linkurile de e-mail.", + "default_app_button": "Alege aplicația implicită…", + "new_mail": "E-mail nou", + "no_subject": "(fără subiect)", + "action_delete": "Șterge", + "copy_as_file": "Copiază ca fișier", + "copied": "Fișier copiat. Îl poți lipi într-un dosar.", + "copy_failed": "Fișierul nu a putut fi copiat.", + "reply_lost": "Răspunsul scris nu a putut fi păstrat. Deschide mesajul și răspunde de acolo." + }, + "desktopReminders": { + "generic_title": "Memento", + "snooze": "Amână {count} min", + "snooze_short": "{count} min", + "open": "Deschide", + "join": "Participă", + "missed_title": "{count, plural, one {# memento ratat} few {# mementouri ratate} other {# de mementouri ratate}}", + "missed_more": "și încă {count}", + "missed_show": "Afișează", + "missed_dialog_title": "Mementouri ratate", + "missed_dialog_description": "Au venit la scadență cât timp aplicația era închisă, computerul în repaus sau notificările în pauză.", + "open_calendar": "Deschide calendarul", + "done": "Gata", + "join_host": "Participă ({host})", + "join_named": "Participă: {title}" + }, "desktop_policy": { "server_refused": "Organizația ta nu permite acest server pe acest dispozitiv.", "plugin_refused": "Organizația ta nu permite acest plugin pe acest dispozitiv, așa că nu rulează. Setările și datele sale sunt păstrate.", @@ -6736,5 +6822,30 @@ "plugins_off": "Organizația ta a dezactivat pluginurile pe acest dispozitiv.", "plugins_allowlist": "Organizația ta permite doar anumite pluginuri pe acest dispozitiv.", "off": "Dezactivat" + }, + "desktopTelemetry": { + "section_title": "Date de utilizare și rapoarte de blocare", + "section_description": "Ambele sunt oprite până le porniți și fiecare are propriul comutator.", + "telemetry_label": "Trimite date de utilizare", + "telemetry_description": "O dată pe zi: un ID aleatoriu, versiunea aplicației, versiunea Node, sistemul de operare, de cât timp rulează aplicația și cum se conectează. Cu propriul server de e-mail, și ce funcții sunt pornite, un număr aproximativ de conturi și câte pluginuri și teme aveți. Niciodată e-mailuri, adrese sau nume de servere.", + "crash_label": "Trimite rapoarte de blocare", + "crash_description": "Erorile din aplicație pleacă cu un ID aleatoriu, nume de fișiere din codul aplicației și mesaje scurte, curățate. Dacă aplicația însăși se blochează, un fișier de blocare rămâne pe acest computer și pleacă doar dacă îl trimiteți.", + "crash_memory": "Un fișier de blocare este o copie a unei părți din memoria aplicației din momentul blocării. Bulwark elimină numele de utilizator, adresele și tokenurile de autentificare pe care le găsește, dar un fișier de blocare poate conține totuși textul e-mailurilor dvs.", + "instance_note": "Pe un server Bulwark, administratorul acestuia decide separat ce îi raportează paginile.", + "forced_off": "Organizația dvs. a oprit această opțiune.", + "url_label": "Unde ajung fișierele de blocare", + "url_description": "Adresa https care primește un fișier de blocare. Bulwark nu vine cu una.", + "url_invalid": "Introduceți o adresă https completă.", + "url_save": "Salvează", + "files_label": "Fișiere de blocare pe acest computer", + "files_description": "Bulwark le păstrează pe cele mai noi și le șterge pe cele mai vechi de o lună.", + "files_empty": "Nu există fișiere de blocare.", + "file_size": "{size} KB", + "send": "Trimite raportul de blocare…", + "delete_all": "Șterge tot", + "sent": "Raport de blocare trimis.", + "send_no_consent": "Porniți mai întâi rapoartele de blocare.", + "send_no_url": "Nu este setată nicio adresă, deci nu există unde să fie trimis. Introduceți una mai sus.", + "send_failed": "Raportul de blocare nu a putut fi trimis." } } diff --git a/locales/ru/common.json b/locales/ru/common.json index e1fd5493f..1683d09b7 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Неотправленные сообщения ждут нового пароля: {count}. Они хранятся до {date}.", "refused_waiting_unknown": "Возможно, неотправленные сообщения ждут нового пароля. Они хранятся до {date}.", "confirm_unsent_unknown": "Здесь могут быть неотправленные сообщения. Они будут потеряны.", - "confirm_templates": "Шаблоны писем, сохранённые на этом устройстве" + "confirm_templates": "Шаблоны писем, сохранённые на этом устройстве", + "session_waiting": "Неотправленные сообщения ждут следующего входа: {count}. Они хранятся до {date}.", + "session_waiting_unknown": "Возможно, неотправленные сообщения ждут следующего входа. Они хранятся до {date}.", + "needs_sign_in": "Нужен вход", + "switcher_waiting": "Неотправленные сообщения: {count}. Будут удалены с этого устройства {date}.", + "switcher_kept": "Почта на этом устройстве будет удалена {date}.", + "sibling_waiting": "Выполнен выход из {name}. Неотправленные сообщения: {count}. Войдите до {date}, иначе они будут удалены с этого устройства.", + "sibling_kept": "Выполнен выход из {name}. Войдите до {date}, иначе почта будет удалена с этого устройства." }, "offlineStorage": { "keep_description": "Хранит вашу почту, календарь, контакты и выбранные файлы в зашифрованном виде на этом устройстве. Без сети можно читать, помечать и перемещать письма, а написанное ждёт в исходящих. При выходе всё удаляется.", @@ -5866,6 +5873,38 @@ "banner_refused": "Черновики, отклонённые сервером: {count}", "banner_waiting": "Черновики, ожидающие сервер: {count}" }, + "offlineExport": { + "settings_label": "Экспорт сохранённой почты", + "settings_description": "Сохраните почту с этого устройства в файлы mbox или .eml. Работает и без сети.", + "title": "Экспорт сохранённой почты", + "description": "Сохраняет оригиналы, которые хранятся на этом устройстве. Файлы .eml — точные копии. Файл mbox добавляет отметки, которых требует его формат, и записывает состояние «прочитано» и «помечено» в заголовки каждого сообщения вместо прежних. Почты старше выбранного периода здесь нет. Чтобы сохранить всё с сервера, используйте «Экспортировать всю почту».", + "folders_label": "Папки", + "all_folders": "Все папки", + "folder_kept": "Здесь: {kept} из {total}", + "loading": "Чтение данных на этом устройстве…", + "load_failed": "Не удалось прочитать почту на этом устройстве.", + "nothing_kept": "На этом устройстве ещё нет сохранённой почты.", + "kept": "Оригиналы на этом устройстве: {kept} из {total}", + "missing_online": "Загрузить с сервера: {count}", + "missing_offline": "Нельзя экспортировать без сети: {count}. Повторите попытку, когда появится сеть.", + "reason_attachments_off": "Исходные сообщения хранятся, только если включено «Хранить вложения».", + "reason_not_kept": "Исходные сообщения загружаются в последнюю очередь и только пока есть место.", + "not_kept_attachments_off": "Нет оригинала, потому что на этом устройстве не хранятся вложения: {count}", + "not_kept_not_kept": "Пока нет оригинала на этом устройстве: {count}", + "retry": "Повторить в сети", + "retry_online": "Сеть есть, поэтому их можно получить с сервера.", + "retry_offline": "Повторите попытку, когда появится сеть.", + "settings_button": "Экспортировать…", + "message_failed": "Сервер не выдал это сообщение.", + "failed_generic": "Экспорт остановлен из-за ошибки. Повторите попытку.", + "stopped_limit": "Экспорт остановлен на {limit}: больше этот браузер удержать не может. Выберите меньше папок или используйте Chromium, Firefox либо приложение для компьютера.", + "over_limit": "Этот браузер может удержать не больше {limit}, а выбрано примерно {size}. Выберите меньше папок или используйте Chromium, Firefox либо приложение для компьютера.", + "no_room": "Для этого экспорта нужно примерно {size}, а этому браузеру доступно {free}. Выберите меньше папок или освободите место.", + "stopped_no_room": "Браузеру не хватило места для экспорта. Выберите меньше папок или освободите место.", + "stopped_offline_off": "Экспорт остановлен: эта учётная запись больше не хранит почту на этом устройстве.", + "policy_unknown": "Правила вашей организации для этой учётной записи пока неизвестны, поэтому экспорт пока выключен.", + "leftover_file": "Не удалось удалить файл {name} из места сохранения. Он неполный, его можно удалить." + }, "deep_link": { "message_not_found": "Это письмо больше недоступно.", "thread_not_found": "Эта переписка больше недоступна.", @@ -6728,6 +6767,53 @@ "revoke": "Отозвать", "revoked": "Разрешение отозвано" }, + "desktopOs": { + "section_title": "Настольное приложение", + "section_description": "Как приложение работает с вашим компьютером.", + "close_to_tray_label": "При закрытии продолжать работу в трее", + "close_to_tray_description": "Новая почта и напоминания приходят и после закрытия окна.", + "start_at_login_label": "Запускать при входе в систему", + "start_at_login_description": "Приложение запускается само, когда вы входите.", + "start_minimized_label": "Запускать в трее", + "start_minimized_description": "При входе окно не открывается. Откройте его значком в трее.", + "attention_label": "Мигать на панели задач при новой почте", + "attention_mac_label": "Подпрыгивать значком в Dock при новой почте", + "attention_description": "Только когда приложение не на переднем плане.", + "quiet_hours_label": "Тихие часы", + "quiet_hours_description": "В это время нет уведомлений и звуков. Почта продолжает приходить.", + "quiet_hours_from": "С", + "quiet_hours_to": "до", + "show_shortcut_label": "Показывать приложение сочетанием клавиш", + "show_shortcut_description": "{keys} выводит приложение на передний план из любого другого.", + "show_shortcut_taken": "{keys} использует другое приложение. Закройте его или выключите и снова включите этот параметр.", + "default_app_label": "Почтовое приложение по умолчанию", + "default_app_description": "Открывать почтовые ссылки и файлы .eml, .ics и .vcf в этом приложении.", + "default_app_is_default": "Это приложение открывает ваши почтовые ссылки.", + "default_app_button": "Выбрать приложение по умолчанию…", + "new_mail": "Новая почта", + "no_subject": "(без темы)", + "action_delete": "Удалить", + "copy_as_file": "Копировать как файл", + "copied": "Файл скопирован. Его можно вставить в папку.", + "copy_failed": "Не удалось скопировать файл.", + "reply_lost": "Не удалось сохранить введённый ответ. Откройте письмо и ответьте из него." + }, + "desktopReminders": { + "generic_title": "Напоминание", + "snooze": "Отложить на {count} мин", + "snooze_short": "{count} мин", + "open": "Открыть", + "join": "Подключиться", + "missed_title": "{count, plural, one {# пропущенное напоминание} few {# пропущенных напоминания} many {# пропущенных напоминаний} other {# пропущенного напоминания}}", + "missed_more": "и ещё {count}", + "missed_show": "Показать", + "missed_dialog_title": "Пропущенные напоминания", + "missed_dialog_description": "Они сработали, пока приложение было закрыто, компьютер спал или уведомления были приостановлены.", + "open_calendar": "Открыть календарь", + "done": "Готово", + "join_host": "Подключиться ({host})", + "join_named": "Подключиться: {title}" + }, "desktop_policy": { "server_refused": "Ваша организация не разрешает этот сервер на этом устройстве.", "plugin_refused": "Ваша организация не разрешает этот плагин на этом устройстве, поэтому он не работает. Его настройки и данные сохраняются.", @@ -6736,5 +6822,30 @@ "plugins_off": "Ваша организация отключила плагины на этом устройстве.", "plugins_allowlist": "Ваша организация разрешает на этом устройстве только некоторые плагины.", "off": "Выкл." + }, + "desktopTelemetry": { + "section_title": "Данные об использовании и отчёты о сбоях", + "section_description": "Оба параметра выключены, пока вы их не включите, и у каждого свой переключатель.", + "telemetry_label": "Отправлять данные об использовании", + "telemetry_description": "Раз в день: случайный идентификатор, версия приложения, версия Node, ваша операционная система, как долго работает приложение и как оно подключается. При собственном почтовом сервере также включённые функции, примерное число учётных записей и количество плагинов и тем. Никогда письма, адреса или имена серверов.", + "crash_label": "Отправлять отчёты о сбоях", + "crash_description": "Ошибки в приложении отправляются со случайным идентификатором, именами файлов из кода самого приложения и короткими очищенными сообщениями. Если сбой происходит в самом приложении, файл сбоя остаётся на этом компьютере и покидает его, только если вы его отправите.", + "crash_memory": "Файл сбоя — это копия части памяти приложения в момент сбоя. Bulwark удаляет найденные имена пользователей, адреса и токены входа, но файл сбоя всё равно может содержать текст ваших писем.", + "instance_note": "На сервере Bulwark его администратор отдельно решает, что страницы сообщают серверу.", + "forced_off": "Ваша организация отключила это.", + "url_label": "Куда отправляются файлы сбоев", + "url_description": "Адрес https, который принимает файл сбоя. В Bulwark его нет.", + "url_invalid": "Введите полный адрес https.", + "url_save": "Сохранить", + "files_label": "Файлы сбоев на этом компьютере", + "files_description": "Bulwark хранит самые новые и удаляет те, что старше месяца.", + "files_empty": "Файлов сбоев нет.", + "file_size": "{size} КБ", + "send": "Отправить отчёт о сбое…", + "delete_all": "Удалить все", + "sent": "Отчёт о сбое отправлен.", + "send_no_consent": "Сначала включите отчёты о сбоях.", + "send_no_url": "Адрес не задан, поэтому отправлять некуда. Введите его выше.", + "send_failed": "Не удалось отправить отчёт о сбое." } } diff --git a/locales/sk/common.json b/locales/sk/common.json index 907e39627..0d1579e80 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Neodoslané správy čakajúce na nové heslo: {count}. Zostanú do {date}.", "refused_waiting_unknown": "Na nové heslo možno čakajú neodoslané správy. Zostanú do {date}.", "confirm_unsent_unknown": "Možno tu čakajú neodoslané správy. Stratia sa.", - "confirm_templates": "Šablóny e-mailov uložené v tomto zariadení" + "confirm_templates": "Šablóny e-mailov uložené v tomto zariadení", + "session_waiting": "Neodoslané správy čakajúce na ďalšie prihlásenie: {count}. Zostanú do {date}.", + "session_waiting_unknown": "Na ďalšie prihlásenie možno čakajú neodoslané správy. Zostanú do {date}.", + "needs_sign_in": "Vyžaduje prihlásenie", + "switcher_waiting": "Neodoslané správy: {count}. Odstránia sa z tohto zariadenia {date}.", + "switcher_kept": "Pošta v tomto zariadení sa odstráni {date}.", + "sibling_waiting": "Účet {name} bol odhlásený. Neodoslané správy: {count}. Prihláste sa do {date}, inak sa z tohto zariadenia odstránia.", + "sibling_kept": "Účet {name} bol odhlásený. Prihláste sa do {date}, inak sa pošta z tohto zariadenia odstráni." }, "offlineStorage": { "keep_description": "Uchováva vašu poštu, kalendár, kontakty a vybrané súbory zašifrované v tomto zariadení. Offline môžete poštu čítať, označovať a presúvať a to, čo napíšete, čaká na odoslanie. Odhlásenie všetko odstráni.", @@ -5866,6 +5873,38 @@ "banner_refused": "Koncepty odmietnuté serverom: {count}", "banner_waiting": "Koncepty čakajúce na server: {count}" }, + "offlineExport": { + "settings_label": "Exportovať uloženú poštu", + "settings_description": "Uložte poštu z tohto zariadenia ako súbory mbox alebo .eml. Funguje to aj offline.", + "title": "Exportovať uloženú poštu", + "description": "Uloží originály, ktoré toto zariadenie uchováva. Súbory .eml sú presné kópie. Súbor mbox pridá značky, ktoré jeho formát vyžaduje, a zapíše stav prečítania a označenia do hlavičiek každej správy namiesto tých, ktoré mala. Pošta staršia ako zvolené obdobie v tom nie je. Ak chcete uložiť všetko zo servera, použite „Exportovať všetku poštu“.", + "folders_label": "Priečinky", + "all_folders": "Všetky priečinky", + "folder_kept": "{kept} z {total} tu", + "loading": "Čítanie údajov z tohto zariadenia…", + "load_failed": "Poštu na tomto zariadení sa nepodarilo prečítať.", + "nothing_kept": "Na tomto zariadení ešte nie je uložená žiadna pošta.", + "kept": "Originály na tomto zariadení: {kept} z {total} správ", + "missing_online": "Na stiahnutie zo servera: {count}", + "missing_offline": "Nedá sa exportovať offline: {count}. Môžete to skúsiť znova, keď budete online.", + "reason_attachments_off": "Pôvodné správy sa uchovávajú, len keď je zapnuté „Uchovávať prílohy“.", + "reason_not_kept": "Pôvodné správy sa sťahujú ako posledné a len vtedy, keď je na ne miesto.", + "not_kept_attachments_off": "Bez originálu, pretože toto zariadenie neuchováva prílohy: {count}", + "not_kept_not_kept": "Zatiaľ bez originálu na tomto zariadení: {count}", + "retry": "Skúsiť znova online", + "retry_online": "Ste online, takže môžu prísť zo servera.", + "retry_offline": "Môžete to skúsiť znova, keď budete online.", + "settings_button": "Exportovať…", + "message_failed": "Server túto správu nedodal.", + "failed_generic": "Export sa zastavil pre chybu. Skúste to znova.", + "stopped_limit": "Export sa zastavil na {limit}, čo je najviac, čo tento prehliadač pojme. Vyberte menej priečinkov alebo použite Chromium, Firefox či desktopovú aplikáciu.", + "over_limit": "Tento prehliadač pojme najviac {limit} a tento výber má asi {size}. Vyberte menej priečinkov alebo použite Chromium, Firefox či desktopovú aplikáciu.", + "no_room": "Tento export potrebuje asi {size} a tento prehliadač má k dispozícii {free}. Vyberte menej priečinkov alebo uvoľnite miesto.", + "stopped_no_room": "Prehliadaču došlo miesto na export. Vyberte menej priečinkov alebo uvoľnite miesto.", + "stopped_offline_off": "Export sa zastavil, pretože tento účet už na tomto zariadení poštu neuchováva.", + "policy_unknown": "Pravidlá vašej organizácie pre tento účet zatiaľ nie sú známe, preto export zatiaľ zostáva vypnutý.", + "leftover_file": "Súbor {name} sa nepodarilo odstrániť z miesta, kam ste ho uložili. Je neúplný, môžete ho vymazať." + }, "deep_link": { "message_not_found": "Táto správa už nie je dostupná.", "thread_not_found": "Táto konverzácia už nie je dostupná.", @@ -6728,6 +6767,53 @@ "revoke": "Odvolať", "revoked": "Povolenie bolo odvolané" }, + "desktopOs": { + "section_title": "Počítačová aplikácia", + "section_description": "Ako aplikácia spolupracuje s vaším počítačom.", + "close_to_tray_label": "Po zatvorení bežať ďalej v oblasti oznámení", + "close_to_tray_description": "Nová pošta a pripomienky prichádzajú aj po zatvorení okna.", + "start_at_login_label": "Spustiť po prihlásení do počítača", + "start_at_login_description": "Aplikácia sa po prihlásení spustí sama.", + "start_minimized_label": "Spustiť v oblasti oznámení", + "start_minimized_description": "Po prihlásení sa neotvorí žiadne okno. Otvorte ju ikonou v oblasti oznámení.", + "attention_label": "Pri novej pošte blikať na paneli úloh", + "attention_mac_label": "Pri novej pošte poskakovať ikonou v Docku", + "attention_description": "Len keď aplikácia nie je v popredí.", + "quiet_hours_label": "Čas pokoja", + "quiet_hours_description": "V tomto čase žiadne oznámenia ani zvuky. Pošta prichádza ďalej.", + "quiet_hours_from": "Od", + "quiet_hours_to": "do", + "show_shortcut_label": "Zobraziť aplikáciu klávesovou skratkou", + "show_shortcut_description": "{keys} prenesie aplikáciu do popredia z akejkoľvek inej aplikácie.", + "show_shortcut_taken": "{keys} používa iná aplikácia. Zatvorte ju alebo túto možnosť vypnite a znova zapnite.", + "default_app_label": "Predvolená poštová aplikácia", + "default_app_description": "Otvárať poštové odkazy a súbory .eml, .ics a .vcf v tejto aplikácii.", + "default_app_is_default": "Táto aplikácia otvára vaše poštové odkazy.", + "default_app_button": "Vybrať predvolenú aplikáciu…", + "new_mail": "Nová pošta", + "no_subject": "(bez predmetu)", + "action_delete": "Odstrániť", + "copy_as_file": "Kopírovať ako súbor", + "copied": "Súbor skopírovaný. Môžete ho vložiť do priečinka.", + "copy_failed": "Súbor sa nepodarilo skopírovať.", + "reply_lost": "Napísanú odpoveď sa nepodarilo zachovať. Otvorte správu a odpovedzte v nej." + }, + "desktopReminders": { + "generic_title": "Pripomienka", + "snooze": "Odložiť o {count} min", + "snooze_short": "{count} min", + "open": "Otvoriť", + "join": "Pripojiť sa", + "missed_title": "{count, plural, one {# zmeškaná pripomienka} few {# zmeškané pripomienky} many {# zmeškanej pripomienky} other {# zmeškaných pripomienok}}", + "missed_more": "a ďalšie ({count})", + "missed_show": "Zobraziť", + "missed_dialog_title": "Zmeškané pripomienky", + "missed_dialog_description": "Nastali, keď bola aplikácia zatvorená, počítač v režime spánku alebo oznámenia pozastavené.", + "open_calendar": "Otvoriť kalendár", + "done": "Hotovo", + "join_host": "Pripojiť sa ({host})", + "join_named": "Pripojiť sa: {title}" + }, "desktop_policy": { "server_refused": "Vaša organizácia tento server na tomto zariadení nepovoľuje.", "plugin_refused": "Vaša organizácia tento plugin na tomto zariadení nepovoľuje, preto nebeží. Jeho nastavenia a údaje zostávajú zachované.", @@ -6736,5 +6822,30 @@ "plugins_off": "Vaša organizácia na tomto zariadení vypla pluginy.", "plugins_allowlist": "Vaša organizácia na tomto zariadení povoľuje len niektoré pluginy.", "off": "Vypnuté" + }, + "desktopTelemetry": { + "section_title": "Údaje o používaní a hlásenia o zlyhaniach", + "section_description": "Oboje je vypnuté, kým to nezapnete, a každé má vlastný prepínač.", + "telemetry_label": "Odosielať údaje o používaní", + "telemetry_description": "Raz denne: náhodné ID, verzia aplikácie, verzia Node, váš operačný systém, ako dlho aplikácia beží a ako sa pripája. Pri vlastnom poštovom serveri aj zapnuté funkcie, približný počet účtov a počet doplnkov a motívov. Nikdy pošta, adresy ani názvy serverov.", + "crash_label": "Odosielať hlásenia o zlyhaniach", + "crash_description": "Chyby v aplikácii sa odosielajú s náhodným ID, názvami súborov z kódu aplikácie a krátkymi, očistenými správami. Keď zlyhá samotná aplikácia, súbor o zlyhaní zostane v tomto počítači a odíde len vtedy, keď ho odošlete.", + "crash_memory": "Súbor o zlyhaní je kópia časti pamäte aplikácie v okamihu zlyhania. Bulwark odstráni používateľské mená, adresy a prihlasovacie tokeny, ktoré nájde, ale súbor o zlyhaní môže aj tak obsahovať text vašej pošty.", + "instance_note": "Na serveri Bulwark rozhoduje jeho správca samostatne o tom, čo mu stránky hlásia.", + "forced_off": "Vaša organizácia to vypla.", + "url_label": "Kam idú súbory o zlyhaní", + "url_description": "Adresa https, ktorá prijíma súbor o zlyhaní. Bulwark žiadnu nemá.", + "url_invalid": "Zadajte úplnú adresu https.", + "url_save": "Uložiť", + "files_label": "Súbory o zlyhaní v tomto počítači", + "files_description": "Bulwark ponechá najnovšie a odstráni tie staršie ako mesiac.", + "files_empty": "Žiadne súbory o zlyhaní.", + "file_size": "{size} KB", + "send": "Odoslať hlásenie o zlyhaní…", + "delete_all": "Odstrániť všetko", + "sent": "Hlásenie o zlyhaní odoslané.", + "send_no_consent": "Najprv zapnite hlásenia o zlyhaniach.", + "send_no_url": "Nie je nastavená žiadna adresa, takže to nie je kam odoslať. Zadajte ju vyššie.", + "send_failed": "Hlásenie o zlyhaní sa nepodarilo odoslať." } } diff --git a/locales/th/common.json b/locales/th/common.json index c39b08b14..b07d7f4f2 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2128,7 +2128,14 @@ "refused_waiting": "ข้อความที่ยังไม่ได้ส่งซึ่งรอรหัสผ่านใหม่ของคุณ: {count} เก็บไว้ถึง {date}", "refused_waiting_unknown": "อาจมีข้อความที่ยังไม่ได้ส่งรอรหัสผ่านใหม่ของคุณ เก็บไว้ถึง {date}", "confirm_unsent_unknown": "อาจมีข้อความที่ยังไม่ได้ส่งรออยู่ที่นี่ ข้อความเหล่านั้นจะสูญหาย", - "confirm_templates": "เทมเพลตอีเมลที่บันทึกไว้ในอุปกรณ์นี้" + "confirm_templates": "เทมเพลตอีเมลที่บันทึกไว้ในอุปกรณ์นี้", + "session_waiting": "ข้อความที่ยังไม่ได้ส่งซึ่งรอการลงชื่อเข้าใช้ครั้งถัดไป: {count} เก็บไว้ถึง {date}", + "session_waiting_unknown": "อาจมีข้อความที่ยังไม่ได้ส่งรอการลงชื่อเข้าใช้ครั้งถัดไป เก็บไว้ถึง {date}", + "needs_sign_in": "ต้องลงชื่อเข้าใช้", + "switcher_waiting": "ข้อความที่ยังไม่ได้ส่ง: {count} จะถูกลบออกจากอุปกรณ์นี้ในวันที่ {date}", + "switcher_kept": "อีเมลในอุปกรณ์นี้จะถูกลบในวันที่ {date}", + "sibling_waiting": "{name} ถูกออกจากระบบแล้ว ข้อความที่ยังไม่ได้ส่ง: {count} โปรดลงชื่อเข้าใช้ภายใน {date} มิฉะนั้นจะถูกลบออกจากอุปกรณ์นี้", + "sibling_kept": "{name} ถูกออกจากระบบแล้ว โปรดลงชื่อเข้าใช้ภายใน {date} มิฉะนั้นอีเมลจะถูกลบออกจากอุปกรณ์นี้" }, "offlineStorage": { "keep_description": "เก็บอีเมล ปฏิทิน รายชื่อติดต่อ และไฟล์ที่คุณเลือกไว้ในอุปกรณ์นี้แบบเข้ารหัส เมื่อออฟไลน์คุณอ่าน ติดธง และย้ายอีเมลได้ และสิ่งที่คุณเขียนจะรออยู่ในกล่องขาออก เมื่อออกจากระบบทุกอย่างจะถูกลบ", @@ -2211,6 +2218,38 @@ "banner_refused": "ฉบับร่างที่เซิร์ฟเวอร์ปฏิเสธ: {count}", "banner_waiting": "ฉบับร่างที่รอเซิร์ฟเวอร์: {count}" }, + "offlineExport": { + "settings_label": "ส่งออกอีเมลที่เก็บไว้", + "settings_description": "บันทึกอีเมลในอุปกรณ์นี้เป็นไฟล์ mbox หรือ .eml ใช้งานแบบออฟไลน์ได้", + "title": "ส่งออกอีเมลที่เก็บไว้", + "description": "บันทึกต้นฉบับที่อุปกรณ์นี้เก็บไว้ ไฟล์ .eml เป็นสำเนาที่ตรงทุกประการ ส่วนไฟล์ mbox จะเพิ่มเครื่องหมายที่รูปแบบไฟล์ต้องการ และเขียนสถานะอ่านแล้วและติดธงลงในส่วนหัวของแต่ละข้อความแทนค่าเดิม อีเมลที่เก่ากว่าช่วงเวลาที่คุณเลือกจะไม่รวมอยู่ด้วย หากต้องการบันทึกทุกอย่างจากเซิร์ฟเวอร์ ให้ใช้ “ส่งออกอีเมลทั้งหมด”", + "folders_label": "โฟลเดอร์", + "all_folders": "ทุกโฟลเดอร์", + "folder_kept": "ที่นี่ {kept} จาก {total}", + "loading": "กำลังอ่านข้อมูลในอุปกรณ์นี้…", + "load_failed": "อ่านอีเมลในอุปกรณ์นี้ไม่ได้", + "nothing_kept": "ยังไม่มีอีเมลที่เก็บไว้ในอุปกรณ์นี้", + "kept": "ต้นฉบับในอุปกรณ์นี้: {kept} จาก {total} ข้อความ", + "missing_online": "ต้องดาวน์โหลดจากเซิร์ฟเวอร์: {count}", + "missing_offline": "ส่งออกขณะออฟไลน์ไม่ได้: {count} ลองอีกครั้งได้เมื่อออนไลน์", + "reason_attachments_off": "ข้อความต้นฉบับจะเก็บไว้เฉพาะเมื่อเปิด “เก็บไฟล์แนบ”", + "reason_not_kept": "ข้อความต้นฉบับจะดาวน์โหลดเป็นลำดับสุดท้าย และเฉพาะเมื่อมีพื้นที่", + "not_kept_attachments_off": "ไม่มีต้นฉบับเพราะอุปกรณ์นี้ไม่เก็บไฟล์แนบ: {count}", + "not_kept_not_kept": "ยังไม่มีต้นฉบับในอุปกรณ์นี้: {count}", + "retry": "ลองอีกครั้งแบบออนไลน์", + "retry_online": "ตอนนี้คุณออนไลน์แล้ว จึงดึงจากเซิร์ฟเวอร์ได้", + "retry_offline": "ลองอีกครั้งได้เมื่อออนไลน์", + "settings_button": "ส่งออก…", + "message_failed": "เซิร์ฟเวอร์ไม่ได้ส่งข้อความนี้มา", + "failed_generic": "การส่งออกหยุดเพราะเกิดข้อผิดพลาด โปรดลองอีกครั้ง", + "stopped_limit": "การส่งออกหยุดที่ {limit} ซึ่งเป็นขนาดสูงสุดที่เบราว์เซอร์นี้เก็บได้ เลือกโฟลเดอร์ให้น้อยลง หรือใช้ Chromium, Firefox หรือแอปเดสก์ท็อป", + "over_limit": "เบราว์เซอร์นี้เก็บได้สูงสุด {limit} แต่ที่เลือกไว้มีขนาดประมาณ {size} เลือกโฟลเดอร์ให้น้อยลง หรือใช้ Chromium, Firefox หรือแอปเดสก์ท็อป", + "no_room": "การส่งออกนี้ต้องใช้ประมาณ {size} แต่เบราว์เซอร์นี้เหลือ {free} เลือกโฟลเดอร์ให้น้อยลงหรือเพิ่มพื้นที่ว่าง", + "stopped_no_room": "เบราว์เซอร์มีพื้นที่ไม่พอสำหรับการส่งออก เลือกโฟลเดอร์ให้น้อยลงหรือเพิ่มพื้นที่ว่าง", + "stopped_offline_off": "การส่งออกหยุดเพราะบัญชีนี้ไม่ได้เก็บอีเมลไว้ในอุปกรณ์นี้แล้ว", + "policy_unknown": "ยังไม่ทราบกฎขององค์กรของคุณสำหรับบัญชีนี้ จึงปิดการส่งออกไว้ก่อน", + "leftover_file": "นำไฟล์ {name} ออกจากตำแหน่งที่คุณบันทึกไม่ได้ ไฟล์นี้ไม่สมบูรณ์ คุณจึงลบได้" + }, "plugin_pages": { "loading": "กำลังโหลดหน้าเว็บ...", "not_available_title": "หน้านี้ไม่พร้อมใช้งาน", @@ -6736,6 +6775,53 @@ "revoke": "ถอน", "revoked": "ถอนการอนุญาตแล้ว" }, + "desktopOs": { + "section_title": "แอปเดสก์ท็อป", + "section_description": "วิธีที่แอปทำงานกับคอมพิวเตอร์ของคุณ", + "close_to_tray_label": "ทำงานต่อในถาดระบบเมื่อปิด", + "close_to_tray_description": "เมลใหม่และการแจ้งเตือนยังเข้ามาหลังปิดหน้าต่าง", + "start_at_login_label": "เริ่มเมื่อลงชื่อเข้าใช้คอมพิวเตอร์", + "start_at_login_description": "แอปเริ่มเองเมื่อคุณลงชื่อเข้าใช้", + "start_minimized_label": "เริ่มในถาดระบบ", + "start_minimized_description": "ไม่มีหน้าต่างเปิดเมื่อลงชื่อเข้าใช้ เปิดด้วยไอคอนในถาดระบบ", + "attention_label": "กะพริบแถบงานเมื่อมีเมลใหม่", + "attention_mac_label": "ให้ไอคอน Dock เด้งเมื่อมีเมลใหม่", + "attention_description": "เฉพาะเมื่อแอปไม่ได้อยู่ด้านหน้า", + "quiet_hours_label": "ช่วงเวลาเงียบ", + "quiet_hours_description": "ไม่มีการแจ้งเตือนและเสียงในช่วงนี้ เมลยังเข้ามาตามปกติ", + "quiet_hours_from": "จาก", + "quiet_hours_to": "ถึง", + "show_shortcut_label": "แสดงแอปด้วยแป้นพิมพ์ลัด", + "show_shortcut_description": "{keys} นำแอปขึ้นมาด้านหน้าจากแอปอื่นใดก็ได้", + "show_shortcut_taken": "แอปอื่นใช้ {keys} อยู่ ปิดแอปนั้น หรือปิดแล้วเปิดตัวเลือกนี้อีกครั้ง", + "default_app_label": "แอปเมลเริ่มต้น", + "default_app_description": "เปิดลิงก์เมลและไฟล์ .eml, .ics และ .vcf ด้วยแอปนี้", + "default_app_is_default": "แอปนี้เปิดลิงก์เมลของคุณ", + "default_app_button": "เลือกแอปเริ่มต้น…", + "new_mail": "เมลใหม่", + "no_subject": "(ไม่มีหัวเรื่อง)", + "action_delete": "ลบ", + "copy_as_file": "คัดลอกเป็นไฟล์", + "copied": "คัดลอกไฟล์แล้ว คุณวางลงในโฟลเดอร์ได้", + "copy_failed": "คัดลอกไฟล์ไม่ได้", + "reply_lost": "ไม่สามารถเก็บคำตอบที่พิมพ์ไว้ได้ เปิดข้อความแล้วตอบจากที่นั่น" + }, + "desktopReminders": { + "generic_title": "การแจ้งเตือน", + "snooze": "เลื่อน {count} นาที", + "snooze_short": "{count} นาที", + "open": "เปิด", + "join": "เข้าร่วม", + "missed_title": "{count, plural, other {การแจ้งเตือนที่พลาด # รายการ}}", + "missed_more": "และอีก {count} รายการ", + "missed_show": "แสดง", + "missed_dialog_title": "การแจ้งเตือนที่พลาด", + "missed_dialog_description": "ถึงเวลาแจ้งเตือนขณะที่แอปปิดอยู่ คอมพิวเตอร์อยู่ในโหมดพัก หรือหยุดการแจ้งเตือนชั่วคราว", + "open_calendar": "เปิดปฏิทิน", + "done": "เสร็จ", + "join_host": "เข้าร่วม ({host})", + "join_named": "เข้าร่วม: {title}" + }, "desktop_policy": { "server_refused": "องค์กรของคุณไม่อนุญาตให้ใช้เซิร์ฟเวอร์นี้บนอุปกรณ์นี้", "plugin_refused": "องค์กรของคุณไม่อนุญาตให้ใช้ปลั๊กอินนี้บนอุปกรณ์นี้ จึงไม่ทำงาน แต่การตั้งค่าและข้อมูลของปลั๊กอินยังคงอยู่", @@ -6744,5 +6830,30 @@ "plugins_off": "องค์กรของคุณปิดปลั๊กอินบนอุปกรณ์นี้", "plugins_allowlist": "องค์กรของคุณอนุญาตเฉพาะปลั๊กอินบางตัวบนอุปกรณ์นี้", "off": "ปิด" + }, + "desktopTelemetry": { + "section_title": "ข้อมูลการใช้งานและรายงานข้อขัดข้อง", + "section_description": "ทั้งสองอย่างปิดอยู่จนกว่าคุณจะเปิด และแต่ละอย่างมีสวิตช์ของตัวเอง", + "telemetry_label": "ส่งข้อมูลการใช้งาน", + "telemetry_description": "วันละครั้ง: ID แบบสุ่ม เวอร์ชันแอป เวอร์ชัน Node ระบบปฏิบัติการของคุณ ระยะเวลาที่แอปทำงาน และวิธีที่แอปเชื่อมต่อ หากใช้เซิร์ฟเวอร์อีเมลของคุณเอง จะส่งฟีเจอร์ที่เปิดอยู่ จำนวนบัญชีโดยประมาณ และจำนวนปลั๊กอินกับธีมด้วย ไม่ส่งอีเมล ที่อยู่ หรือชื่อเซิร์ฟเวอร์เด็ดขาด", + "crash_label": "ส่งรายงานข้อขัดข้อง", + "crash_description": "ข้อผิดพลาดในแอปจะถูกส่งพร้อม ID แบบสุ่ม ชื่อไฟล์จากโค้ดของแอปเอง และข้อความสั้นๆ ที่ทำความสะอาดแล้ว หากตัวแอปเองขัดข้อง ไฟล์ข้อขัดข้องจะอยู่บนคอมพิวเตอร์เครื่องนี้และออกไปเมื่อคุณส่งเท่านั้น", + "crash_memory": "ไฟล์ข้อขัดข้องคือสำเนาส่วนหนึ่งของหน่วยความจำแอปในขณะที่ขัดข้อง Bulwark จะลบชื่อผู้ใช้ ที่อยู่ และโทเค็นการลงชื่อเข้าใช้ที่พบ แต่ไฟล์ข้อขัดข้องอาจยังมีข้อความในอีเมลของคุณอยู่", + "instance_note": "บนเซิร์ฟเวอร์ Bulwark ผู้ดูแลเซิร์ฟเวอร์นั้นจะตัดสินใจแยกต่างหากว่าหน้าเว็บจะรายงานอะไรให้เซิร์ฟเวอร์", + "forced_off": "องค์กรของคุณปิดสิ่งนี้แล้ว", + "url_label": "ปลายทางของไฟล์ข้อขัดข้อง", + "url_description": "ที่อยู่ https ที่รับไฟล์ข้อขัดข้อง Bulwark ไม่มีที่อยู่ให้มาด้วย", + "url_invalid": "ป้อนที่อยู่ https แบบเต็ม", + "url_save": "บันทึก", + "files_label": "ไฟล์ข้อขัดข้องบนคอมพิวเตอร์เครื่องนี้", + "files_description": "Bulwark เก็บไฟล์ล่าสุดไว้และลบไฟล์ที่เก่ากว่าหนึ่งเดือน", + "files_empty": "ไม่มีไฟล์ข้อขัดข้อง", + "file_size": "{size} KB", + "send": "ส่งรายงานข้อขัดข้อง…", + "delete_all": "ลบทั้งหมด", + "sent": "ส่งรายงานข้อขัดข้องแล้ว", + "send_no_consent": "เปิดรายงานข้อขัดข้องก่อน", + "send_no_url": "ไม่ได้ตั้งที่อยู่ จึงไม่มีปลายทางให้ส่ง ป้อนที่อยู่ด้านบน", + "send_failed": "ไม่สามารถส่งรายงานข้อขัดข้องได้" } } diff --git a/locales/tr/common.json b/locales/tr/common.json index 66f2a6fae..44d879076 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Yeni parolanızı bekleyen gönderilmemiş iletiler: {count}. {date} tarihine kadar kalır.", "refused_waiting_unknown": "Gönderilmemiş iletiler yeni parolanızı bekliyor olabilir. {date} tarihine kadar kalır.", "confirm_unsent_unknown": "Burada gönderilmemiş iletiler bekliyor olabilir. Kaybolacaklar.", - "confirm_templates": "Bu cihazda kayıtlı e-posta şablonları" + "confirm_templates": "Bu cihazda kayıtlı e-posta şablonları", + "session_waiting": "Bir sonraki oturum açmanızı bekleyen gönderilmemiş iletiler: {count}. {date} tarihine kadar kalır.", + "session_waiting_unknown": "Gönderilmemiş iletiler bir sonraki oturum açmanızı bekliyor olabilir. {date} tarihine kadar kalır.", + "needs_sign_in": "Oturum açılması gerekiyor", + "switcher_waiting": "Gönderilmemiş iletiler: {count}. {date} tarihinde bu cihazdan silinecek.", + "switcher_kept": "Bu cihazdaki e-postalar {date} tarihinde silinecek.", + "sibling_waiting": "{name} oturumu kapatıldı. Gönderilmemiş iletiler: {count}. {date} tarihine kadar oturum açın, yoksa bu cihazdan silinecekler.", + "sibling_kept": "{name} oturumu kapatıldı. {date} tarihine kadar oturum açın, yoksa e-postalar bu cihazdan silinecek." }, "offlineStorage": { "keep_description": "E-postalarınızı, takviminizi, kişilerinizi ve seçtiğiniz dosyaları bu cihazda şifreli olarak tutar. Çevrimdışıyken e-postaları okuyabilir, işaretleyebilir ve taşıyabilirsiniz; yazdıklarınız giden kutusunda bekler. Oturumu kapatınca hepsi silinir.", @@ -5866,6 +5873,38 @@ "banner_refused": "Sunucunun reddettiği taslaklar: {count}", "banner_waiting": "Sunucuyu bekleyen taslaklar: {count}" }, + "offlineExport": { + "settings_label": "Saklanan postayı dışa aktar", + "settings_description": "Bu cihazdaki postayı mbox veya .eml dosyası olarak kaydedin. Çevrimdışı da çalışır.", + "title": "Saklanan postayı dışa aktar", + "description": "Bu cihazın sakladığı orijinalleri kaydeder. .eml dosyaları birebir kopyalardır. mbox dosyası, biçiminin gerektirdiği işaretleri ekler ve okundu ile bayraklı durumunu her iletinin üst bilgilerine, var olanların yerine yazar. Seçtiğiniz süreden eski postalar buna dahil değildir. Sunucudaki her şeyi kaydetmek için “Tüm e-postaları dışa aktar” seçeneğini kullanın.", + "folders_label": "Klasörler", + "all_folders": "Tüm klasörler", + "folder_kept": "Burada: {kept} / {total}", + "loading": "Bu cihazdakiler okunuyor…", + "load_failed": "Bu cihazdaki posta okunamadı.", + "nothing_kept": "Bu cihazda henüz saklanan posta yok.", + "kept": "Bu cihazdaki orijinaller: {kept} / {total} ileti", + "missing_online": "Sunucudan indirilecek: {count}", + "missing_offline": "Çevrimdışıyken dışa aktarılamaz: {count}. Çevrimiçi olduğunuzda yeniden deneyebilirsiniz.", + "reason_attachments_off": "Orijinal iletiler yalnızca “Ekleri tut” açıkken saklanır.", + "reason_not_kept": "Orijinal iletiler en son indirilir, o da yalnızca yer varsa.", + "not_kept_attachments_off": "Bu cihazda ekler saklanmadığı için orijinal yok: {count}", + "not_kept_not_kept": "Bu cihazda henüz orijinal yok: {count}", + "retry": "Çevrimiçi yeniden dene", + "retry_online": "Artık çevrimiçisiniz, bunlar sunucudan gelebilir.", + "retry_offline": "Çevrimiçi olduğunuzda yeniden deneyebilirsiniz.", + "settings_button": "Dışa aktar…", + "message_failed": "Sunucu bu iletiyi vermedi.", + "failed_generic": "Dışa aktarma bir hata nedeniyle durdu. Yeniden deneyin.", + "stopped_limit": "Dışa aktarma {limit} boyutunda durdu; bu tarayıcı daha fazlasını tutamaz. Daha az klasör seçin veya Chromium, Firefox ya da masaüstü uygulamasını kullanın.", + "over_limit": "Bu tarayıcı en fazla {limit} tutabilir, bu seçim ise yaklaşık {size}. Daha az klasör seçin veya Chromium, Firefox ya da masaüstü uygulamasını kullanın.", + "no_room": "Bu dışa aktarma yaklaşık {size} gerektiriyor, bu tarayıcıda ise {free} kaldı. Daha az klasör seçin veya yer açın.", + "stopped_no_room": "Tarayıcıda dışa aktarma için yer kalmadı. Daha az klasör seçin veya yer açın.", + "stopped_offline_off": "Dışa aktarma durdu çünkü bu hesap artık bu cihazda posta saklamıyor.", + "policy_unknown": "Kuruluşunuzun bu hesap için kuralları henüz bilinmiyor, bu nedenle dışa aktarma şimdilik kapalı.", + "leftover_file": "{name} dosyası kaydettiğiniz yerden kaldırılamadı. Eksik olduğu için silebilirsiniz." + }, "deep_link": { "message_not_found": "Bu ileti artık kullanılamıyor.", "thread_not_found": "Bu sohbet artık kullanılamıyor.", @@ -6728,6 +6767,53 @@ "revoke": "Geri al", "revoked": "İzin geri alındı" }, + "desktopOs": { + "section_title": "Masaüstü uygulaması", + "section_description": "Uygulamanın bilgisayarınızla nasıl çalıştığı.", + "close_to_tray_label": "Kapatınca sistem tepsisinde çalışmaya devam et", + "close_to_tray_description": "Pencereyi kapattıktan sonra da yeni postalar ve anımsatıcılar gelmeye devam eder.", + "start_at_login_label": "Bilgisayarda oturum açınca başlat", + "start_at_login_description": "Oturum açtığınızda uygulama kendiliğinden başlar.", + "start_minimized_label": "Sistem tepsisinde başlat", + "start_minimized_description": "Oturum açarken pencere açılmaz. Sistem tepsisindeki simgeyle açın.", + "attention_label": "Yeni postada görev çubuğunu yanıp söndür", + "attention_mac_label": "Yeni postada Dock simgesini zıplat", + "attention_description": "Yalnızca uygulama önde değilken.", + "quiet_hours_label": "Sessiz saatler", + "quiet_hours_description": "Bu saatlerde bildirim ve ses yok. Postalar gelmeye devam eder.", + "quiet_hours_from": "Başlangıç", + "quiet_hours_to": "bitiş", + "show_shortcut_label": "Uygulamayı klavye kısayoluyla göster", + "show_shortcut_description": "{keys} uygulamayı başka herhangi bir uygulamadan öne getirir.", + "show_shortcut_taken": "{keys} başka bir uygulama tarafından kullanılıyor. O uygulamayı kapatın ya da bunu kapatıp yeniden açın.", + "default_app_label": "Varsayılan posta uygulaması", + "default_app_description": "Posta bağlantılarını ve .eml, .ics, .vcf dosyalarını bu uygulamayla aç.", + "default_app_is_default": "Posta bağlantılarınızı bu uygulama açıyor.", + "default_app_button": "Varsayılan uygulamayı seç…", + "new_mail": "Yeni posta", + "no_subject": "(konu yok)", + "action_delete": "Sil", + "copy_as_file": "Dosya olarak kopyala", + "copied": "Dosya kopyalandı. Bir klasöre yapıştırabilirsiniz.", + "copy_failed": "Dosya kopyalanamadı.", + "reply_lost": "Yazdığınız yanıt korunamadı. İletiyi açıp oradan yanıtlayın." + }, + "desktopReminders": { + "generic_title": "Anımsatıcı", + "snooze": "{count} dk ertele", + "snooze_short": "{count} dk", + "open": "Aç", + "join": "Katıl", + "missed_title": "{count, plural, one {Kaçırılan # anımsatıcı} other {Kaçırılan # anımsatıcı}}", + "missed_more": "ve {count} tane daha", + "missed_show": "Göster", + "missed_dialog_title": "Kaçırılan anımsatıcılar", + "missed_dialog_description": "Uygulama kapalıyken, bilgisayar uykudayken veya bildirimler duraklatılmışken zamanı geldi.", + "open_calendar": "Takvimi aç", + "done": "Bitti", + "join_host": "Katıl ({host})", + "join_named": "Katıl: {title}" + }, "desktop_policy": { "server_refused": "Kuruluşunuz bu cihazda bu sunucuya izin vermiyor.", "plugin_refused": "Kuruluşunuz bu cihazda bu eklentiye izin vermiyor, bu yüzden çalışmıyor. Ayarları ve verileri korunur.", @@ -6736,5 +6822,30 @@ "plugins_off": "Kuruluşunuz bu cihazda eklentileri kapattı.", "plugins_allowlist": "Kuruluşunuz bu cihazda yalnızca belirli eklentilere izin veriyor.", "off": "Kapalı" + }, + "desktopTelemetry": { + "section_title": "Kullanım bilgileri ve kilitlenme raporları", + "section_description": "İkisi de siz açana kadar kapalıdır ve her birinin kendi anahtarı vardır.", + "telemetry_label": "Kullanım bilgilerini gönder", + "telemetry_description": "Günde bir kez: rastgele bir kimlik, uygulama sürümü, Node sürümü, işletim sisteminiz, uygulamanın ne kadar süredir çalıştığı ve nasıl bağlandığı. Kendi posta sunucunuzda ayrıca hangi özelliklerin açık olduğu, yaklaşık hesap sayısı ve kaç eklenti ile temanız olduğu. Asla e-posta, adres veya sunucu adı gönderilmez.", + "crash_label": "Kilitlenme raporlarını gönder", + "crash_description": "Uygulamadaki hatalar rastgele bir kimlik, uygulamanın kendi kodundaki dosya adları ve kısa, temizlenmiş iletilerle gönderilir. Uygulamanın kendisi kilitlenirse bir kilitlenme dosyası bu bilgisayarda kalır ve yalnızca siz gönderirseniz çıkar.", + "crash_memory": "Kilitlenme dosyası, kilitlenme anında uygulama belleğinin bir bölümünün kopyasıdır. Bulwark bulduğu kullanıcı adlarını, adresleri ve oturum açma belirteçlerini kaldırır ancak kilitlenme dosyası yine de e-postalarınızın metnini içerebilir.", + "instance_note": "Bir Bulwark sunucusunda, sayfaların ona ne bildireceğine yöneticisi ayrıca karar verir.", + "forced_off": "Kuruluşunuz bunu kapattı.", + "url_label": "Kilitlenme dosyalarının gideceği yer", + "url_description": "Kilitlenme dosyasını alan https adresi. Bulwark bir adresle gelmez.", + "url_invalid": "Tam bir https adresi girin.", + "url_save": "Kaydet", + "files_label": "Bu bilgisayardaki kilitlenme dosyaları", + "files_description": "Bulwark en yenileri saklar ve bir aydan eski olanları siler.", + "files_empty": "Kilitlenme dosyası yok.", + "file_size": "{size} KB", + "send": "Kilitlenme raporunu gönder…", + "delete_all": "Tümünü sil", + "sent": "Kilitlenme raporu gönderildi.", + "send_no_consent": "Önce kilitlenme raporlarını açın.", + "send_no_url": "Adres ayarlanmadı, bu yüzden gönderilecek yer yok. Yukarıya bir adres girin.", + "send_failed": "Kilitlenme raporu gönderilemedi." } } diff --git a/locales/uk/common.json b/locales/uk/common.json index c73a100bc..2ca73c96b 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "Ненадіслані повідомлення чекають на новий пароль: {count}. Вони зберігаються до {date}.", "refused_waiting_unknown": "Можливо, ненадіслані повідомлення чекають на новий пароль. Вони зберігаються до {date}.", "confirm_unsent_unknown": "Тут можуть бути ненадіслані повідомлення. Їх буде втрачено.", - "confirm_templates": "Шаблони листів, збережені на цьому пристрої" + "confirm_templates": "Шаблони листів, збережені на цьому пристрої", + "session_waiting": "Ненадіслані повідомлення чекають на наступний вхід: {count}. Вони зберігаються до {date}.", + "session_waiting_unknown": "Можливо, ненадіслані повідомлення чекають на наступний вхід. Вони зберігаються до {date}.", + "needs_sign_in": "Потрібен вхід", + "switcher_waiting": "Ненадіслані повідомлення: {count}. Буде видалено з цього пристрою {date}.", + "switcher_kept": "Пошту на цьому пристрої буде видалено {date}.", + "sibling_waiting": "Виконано вихід із {name}. Ненадіслані повідомлення: {count}. Увійдіть до {date}, інакше їх буде видалено з цього пристрою.", + "sibling_kept": "Виконано вихід із {name}. Увійдіть до {date}, інакше пошту буде видалено з цього пристрою." }, "offlineStorage": { "keep_description": "Зберігає вашу пошту, календар, контакти та вибрані файли в зашифрованому вигляді на цьому пристрої. Без мережі можна читати, позначати й переміщати листи, а написане чекає у вихідних. Під час виходу все видаляється.", @@ -5866,6 +5873,38 @@ "banner_refused": "Чернетки, відхилені сервером: {count}", "banner_waiting": "Чернетки, що чекають на сервер: {count}" }, + "offlineExport": { + "settings_label": "Експорт збереженої пошти", + "settings_description": "Збережіть пошту з цього пристрою у файли mbox або .eml. Працює і без мережі.", + "title": "Експорт збереженої пошти", + "description": "Зберігає оригінали, які є на цьому пристрої. Файли .eml — точні копії. Файл mbox додає позначки, яких вимагає його формат, і записує стан «прочитано» й «позначено» в заголовки кожного повідомлення замість попередніх. Пошти, старшої за вибраний період, тут немає. Щоб зберегти все із сервера, скористайтеся «Експортувати всю пошту».", + "folders_label": "Папки", + "all_folders": "Усі папки", + "folder_kept": "Тут: {kept} із {total}", + "loading": "Читання даних на цьому пристрої…", + "load_failed": "Не вдалося прочитати пошту на цьому пристрої.", + "nothing_kept": "На цьому пристрої ще немає збереженої пошти.", + "kept": "Оригінали на цьому пристрої: {kept} із {total}", + "missing_online": "Завантажити із сервера: {count}", + "missing_offline": "Не можна експортувати без мережі: {count}. Спробуйте ще раз, коли з'явиться мережа.", + "reason_attachments_off": "Початкові повідомлення зберігаються, лише якщо ввімкнено «Зберігати вкладення».", + "reason_not_kept": "Початкові повідомлення завантажуються останніми й лише поки є місце.", + "not_kept_attachments_off": "Немає оригіналу, бо на цьому пристрої не зберігаються вкладення: {count}", + "not_kept_not_kept": "Поки немає оригіналу на цьому пристрої: {count}", + "retry": "Повторити в мережі", + "retry_online": "Мережа є, тож їх можна отримати із сервера.", + "retry_offline": "Спробуйте ще раз, коли з'явиться мережа.", + "settings_button": "Експортувати…", + "message_failed": "Сервер не видав це повідомлення.", + "failed_generic": "Експорт зупинено через помилку. Спробуйте ще раз.", + "stopped_limit": "Експорт зупинено на {limit}: більше цей браузер утримати не може. Виберіть менше папок або скористайтеся Chromium, Firefox чи застосунком для комп'ютера.", + "over_limit": "Цей браузер може утримати щонайбільше {limit}, а вибрано приблизно {size}. Виберіть менше папок або скористайтеся Chromium, Firefox чи застосунком для комп'ютера.", + "no_room": "Для цього експорту потрібно приблизно {size}, а цьому браузеру доступно {free}. Виберіть менше папок або звільніть місце.", + "stopped_no_room": "Браузеру забракло місця для експорту. Виберіть менше папок або звільніть місце.", + "stopped_offline_off": "Експорт зупинено: цей обліковий запис більше не зберігає пошту на цьому пристрої.", + "policy_unknown": "Правила вашої організації для цього облікового запису поки невідомі, тому експорт поки вимкнено.", + "leftover_file": "Не вдалося вилучити файл {name} з місця збереження. Він неповний, його можна видалити." + }, "deep_link": { "message_not_found": "Цей лист більше недоступний.", "thread_not_found": "Ця розмова більше недоступна.", @@ -6728,6 +6767,53 @@ "revoke": "Відкликати", "revoked": "Дозвіл відкликано" }, + "desktopOs": { + "section_title": "Настільний застосунок", + "section_description": "Як застосунок працює з вашим комп’ютером.", + "close_to_tray_label": "Під час закриття працювати далі в треї", + "close_to_tray_description": "Нова пошта й нагадування надходять і після закриття вікна.", + "start_at_login_label": "Запускати під час входу в систему", + "start_at_login_description": "Застосунок запускається сам, коли ви входите.", + "start_minimized_label": "Запускати в треї", + "start_minimized_description": "Під час входу вікно не відкривається. Відкрийте його значком у треї.", + "attention_label": "Блимати на панелі завдань за нової пошти", + "attention_mac_label": "Підстрибувати значком у Dock за нової пошти", + "attention_description": "Лише коли застосунок не на передньому плані.", + "quiet_hours_label": "Тихі години", + "quiet_hours_description": "У цей час немає сповіщень і звуків. Пошта надходить далі.", + "quiet_hours_from": "З", + "quiet_hours_to": "до", + "show_shortcut_label": "Показувати застосунок сполученням клавіш", + "show_shortcut_description": "{keys} виводить застосунок на передній план із будь-якого іншого.", + "show_shortcut_taken": "{keys} використовує інший застосунок. Закрийте його або вимкніть і знову увімкніть цей параметр.", + "default_app_label": "Поштовий застосунок за замовчуванням", + "default_app_description": "Відкривати поштові посилання та файли .eml, .ics і .vcf у цьому застосунку.", + "default_app_is_default": "Цей застосунок відкриває ваші поштові посилання.", + "default_app_button": "Вибрати застосунок за замовчуванням…", + "new_mail": "Нова пошта", + "no_subject": "(без теми)", + "action_delete": "Видалити", + "copy_as_file": "Копіювати як файл", + "copied": "Файл скопійовано. Його можна вставити в папку.", + "copy_failed": "Не вдалося скопіювати файл.", + "reply_lost": "Не вдалося зберегти введену відповідь. Відкрийте лист і дайте відповідь у ньому." + }, + "desktopReminders": { + "generic_title": "Нагадування", + "snooze": "Відкласти на {count} хв", + "snooze_short": "{count} хв", + "open": "Відкрити", + "join": "Приєднатися", + "missed_title": "{count, plural, one {# пропущене нагадування} few {# пропущені нагадування} many {# пропущених нагадувань} other {# пропущеного нагадування}}", + "missed_more": "і ще {count}", + "missed_show": "Показати", + "missed_dialog_title": "Пропущені нагадування", + "missed_dialog_description": "Вони спрацювали, коли застосунок було закрито, комп'ютер спав або сповіщення були призупинені.", + "open_calendar": "Відкрити календар", + "done": "Готово", + "join_host": "Приєднатися ({host})", + "join_named": "Приєднатися: {title}" + }, "desktop_policy": { "server_refused": "Ваша організація не дозволяє цей сервер на цьому пристрої.", "plugin_refused": "Ваша організація не дозволяє цей плагін на цьому пристрої, тому він не працює. Його налаштування та дані зберігаються.", @@ -6736,5 +6822,30 @@ "plugins_off": "Ваша організація вимкнула плагіни на цьому пристрої.", "plugins_allowlist": "Ваша організація дозволяє на цьому пристрої лише деякі плагіни.", "off": "Вимк." + }, + "desktopTelemetry": { + "section_title": "Дані про використання та звіти про збої", + "section_description": "Обидва вимкнені, доки ви їх не ввімкнете, і кожен має власний перемикач.", + "telemetry_label": "Надсилати дані про використання", + "telemetry_description": "Раз на день: випадковий ідентифікатор, версія застосунку, версія Node, ваша операційна система, як довго працює застосунок і як він підключається. З власним поштовим сервером також увімкнені функції, приблизна кількість облікових записів і кількість плагінів і тем. Ніколи листи, адреси чи назви серверів.", + "crash_label": "Надсилати звіти про збої", + "crash_description": "Помилки в застосунку надсилаються з випадковим ідентифікатором, назвами файлів із коду самого застосунку та короткими очищеними повідомленнями. Якщо збій стається в самому застосунку, файл збою залишається на цьому комп'ютері й покидає його, лише якщо ви його надішлете.", + "crash_memory": "Файл збою — це копія частини пам'яті застосунку в момент збою. Bulwark вилучає знайдені імена користувачів, адреси й токени входу, але файл збою все одно може містити текст ваших листів.", + "instance_note": "На сервері Bulwark його адміністратор окремо вирішує, що сторінки йому повідомляють.", + "forced_off": "Ваша організація вимкнула це.", + "url_label": "Куди надходять файли збоїв", + "url_description": "Адреса https, яка приймає файл збою. У Bulwark її немає.", + "url_invalid": "Введіть повну адресу https.", + "url_save": "Зберегти", + "files_label": "Файли збоїв на цьому комп'ютері", + "files_description": "Bulwark зберігає найновіші та видаляє ті, що старші за місяць.", + "files_empty": "Файлів збоїв немає.", + "file_size": "{size} КБ", + "send": "Надіслати звіт про збій…", + "delete_all": "Видалити всі", + "sent": "Звіт про збій надіслано.", + "send_no_consent": "Спочатку ввімкніть звіти про збої.", + "send_no_url": "Адресу не задано, тому надсилати нікуди. Введіть її вище.", + "send_failed": "Не вдалося надіслати звіт про збій." } } diff --git a/locales/vi/common.json b/locales/vi/common.json index 7909595be..5d6c6ccf8 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5795,7 +5795,14 @@ "refused_waiting": "Thư chưa gửi đang chờ mật khẩu mới của bạn: {count}. Được giữ đến {date}.", "refused_waiting_unknown": "Có thể có thư chưa gửi đang chờ mật khẩu mới của bạn. Được giữ đến {date}.", "confirm_unsent_unknown": "Có thể có thư chưa gửi đang chờ ở đây. Chúng sẽ bị mất.", - "confirm_templates": "Mẫu email đã lưu trên thiết bị này" + "confirm_templates": "Mẫu email đã lưu trên thiết bị này", + "session_waiting": "Thư chưa gửi đang chờ lần đăng nhập tiếp theo: {count}. Được giữ đến {date}.", + "session_waiting_unknown": "Có thể có thư chưa gửi đang chờ lần đăng nhập tiếp theo. Được giữ đến {date}.", + "needs_sign_in": "Cần đăng nhập", + "switcher_waiting": "Thư chưa gửi: {count}. Sẽ bị xóa khỏi thiết bị này vào {date}.", + "switcher_kept": "Email trên thiết bị này sẽ bị xóa vào {date}.", + "sibling_waiting": "{name} đã bị đăng xuất. Thư chưa gửi: {count}. Hãy đăng nhập trước {date}, nếu không chúng sẽ bị xóa khỏi thiết bị này.", + "sibling_kept": "{name} đã bị đăng xuất. Hãy đăng nhập trước {date}, nếu không email sẽ bị xóa khỏi thiết bị này." }, "offlineStorage": { "keep_description": "Giữ thư, lịch, danh bạ và các tệp bạn chọn, được mã hóa trên thiết bị này. Khi ngoại tuyến bạn có thể đọc, gắn cờ và di chuyển thư, còn thư bạn viết sẽ chờ trong Hộp thư đi. Khi đăng xuất, tất cả sẽ bị xóa.", @@ -5878,6 +5885,38 @@ "banner_refused": "Bản nháp bị máy chủ từ chối: {count}", "banner_waiting": "Bản nháp đang chờ máy chủ: {count}" }, + "offlineExport": { + "settings_label": "Xuất thư đã lưu", + "settings_description": "Lưu thư trên thiết bị này thành tệp mbox hoặc .eml. Hoạt động cả khi ngoại tuyến.", + "title": "Xuất thư đã lưu", + "description": "Lưu các bản gốc mà thiết bị này giữ. Tệp .eml là bản sao chính xác. Tệp mbox thêm các dấu mà định dạng của nó cần và ghi trạng thái đã đọc và gắn cờ vào tiêu đề của mỗi thư, thay cho giá trị cũ. Thư cũ hơn khoảng thời gian bạn chọn không có trong đó. Để lưu mọi thứ trên máy chủ, hãy dùng “Xuất toàn bộ thư”.", + "folders_label": "Thư mục", + "all_folders": "Tất cả thư mục", + "folder_kept": "Ở đây {kept}/{total}", + "loading": "Đang đọc dữ liệu trên thiết bị này…", + "load_failed": "Không đọc được thư trên thiết bị này.", + "nothing_kept": "Chưa có thư nào được lưu trên thiết bị này.", + "kept": "Bản gốc trên thiết bị này: {kept}/{total} thư", + "missing_online": "Cần tải từ máy chủ: {count}", + "missing_offline": "Không xuất được khi ngoại tuyến: {count}. Bạn có thể thử lại khi trực tuyến.", + "reason_attachments_off": "Thư gốc chỉ được giữ khi “Giữ tệp đính kèm” đang bật.", + "reason_not_kept": "Thư gốc được tải sau cùng và chỉ khi còn chỗ.", + "not_kept_attachments_off": "Không có bản gốc vì thiết bị này không giữ tệp đính kèm: {count}", + "not_kept_not_kept": "Chưa có bản gốc trên thiết bị này: {count}", + "retry": "Thử lại khi trực tuyến", + "retry_online": "Bạn đang trực tuyến nên có thể lấy từ máy chủ.", + "retry_offline": "Bạn có thể thử lại khi trực tuyến.", + "settings_button": "Xuất…", + "message_failed": "Máy chủ không trả về thư này.", + "failed_generic": "Việc xuất đã dừng do lỗi. Hãy thử lại.", + "stopped_limit": "Việc xuất đã dừng ở {limit}, mức tối đa trình duyệt này có thể giữ. Hãy chọn ít thư mục hơn, hoặc dùng Chromium, Firefox hay ứng dụng máy tính.", + "over_limit": "Trình duyệt này giữ được tối đa {limit}, còn lựa chọn này khoảng {size}. Hãy chọn ít thư mục hơn, hoặc dùng Chromium, Firefox hay ứng dụng máy tính.", + "no_room": "Lần xuất này cần khoảng {size}, còn trình duyệt này còn {free}. Hãy chọn ít thư mục hơn hoặc giải phóng dung lượng.", + "stopped_no_room": "Trình duyệt đã hết chỗ cho việc xuất. Hãy chọn ít thư mục hơn hoặc giải phóng dung lượng.", + "stopped_offline_off": "Việc xuất đã dừng vì tài khoản này không còn giữ thư trên thiết bị này.", + "policy_unknown": "Chưa biết quy tắc của tổ chức bạn cho tài khoản này, nên việc xuất tạm thời tắt.", + "leftover_file": "Không thể xóa tệp {name} khỏi nơi bạn đã lưu. Tệp chưa hoàn chỉnh, bạn có thể xóa nó." + }, "deep_link": { "message_not_found": "Thư này không còn nữa.", "thread_not_found": "Cuộc trò chuyện này không còn tồn tại nữa.", @@ -6740,6 +6779,53 @@ "revoke": "Thu hồi", "revoked": "Đã thu hồi quyền" }, + "desktopOs": { + "section_title": "Ứng dụng máy tính", + "section_description": "Cách ứng dụng hoạt động với máy tính của bạn.", + "close_to_tray_label": "Tiếp tục chạy ở khay hệ thống khi đóng", + "close_to_tray_description": "Thư mới và lời nhắc vẫn đến sau khi bạn đóng cửa sổ.", + "start_at_login_label": "Khởi động khi đăng nhập vào máy tính", + "start_at_login_description": "Ứng dụng tự khởi động khi bạn đăng nhập.", + "start_minimized_label": "Khởi động ở khay hệ thống", + "start_minimized_description": "Không có cửa sổ nào mở khi đăng nhập. Mở bằng biểu tượng ở khay hệ thống.", + "attention_label": "Nhấp nháy thanh tác vụ khi có thư mới", + "attention_mac_label": "Làm biểu tượng Dock nảy khi có thư mới", + "attention_description": "Chỉ khi ứng dụng không ở phía trước.", + "quiet_hours_label": "Giờ yên tĩnh", + "quiet_hours_description": "Không có thông báo hay âm thanh vào giờ này. Thư vẫn đến.", + "quiet_hours_from": "Từ", + "quiet_hours_to": "đến", + "show_shortcut_label": "Hiện ứng dụng bằng phím tắt", + "show_shortcut_description": "{keys} đưa ứng dụng lên trước từ bất kỳ ứng dụng nào khác.", + "show_shortcut_taken": "Ứng dụng khác đang dùng {keys}. Hãy đóng ứng dụng đó, hoặc tắt rồi bật lại tùy chọn này.", + "default_app_label": "Ứng dụng thư mặc định", + "default_app_description": "Mở liên kết thư và tệp .eml, .ics, .vcf bằng ứng dụng này.", + "default_app_is_default": "Ứng dụng này mở các liên kết thư của bạn.", + "default_app_button": "Chọn ứng dụng mặc định…", + "new_mail": "Thư mới", + "no_subject": "(không có chủ đề)", + "action_delete": "Xóa", + "copy_as_file": "Sao chép dưới dạng tệp", + "copied": "Đã sao chép tệp. Bạn có thể dán vào một thư mục.", + "copy_failed": "Không thể sao chép tệp.", + "reply_lost": "Không thể giữ câu trả lời bạn đã nhập. Hãy mở thư và trả lời ở đó." + }, + "desktopReminders": { + "generic_title": "Lời nhắc", + "snooze": "Báo lại sau {count} phút", + "snooze_short": "{count} phút", + "open": "Mở", + "join": "Tham gia", + "missed_title": "{count, plural, other {# lời nhắc đã bỏ lỡ}}", + "missed_more": "và {count} lời nhắc khác", + "missed_show": "Hiển thị", + "missed_dialog_title": "Lời nhắc đã bỏ lỡ", + "missed_dialog_description": "Những lời nhắc này đến hạn khi ứng dụng đóng, máy tính ngủ hoặc thông báo bị tạm dừng.", + "open_calendar": "Mở lịch", + "done": "Xong", + "join_host": "Tham gia ({host})", + "join_named": "Tham gia: {title}" + }, "desktop_policy": { "server_refused": "Tổ chức của bạn không cho phép máy chủ này trên thiết bị này.", "plugin_refused": "Tổ chức của bạn không cho phép tiện ích này trên thiết bị này nên tiện ích không chạy. Cài đặt và dữ liệu của tiện ích vẫn được giữ lại.", @@ -6748,5 +6834,30 @@ "plugins_off": "Tổ chức của bạn đã tắt tiện ích trên thiết bị này.", "plugins_allowlist": "Tổ chức của bạn chỉ cho phép một số tiện ích trên thiết bị này.", "off": "Tắt" + }, + "desktopTelemetry": { + "section_title": "Thông tin sử dụng và báo cáo sự cố", + "section_description": "Cả hai đều tắt cho đến khi bạn bật, và mỗi mục có công tắc riêng.", + "telemetry_label": "Gửi thông tin sử dụng", + "telemetry_description": "Mỗi ngày một lần: một ID ngẫu nhiên, phiên bản ứng dụng, phiên bản Node, hệ điều hành của bạn, ứng dụng đã chạy bao lâu và cách nó kết nối. Với máy chủ thư riêng, còn có các tính năng đang bật, số tài khoản ước tính và số tiện ích cùng chủ đề bạn có. Không bao giờ gửi thư, địa chỉ hay tên máy chủ.", + "crash_label": "Gửi báo cáo sự cố", + "crash_description": "Lỗi trong ứng dụng được gửi kèm một ID ngẫu nhiên, tên tệp từ mã của chính ứng dụng và thông báo ngắn đã được làm sạch. Nếu chính ứng dụng gặp sự cố, một tệp sự cố sẽ ở lại trên máy tính này và chỉ rời đi khi bạn gửi.", + "crash_memory": "Tệp sự cố là bản sao một phần bộ nhớ của ứng dụng khi gặp sự cố. Bulwark xóa tên người dùng, địa chỉ và mã đăng nhập mà nó tìm thấy, nhưng tệp sự cố vẫn có thể chứa nội dung thư của bạn.", + "instance_note": "Trên máy chủ Bulwark, quản trị viên của máy chủ đó quyết định riêng những gì các trang báo cáo về.", + "forced_off": "Tổ chức của bạn đã tắt mục này.", + "url_label": "Nơi nhận tệp sự cố", + "url_description": "Địa chỉ https nhận tệp sự cố. Bulwark không có sẵn địa chỉ nào.", + "url_invalid": "Nhập địa chỉ https đầy đủ.", + "url_save": "Lưu", + "files_label": "Tệp sự cố trên máy tính này", + "files_description": "Bulwark giữ các tệp mới nhất và xóa các tệp cũ hơn một tháng.", + "files_empty": "Không có tệp sự cố.", + "file_size": "{size} KB", + "send": "Gửi báo cáo sự cố…", + "delete_all": "Xóa tất cả", + "sent": "Đã gửi báo cáo sự cố.", + "send_no_consent": "Hãy bật báo cáo sự cố trước.", + "send_no_url": "Chưa đặt địa chỉ nên không có nơi để gửi. Hãy nhập một địa chỉ ở trên.", + "send_failed": "Không thể gửi báo cáo sự cố." } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 215809552..5c5ba12ad 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "等待新密碼的未傳送郵件:{count} 封。保留至 {date}。", "refused_waiting_unknown": "可能有未傳送的郵件正在等待新密碼。保留至 {date}。", "confirm_unsent_unknown": "這裡可能有未傳送的郵件。它們將會遺失。", - "confirm_templates": "儲存在此裝置上的郵件範本" + "confirm_templates": "儲存在此裝置上的郵件範本", + "session_waiting": "等待下次登入的未傳送郵件:{count} 封。保留至 {date}。", + "session_waiting_unknown": "可能有未傳送的郵件正在等待下次登入。保留至 {date}。", + "needs_sign_in": "需要登入", + "switcher_waiting": "未傳送郵件:{count} 封。將於 {date} 從此裝置刪除。", + "switcher_kept": "此裝置上的郵件將於 {date} 刪除。", + "sibling_waiting": "{name} 已登出。未傳送郵件:{count} 封。請在 {date} 前登入,否則將從此裝置刪除。", + "sibling_kept": "{name} 已登出。請在 {date} 前登入,否則郵件將從此裝置刪除。" }, "offlineStorage": { "keep_description": "在此裝置上加密保留你的郵件、行事曆、聯絡人和你選擇的檔案。離線時可以閱讀、標記和移動郵件,寫好的郵件會在寄件匣中等待。登出時會全部刪除。", @@ -5866,6 +5873,38 @@ "banner_refused": "伺服器拒絕的草稿:{count}", "banner_waiting": "等待伺服器的草稿:{count}" }, + "offlineExport": { + "settings_label": "匯出已保存的郵件", + "settings_description": "將此裝置上的郵件儲存為 mbox 或 .eml 檔案。離線時也可使用。", + "title": "匯出已保存的郵件", + "description": "儲存此裝置保留的原件。.eml 檔案是完全相同的副本。mbox 檔案會加上其格式需要的標記,並把已讀和標幟狀態寫入每封郵件的標頭,取代原有內容。早於所選期間的郵件不包含在內。要儲存伺服器上的全部內容,請使用「匯出所有郵件」。", + "folders_label": "資料夾", + "all_folders": "所有資料夾", + "folder_kept": "此處 {kept} / {total}", + "loading": "正在讀取此裝置上的內容…", + "load_failed": "無法讀取此裝置上的郵件。", + "nothing_kept": "此裝置上尚未保存郵件。", + "kept": "此裝置上的原件:{kept} / {total} 封郵件", + "missing_online": "要從伺服器下載:{count}", + "missing_offline": "離線時無法匯出:{count}。連線後可以重試。", + "reason_attachments_off": "只有開啟「保留附件」時才會保留原始郵件。", + "reason_not_kept": "原始郵件最後下載,且僅在有空間時下載。", + "not_kept_attachments_off": "此裝置不保留附件,因此沒有原件:{count}", + "not_kept_not_kept": "此裝置上尚無原件:{count}", + "retry": "連線後重試", + "retry_online": "現在已連線,可以從伺服器取得。", + "retry_offline": "連線後可以重試。", + "settings_button": "匯出…", + "message_failed": "伺服器沒有提供這封郵件。", + "failed_generic": "匯出因錯誤而停止。請重試。", + "stopped_limit": "匯出在 {limit} 處停止,這是此瀏覽器能容納的上限。請選擇較少的資料夾,或使用 Chromium、Firefox 或桌面應用程式。", + "over_limit": "此瀏覽器最多能容納 {limit},而此選擇約為 {size}。請選擇較少的資料夾,或使用 Chromium、Firefox 或桌面應用程式。", + "no_room": "此次匯出約需 {size},而此瀏覽器還剩 {free}。請選擇較少的資料夾或釋放空間。", + "stopped_no_room": "瀏覽器沒有足夠空間完成匯出。請選擇較少的資料夾或釋放空間。", + "stopped_offline_off": "匯出已停止,因為此帳號不再於此裝置上保留郵件。", + "policy_unknown": "尚不清楚貴組織對此帳號的規則,因此匯出暫時關閉。", + "leftover_file": "無法從儲存位置移除檔案 {name}。它不完整,您可以將其刪除。" + }, "deep_link": { "message_not_found": "已無法使用此郵件。", "thread_not_found": "已無法使用此對話串。", @@ -6728,6 +6767,53 @@ "revoke": "收回", "revoked": "已收回許可" }, + "desktopOs": { + "section_title": "桌面應用程式", + "section_description": "應用程式如何與您的電腦配合。", + "close_to_tray_label": "關閉時繼續在系統匣中執行", + "close_to_tray_description": "關閉視窗後,新郵件和提醒仍會送達。", + "start_at_login_label": "登入電腦時啟動", + "start_at_login_description": "您登入時應用程式會自動啟動。", + "start_minimized_label": "在系統匣中啟動", + "start_minimized_description": "登入時不開啟視窗。請透過系統匣圖示開啟。", + "attention_label": "有新郵件時閃爍工作列", + "attention_mac_label": "有新郵件時讓 Dock 圖示跳動", + "attention_description": "僅在應用程式不在前景時。", + "quiet_hours_label": "勿擾時段", + "quiet_hours_description": "此時段沒有通知和聲音。郵件仍會送達。", + "quiet_hours_from": "從", + "quiet_hours_to": "到", + "show_shortcut_label": "使用鍵盤快速鍵顯示應用程式", + "show_shortcut_description": "{keys} 可從任何其他應用程式將此應用程式帶到前景。", + "show_shortcut_taken": "其他應用程式正在使用 {keys}。請關閉該應用程式,或關閉此選項後再開啟。", + "default_app_label": "預設郵件應用程式", + "default_app_description": "使用此應用程式開啟郵件連結以及 .eml、.ics 和 .vcf 檔案。", + "default_app_is_default": "此應用程式會開啟您的郵件連結。", + "default_app_button": "選擇預設應用程式…", + "new_mail": "新郵件", + "no_subject": "(無主旨)", + "action_delete": "刪除", + "copy_as_file": "複製為檔案", + "copied": "檔案已複製。您可以將其貼到資料夾中。", + "copy_failed": "無法複製檔案。", + "reply_lost": "無法保留您輸入的回覆。請開啟郵件並在其中回覆。" + }, + "desktopReminders": { + "generic_title": "提醒", + "snooze": "{count} 分鐘後再提醒", + "snooze_short": "{count} 分鐘", + "open": "開啟", + "join": "加入", + "missed_title": "{count, plural, other {# 則錯過的提醒}}", + "missed_more": "還有 {count} 則", + "missed_show": "顯示", + "missed_dialog_title": "錯過的提醒", + "missed_dialog_description": "這些提醒在應用程式關閉、電腦睡眠或通知暫停時到期。", + "open_calendar": "開啟日曆", + "done": "完成", + "join_host": "加入 ({host})", + "join_named": "加入: {title}" + }, "desktop_policy": { "server_refused": "您的組織不允許在此裝置上使用此伺服器。", "plugin_refused": "您的組織不允許在此裝置上使用此外掛程式,因此不會執行。其設定和資料會保留。", @@ -6736,5 +6822,30 @@ "plugins_off": "您的組織已在此裝置上關閉外掛程式。", "plugins_allowlist": "您的組織在此裝置上只允許使用部分外掛程式。", "off": "關閉" + }, + "desktopTelemetry": { + "section_title": "使用資訊與當機報告", + "section_description": "兩者在您開啟前都維持關閉,且各有獨立的開關。", + "telemetry_label": "傳送使用資訊", + "telemetry_description": "每天一次:一個隨機 ID、應用程式版本、Node 版本、您的作業系統、應用程式已執行多久以及連線方式。使用自己的郵件伺服器時,還包括已開啟的功能、大致的帳號數量以及外掛和主題的數量。絕不傳送郵件、地址或伺服器名稱。", + "crash_label": "傳送當機報告", + "crash_description": "應用程式中的錯誤會連同一個隨機 ID、應用程式本身程式碼的檔案名稱和經過清理的簡短訊息一起傳送。如果應用程式本身當機,當機檔案會留在這台電腦上,只有您傳送時才會離開。", + "crash_memory": "當機檔案是當機時應用程式部分記憶體的副本。Bulwark 會移除它找到的使用者名稱、地址和登入權杖,但當機檔案仍可能包含您郵件的文字。", + "instance_note": "在 Bulwark 伺服器上,頁面向伺服器回報哪些內容由該伺服器的管理員另行決定。", + "forced_off": "您的組織已關閉此功能。", + "url_label": "當機檔案的去向", + "url_description": "接收當機檔案的 https 地址。Bulwark 沒有預設地址。", + "url_invalid": "請輸入完整的 https 地址。", + "url_save": "儲存", + "files_label": "這台電腦上的當機檔案", + "files_description": "Bulwark 保留最新的檔案,並刪除超過一個月的檔案。", + "files_empty": "沒有當機檔案。", + "file_size": "{size} KB", + "send": "傳送當機報告…", + "delete_all": "全部刪除", + "sent": "當機報告已傳送。", + "send_no_consent": "請先開啟當機報告。", + "send_no_url": "未設定地址,因此無處傳送。請在上方輸入一個。", + "send_failed": "無法傳送當機報告。" } } diff --git a/locales/zh/common.json b/locales/zh/common.json index bc9c31732..67314fa42 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5783,7 +5783,14 @@ "refused_waiting": "等待新密码的未发送邮件:{count} 封。保留至 {date}。", "refused_waiting_unknown": "可能有未发送的邮件在等待新密码。保留至 {date}。", "confirm_unsent_unknown": "这里可能有未发送的邮件。它们将会丢失。", - "confirm_templates": "保存在此设备上的邮件模板" + "confirm_templates": "保存在此设备上的邮件模板", + "session_waiting": "等待下次登录的未发送邮件:{count} 封。保留至 {date}。", + "session_waiting_unknown": "可能有未发送的邮件在等待下次登录。保留至 {date}。", + "needs_sign_in": "需要登录", + "switcher_waiting": "未发送邮件:{count} 封。将于 {date} 从此设备删除。", + "switcher_kept": "此设备上的邮件将于 {date} 删除。", + "sibling_waiting": "{name} 已退出登录。未发送邮件:{count} 封。请在 {date} 前登录,否则将从此设备删除。", + "sibling_kept": "{name} 已退出登录。请在 {date} 前登录,否则邮件将从此设备删除。" }, "offlineStorage": { "keep_description": "在此设备上加密保留你的邮件、日历、联系人和你选择的文件。离线时可以阅读、标记和移动邮件,写好的邮件会在发件箱中等待。退出登录时会全部删除。", @@ -5866,6 +5873,38 @@ "banner_refused": "服务器拒绝的草稿:{count}", "banner_waiting": "等待服务器的草稿:{count}" }, + "offlineExport": { + "settings_label": "导出已保存的邮件", + "settings_description": "将此设备上的邮件保存为 mbox 或 .eml 文件。离线时也可使用。", + "title": "导出已保存的邮件", + "description": "保存此设备保留的原件。.eml 文件是完全相同的副本。mbox 文件会加上其格式需要的标记,并把已读和标记状态写入每封邮件的邮件头,替换原有内容。早于所选时间范围的邮件不包含在内。要保存服务器上的全部内容,请使用“导出所有邮件”。", + "folders_label": "文件夹", + "all_folders": "所有文件夹", + "folder_kept": "此处 {kept} / {total}", + "loading": "正在读取此设备上的内容…", + "load_failed": "无法读取此设备上的邮件。", + "nothing_kept": "此设备上还没有保存邮件。", + "kept": "此设备上的原件:{kept} / {total} 封邮件", + "missing_online": "要从服务器下载:{count}", + "missing_offline": "离线时无法导出:{count}。联网后可以重试。", + "reason_attachments_off": "只有开启“保留附件”时才会保留原始邮件。", + "reason_not_kept": "原始邮件最后下载,且仅在有空间时下载。", + "not_kept_attachments_off": "此设备不保留附件,因此没有原件:{count}", + "not_kept_not_kept": "此设备上还没有原件:{count}", + "retry": "联网后重试", + "retry_online": "现在已联网,可以从服务器获取。", + "retry_offline": "联网后可以重试。", + "settings_button": "导出…", + "message_failed": "服务器没有提供这封邮件。", + "failed_generic": "导出因错误而停止。请重试。", + "stopped_limit": "导出在 {limit} 处停止,这是此浏览器能容纳的上限。请选择较少的文件夹,或使用 Chromium、Firefox 或桌面应用。", + "over_limit": "此浏览器最多能容纳 {limit},而此选择约为 {size}。请选择较少的文件夹,或使用 Chromium、Firefox 或桌面应用。", + "no_room": "此次导出约需 {size},而此浏览器还剩 {free}。请选择较少的文件夹或释放空间。", + "stopped_no_room": "浏览器没有足够空间完成导出。请选择较少的文件夹或释放空间。", + "stopped_offline_off": "导出已停止,因为此账户不再在此设备上保留邮件。", + "policy_unknown": "尚不清楚贵组织对此账户的规则,因此导出暂时关闭。", + "leftover_file": "无法从保存位置移除文件 {name}。它不完整,您可以将其删除。" + }, "deep_link": { "message_not_found": "此邮件已不可用。", "thread_not_found": "此会话已不可用。", @@ -6728,6 +6767,53 @@ "revoke": "收回", "revoked": "已收回许可" }, + "desktopOs": { + "section_title": "桌面应用", + "section_description": "应用如何与你的电脑配合。", + "close_to_tray_label": "关闭时继续在托盘中运行", + "close_to_tray_description": "关闭窗口后,新邮件和提醒仍会到达。", + "start_at_login_label": "登录电脑时启动", + "start_at_login_description": "你登录时应用会自动启动。", + "start_minimized_label": "在托盘中启动", + "start_minimized_description": "登录时不打开窗口。请通过托盘图标打开。", + "attention_label": "有新邮件时闪烁任务栏", + "attention_mac_label": "有新邮件时让 Dock 图标跳动", + "attention_description": "仅在应用不在前台时。", + "quiet_hours_label": "免打扰时段", + "quiet_hours_description": "此时段没有通知和声音。邮件仍会到达。", + "quiet_hours_from": "从", + "quiet_hours_to": "到", + "show_shortcut_label": "使用键盘快捷键显示应用", + "show_shortcut_description": "{keys} 可从任何其他应用把此应用带到前台。", + "show_shortcut_taken": "其他应用正在使用 {keys}。请关闭该应用,或关闭此选项后重新打开。", + "default_app_label": "默认邮件应用", + "default_app_description": "用此应用打开邮件链接以及 .eml、.ics 和 .vcf 文件。", + "default_app_is_default": "此应用会打开你的邮件链接。", + "default_app_button": "选择默认应用…", + "new_mail": "新邮件", + "no_subject": "(无主题)", + "action_delete": "删除", + "copy_as_file": "复制为文件", + "copied": "文件已复制。你可以将其粘贴到文件夹中。", + "copy_failed": "无法复制文件。", + "reply_lost": "无法保留你输入的回复。请打开邮件并在其中回复。" + }, + "desktopReminders": { + "generic_title": "提醒", + "snooze": "{count} 分钟后再提醒", + "snooze_short": "{count} 分钟", + "open": "打开", + "join": "加入", + "missed_title": "{count, plural, other {# 条错过的提醒}}", + "missed_more": "还有 {count} 条", + "missed_show": "显示", + "missed_dialog_title": "错过的提醒", + "missed_dialog_description": "这些提醒在应用关闭、电脑睡眠或通知暂停时到期。", + "open_calendar": "打开日历", + "done": "完成", + "join_host": "加入 ({host})", + "join_named": "加入: {title}" + }, "desktop_policy": { "server_refused": "您的组织不允许在此设备上使用此服务器。", "plugin_refused": "您的组织不允许在此设备上使用此插件,因此它不会运行。其设置和数据会保留。", @@ -6736,5 +6822,30 @@ "plugins_off": "您的组织已在此设备上关闭插件。", "plugins_allowlist": "您的组织在此设备上只允许使用部分插件。", "off": "关闭" + }, + "desktopTelemetry": { + "section_title": "使用信息和崩溃报告", + "section_description": "两者在您开启之前都处于关闭状态,并且各有独立的开关。", + "telemetry_label": "发送使用信息", + "telemetry_description": "每天一次:一个随机 ID、应用版本、Node 版本、您的操作系统、应用已运行多久以及连接方式。使用自己的邮件服务器时,还包括已开启的功能、大致的帐户数量以及插件和主题的数量。绝不发送邮件、地址或服务器名称。", + "crash_label": "发送崩溃报告", + "crash_description": "应用中的错误会连同一个随机 ID、应用自身代码的文件名和经过清理的简短消息一起发送。如果应用本身崩溃,崩溃文件会留在这台电脑上,只有您发送时才会离开。", + "crash_memory": "崩溃文件是崩溃时应用部分内存的副本。Bulwark 会删除它找到的用户名、地址和登录令牌,但崩溃文件仍可能包含您邮件的文本。", + "instance_note": "在 Bulwark 服务器上,页面向服务器报告哪些内容由该服务器的管理员另行决定。", + "forced_off": "您的组织已关闭此功能。", + "url_label": "崩溃文件的去向", + "url_description": "接收崩溃文件的 https 地址。Bulwark 没有预设地址。", + "url_invalid": "请输入完整的 https 地址。", + "url_save": "保存", + "files_label": "这台电脑上的崩溃文件", + "files_description": "Bulwark 保留最新的文件,并删除超过一个月的文件。", + "files_empty": "没有崩溃文件。", + "file_size": "{size} KB", + "send": "发送崩溃报告…", + "delete_all": "全部删除", + "sent": "崩溃报告已发送。", + "send_no_consent": "请先开启崩溃报告。", + "send_no_url": "未设置地址,因此无处发送。请在上方输入一个。", + "send_failed": "无法发送崩溃报告。" } } diff --git a/public/sw.js b/public/sw.js index e34ec85ef..62a4a7b62 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1147,6 +1147,10 @@ function getReusableClientScore(state) { function buildClickUrl(data, reply) { if (!data) return `${BASE_PATH}/`; + if (data.kind === "calendar-reminder") { + // The page's address, with its language. Only ever a path on this origin. + return typeof data.url === "string" && /^\/(?!\/)/.test(data.url) ? data.url : `${BASE_PATH}/calendar`; + } const params = new URLSearchParams(); if (Number.isInteger(data.slot)) params.set("slot", String(data.slot)); if (typeof data.accountId === "string" && data.accountId) params.set("owner", data.accountId); diff --git a/stores/__tests__/auth-store-device-wipe.test.ts b/stores/__tests__/auth-store-device-wipe.test.ts index 1f1d9a00b..52c036b00 100644 --- a/stores/__tests__/auth-store-device-wipe.test.ts +++ b/stores/__tests__/auth-store-device-wipe.test.ts @@ -51,8 +51,9 @@ vi.mock('@/lib/offline/offline-mode', () => ({ const heard = vi.hoisted(() => [] as string[]); const certain = vi.hoisted(() => [] as boolean[]); +const kinds = vi.hoisted(() => [] as string[]); vi.mock('@/lib/offline/credentials-rejected', () => ({ - noteCredentialsRejected: (account: string, sure = true) => { heard.push(account); certain.push(sure); }, + noteCredentialsRejected: (account: string, sure = true, kind = 'refused') => { heard.push(account); certain.push(sure); kinds.push(kind); }, onCredentialsRejected: () => () => {}, })); @@ -97,6 +98,7 @@ beforeEach(async () => { order.length = 0; heard.length = 0; certain.length = 0; + kinds.length = 0; clients.made.length = 0; clients.connect = async () => {}; gate.run = async () => {}; @@ -189,14 +191,15 @@ describe('a login the server ended, at a start', () => { it.each([ ['a password login', 'basic', '/api/auth/session'], ['an OAuth login', 'oauth', '/api/auth/token'], - ])('the session limit is no refusal, for %s: no hook, no wait, and the account is only dropped', async (_name, mode, route) => { + ])('the session limit goes the same way, as what it is, for %s: nothing about the password', async (_name, mode, route) => { useOfflineStore.setState({ enabled: [ALICE] }); useAccountStore.setState({ accounts: [account(ALICE, 0, mode as 'basic' | 'oauth')] } as never); answer = (url, method) => (url.includes(route) && method === 'PUT' ? json(401, { error: 'session_expired', maxHours: 8 }) : null); await useAuthStore.getState().checkAuth(); await vi.waitFor(() => expect(useAccountStore.getState().getAccountById(ALICE)).toBeUndefined()); - expect(heard).toEqual([]); - expect(readRefusedLogins()).toEqual({}); + expect(heard).toEqual([ALICE]); + expect(kinds).toEqual(['session']); + expect(readRefusedLogins()[ALICE]).toMatchObject({ kind: 'session', certain: true }); expect(isEvicted(ALICE)).toBe(true); expect(useOfflineStore.getState().enabled).toEqual([ALICE]); }); @@ -239,11 +242,12 @@ describe('a login the server ended, at a switch', () => { expect(useOfflineStore.getState().enabled).toContain(BOB); }); - it('the session limit doesn\'t fire it and only drops the account', async () => { + it('the session limit fires it as what it is, and only drops the account', async () => { useOfflineStore.setState({ enabled: [ALICE, BOB] }); answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'session_expired', maxHours: 8 }) : null); await useAuthStore.getState().switchAccount(BOB); - expect(heard).toEqual([]); + expect(heard).toEqual([BOB]); + expect(kinds).toEqual(['session']); expect(isEvicted(BOB)).toBe(true); expect(useOfflineStore.getState().enabled).toContain(BOB); }); @@ -255,6 +259,33 @@ describe('a login the server ended, at a switch', () => { }); }); +describe('a refusal with another account signed in', () => { + beforeEach(() => { + useAccountStore.setState({ accounts: [account(ALICE, 0, 'oauth'), account(BOB, 1, 'oauth')], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ activeAccountId: ALICE, isAuthenticated: true }); + }); + + it('writes down who was there, so the note reaches them and nobody later', async () => { + useOfflineStore.setState({ enabled: [ALICE, BOB] }); + answer = (url, method) => (url.includes('/api/auth/token') && method === 'PUT' ? json(401, { error: 'invalid_grant' }) : null); + await useAuthStore.getState().refreshAccessToken({ accountId: BOB }); + expect(readRefusedLogins()[BOB]).toMatchObject({ seenBy: [ALICE], label: BOB, username: 'bob' }); + }); +}); + +describe('a login with no "Remember me", when the browser closed', () => { + it('still takes its data with it: that was the choice on a shared computer', async () => { + const reasons = await removalReasons(); + useAccountStore.setState({ accounts: [{ ...account(ALICE, 0, 'basic'), rememberMe: false }], activeAccountId: ALICE, defaultAccountId: ALICE } as never); + useAuthStore.setState({ activeAccountId: ALICE }); + useOfflineStore.setState({ enabled: [ALICE] }); + await useAuthStore.getState().checkAuth(); + await vi.waitFor(() => expect(reasons).toEqual(['explicit'])); + expect(readRefusedLogins()).toEqual({}); + expect(heard).toEqual([]); + }); +}); + describe('a password the server keeps refusing', () => { beforeEach(() => { useAccountStore.setState({ accounts: [account(ALICE, 0, 'basic')], activeAccountId: ALICE, defaultAccountId: ALICE } as never); diff --git a/stores/__tests__/auth-store-logout-hooks.test.ts b/stores/__tests__/auth-store-logout-hooks.test.ts index 5deaf2b14..663d0d575 100644 --- a/stores/__tests__/auth-store-logout-hooks.test.ts +++ b/stores/__tests__/auth-store-logout-hooks.test.ts @@ -33,6 +33,46 @@ describe('sign-out with plugins', () => { expect(after).toHaveBeenCalledWith({ accountId: 'all' }); }); + describe('crash files, which can hold the mail of whoever was signed in', () => { + afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; }); + const withBridge = (deleteCrashes?: () => Promise) => { + (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', privacy: { deleteCrashes } }; + }; + + it('go at sign-out', async () => { + const deleteCrashes = vi.fn().mockResolvedValue(undefined); + withBridge(deleteCrashes); + await useAuthStore.getState().logout(); + expect(deleteCrashes).toHaveBeenCalledTimes(1); + }); + + it('go when all accounts sign out', async () => { + const deleteCrashes = vi.fn().mockResolvedValue(undefined); + withBridge(deleteCrashes); + await useAuthStore.getState().logoutAll(); + expect(deleteCrashes).toHaveBeenCalled(); + }); + + it('go when switching to another account, and not before', () => { + const deleteCrashes = vi.fn().mockResolvedValue(undefined); + withBridge(deleteCrashes); + useAuthStore.setState({ isAuthenticated: true, activeAccountId: 'a1' }); + expect(deleteCrashes).not.toHaveBeenCalled(); + useAuthStore.setState({ activeAccountId: 'a2' }); + expect(deleteCrashes).toHaveBeenCalledTimes(1); + }); + + it('signing out works all the same on a shell without the call, or one that fails', async () => { + withBridge(undefined); + await useAuthStore.getState().logout(); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + useAuthStore.setState({ isAuthenticated: true }); + withBridge(vi.fn().mockRejectedValue(new Error('gone'))); + await useAuthStore.getState().logout(); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); + }); + describe('keys in the OS store', () => { afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; }); const withBridge = (clearGrants?: () => Promise) => { diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 8e5d6a6df..2250c9ec9 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -18,7 +18,7 @@ import { generateAccountId, MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } from '@/lib/browser-navigation'; import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; import { getDesktopBridge } from '@/lib/desktop/bridge'; -import { clearKeystoreGrants } from '@/lib/desktop/keystore-grants'; +import { clearDesktopCrashFiles, clearKeystoreGrants } from '@/lib/desktop/keystore-grants'; import { DESKTOP_CLIENT_ID_KEY } from '@/lib/desktop/browser-sign-in'; import { isEmbedded, notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager'; @@ -41,7 +41,7 @@ import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite'; import { withTokenLock } from '@/lib/auth/token-lock'; import { requestWindowSession, type SharedAccountSession } from '@/lib/popout/channel'; import { closePopoutWindow, isPopoutWindow, mainWindowStorage } from '@/lib/popout/window-role'; -import { noteCredentialsRejected } from '@/lib/offline/credentials-rejected'; +import { noteCredentialsRejected, type ForcedSignOutKind } from '@/lib/offline/credentials-rejected'; import { CREDENTIALS_REFUSED_CONFIRM_MS, markLoginRefused, wipeWorkWaiting } from '@/lib/devices/wipe-state'; import { markEvicted, readEvicted, useOfflineStore } from '@/lib/offline/state'; import { toAsciiEmail } from '@/lib/idn'; @@ -178,6 +178,8 @@ export interface LogoutOptions { * hidden, for the next sign-in or 30 days. */ keepLocalData?: boolean; + /** It's the organisation's idle or session limit that ended it: with `keepLocalData`, what lib/offline/device-wipe.ts is told. */ + sessionLimit?: boolean; } const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [ @@ -807,18 +809,27 @@ function holdClient(accountId: string, client: JMAPClient): void { } /** - * The server refused this login, and the data hides from here on. It waits for the - * new password, or goes after 30 days, or now if the organisation asks for that + * This login ended with nobody asking: the server refused it, or the organisation's + * idle or session limit ran out. The data hides from here on. It waits for the next + * sign-in, or goes after 30 days, or now if the organisation asks for that * (lib/devices/wipe-state.ts, lib/offline/device-wipe.ts). The usual cause is a * password changed on another device: nothing goes by itself, unsent mail included. - * `certain`: `invalid_grant`, or a 401 that lasted. Not one bad answer at a connect. + * `certain`: not one bad answer at a connect. */ -function noteRefusedLogin(accountId: string, certain: boolean): void { +function noteRefusedLogin(accountId: string, certain: boolean, kind: ForcedSignOutKind = 'refused'): void { if (useOfflineStore.getState().enabled.includes(accountId)) { - markLoginRefused(accountId, certain); + const { accounts } = useAccountStore.getState(); + const account = accounts.find((entry) => entry.id === accountId); + markLoginRefused(accountId, { + certain, + kind, + seenBy: accounts.filter((entry) => entry.id !== accountId).map((entry) => entry.id), + label: account?.email || account?.username, + username: account?.username, + }); markEvicted(accountId); } - noteCredentialsRejected(accountId, certain); + noteCredentialsRejected(accountId, certain, kind); } /** @@ -1317,17 +1328,19 @@ function recordPermanentRefreshFailure(status: number, accountId?: string): bool } /** - * Whether the server refused the login itself: invalid_grant. That signs out and - * hides the data (noteRefusedLogin). Anything else keeps it the same way with no - * word to anyone, including a body we can't read and the organisation's session - * limit, which says the session is old and nothing about the password. + * What a refused renewal says about the login: the server refused it + * (invalid_grant), or the organisation's session limit was reached, which says + * nothing about the password. Both sign out and hide the data (noteRefusedLogin). + * Anything else keeps it the same way with no word to anyone, including a body + * we can't read. */ -async function loginRefused(res: Response): Promise { +async function forcedSignOutOf(res: Response): Promise { try { const { error } = await res.clone().json() as { error?: unknown }; - return error === 'invalid_grant'; + if (error === 'invalid_grant') return 'refused'; + return error === SESSION_TOO_OLD ? 'session' : null; } catch { - return false; + return null; } } @@ -2400,15 +2413,15 @@ export const useAuthStore = create()( // maintenance windows and offline spells. if (res.status === 401) { resetRefreshBackoff(accountId ?? undefined); - const refused = await loginRefused(res); + const forced = await forcedSignOutOf(res); // Only this login's refresh token was refused. if (accountId && !isActive) { - if (refused) noteRefusedLogin(accountId, true); + if (forced) noteRefusedLogin(accountId, true, forced); get().removeAccount(accountId, 'evicted'); return null; } await saveDraftsBeforeExpiry(slot); - if (refused && accountId) noteRefusedLogin(accountId, true); + if (forced && accountId) noteRefusedLogin(accountId, true, forced); notifyParent('sso:session-expired'); markSessionExpired(); get().logout({ endProviderSession: false, keepLocalData: true }); @@ -2531,6 +2544,7 @@ export const useAuthStore = create()( useSettingsStore.getState().disableSync(); clearKeystoreGrants(); + clearDesktopCrashFiles(); void authHooks.onAfterLogout.emit({ accountId: accountId ?? 'all', }); @@ -2671,7 +2685,12 @@ export const useAuthStore = create()( logoutAll: async (options) => { if (options?.endProviderSession !== false && !(await passSignOutGates('all'))) return; - await beforeLogoutHooks('all'); + const keepLocalData = options?.keepLocalData === true; + // Before anything goes, while the accounts are still there to be named. + if (options?.sessionLimit) { + for (const account of useAccountStore.getState().accounts) noteRefusedLogin(account.id, true, 'session'); + } + await beforeLogoutHooks('all', { keepLocalStore: keepLocalData }); const accountStore = useAccountStore.getState(); const allAccounts = [...accountStore.accounts]; @@ -2698,14 +2717,15 @@ export const useAuthStore = create()( // Credentials are cleared before the page state - see logout(). const endSessionUrl = await cleanup; - performFullLogout(set); + performFullLogout(set, { keepLocalStore: keepLocalData }); // Clear all accounts from registry for (const account of allAccounts) { - accountStore.removeAccount(account.id); + accountStore.removeAccount(account.id, keepLocalData ? 'evicted' : 'explicit'); } clearKeystoreGrants(); + clearDesktopCrashFiles(); void authHooks.onAfterLogout.emit({ accountId: 'all' }); @@ -2741,7 +2761,7 @@ export const useAuthStore = create()( let targetClient = clients.get(accountId); const wasConnected = !!targetClient; let targetRestoreRateLimited = false; - let targetRefused = false; + let targetForced: ForcedSignOutKind | null = null; if (!targetClient) { // Null out the client immediately so the page doesn't fire data-loading @@ -2758,7 +2778,7 @@ export const useAuthStore = create()( try { if (targetAccount.authMode === 'oauth') { const res = await fetchSlotAccessToken(targetAccount.cookieSlot, { remember: targetAccount.rememberMe }); - if (!res.ok && res.status === 401) targetRefused = await loginRefused(res); + if (!res.ok && res.status === 401) targetForced = await forcedSignOutOf(res); if (res.ok) { const { access_token, expires_in } = await res.json(); targetClient = JMAPClient.withBearer(targetAccount.serverUrl, access_token, targetAccount.username, () => get().refreshAccessToken({ accountId })); @@ -2775,7 +2795,7 @@ export const useAuthStore = create()( } } else if (targetAccount.rememberMe) { const res = await fetchSlotSession(targetAccount.cookieSlot); - if (!res.ok && res.status === 401) targetRefused = await loginRefused(res); + if (!res.ok && res.status === 401) targetForced = await forcedSignOutOf(res); if (res.ok) { const session: RememberedSession = await res.json(); targetClient = clientForSession(session); @@ -2827,7 +2847,8 @@ export const useAuthStore = create()( } // Cannot restore - remove the stale account and redirect to login - if (targetRefused || refusedAtConnect.has(accountId)) noteRefusedLogin(accountId, targetRefused); + if (targetForced) noteRefusedLogin(accountId, true, targetForced); + else if (refusedAtConnect.has(accountId)) noteRefusedLogin(accountId, false); refusedAtConnect.delete(accountId); evictAccount(accountId); accountStore.removeAccount(accountId, 'evicted'); @@ -3092,7 +3113,7 @@ export const useAuthStore = create()( // Repeated 500/502 (see recordPermanentRefreshFailure) falls // through here and evicts the account like a rejection. (#972) if (res.status >= 500) notifySignInAgain(); - throw Object.assign(new Error(`Token refresh failed: ${res.status}`), { refused: await loginRefused(res) }); + throw Object.assign(new Error(`Token refresh failed: ${res.status}`), { forced: await forcedSignOutOf(res) }); } } else { const res = await fetchSlotSession(account.cookieSlot); @@ -3113,7 +3134,7 @@ export const useAuthStore = create()( } else if (res.status >= 500 || res.status === 429) { throw new TransientAuthError('Session restore failed', res.status); } else { - throw Object.assign(new Error(`Session cookie missing: ${res.status}`), { refused: await loginRefused(res) }); + throw Object.assign(new Error(`Session cookie missing: ${res.status}`), { forced: await forcedSignOutOf(res) }); } } } catch (err) { @@ -3151,8 +3172,9 @@ export const useAuthStore = create()( // again rather than seeing a stale error entry forever. Its // refresh token goes with it: nothing would use or revoke it. Its // local data waits. - const refused = (err as { refused?: boolean }).refused === true; - if (refused || refusedAtConnect.has(account.id)) noteRefusedLogin(account.id, refused); + const { forced } = err as { forced?: ForcedSignOutKind | null }; + if (forced) noteRefusedLogin(account.id, true, forced); + else if (refusedAtConnect.has(account.id)) noteRefusedLogin(account.id, false); refusedAtConnect.delete(account.id); evictAccount(account.id); accountStore.removeAccount(account.id, 'evicted'); @@ -3852,6 +3874,7 @@ useAuthStore.subscribe((state, prev) => { if (ready && !wasReady) void authHooks.onAuthReady.emit({ accountId: state.activeAccountId }); if (state.activeAccountId && prev.activeAccountId && state.activeAccountId !== prev.activeAccountId) { clearKeystoreGrants(); + clearDesktopCrashFiles(); void authHooks.onAccountSwitch.emit({ accountId: state.activeAccountId, previousAccountId: prev.activeAccountId }); } }); diff --git a/stores/calendar-notification-store.ts b/stores/calendar-notification-store.ts index da0a266ea..8e7d41983 100644 --- a/stores/calendar-notification-store.ts +++ b/stores/calendar-notification-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { clearClaims } from '@/lib/calendar-reminders/claims'; const RETENTION_THRESHOLD_MS = 24 * 60 * 60 * 1000; @@ -37,6 +38,7 @@ export const useCalendarNotificationStore = create()( clearAll: () => { set({ acknowledgedAlerts: {} }); + clearClaims(); }, }), { diff --git a/stores/mail-transfer-store.ts b/stores/mail-transfer-store.ts index e5ca8c792..79978fe50 100644 --- a/stores/mail-transfer-store.ts +++ b/stores/mail-transfer-store.ts @@ -22,7 +22,13 @@ export interface ExportRequest { mailboxId?: string; } -export type TransferRequest = ImportRequest | ExportRequest; +/** Mail kept on this device, of a signed-in account, not only the active one. */ +export interface LocalExportRequest { + kind: "local-export"; + account: string; +} + +export type TransferRequest = ImportRequest | ExportRequest | LocalExportRequest; interface JobBase { controller: AbortController; @@ -72,6 +78,7 @@ export const useMailTransferStore = create()((set, get) => ({ // The device policy "No export" of the active account (Organisation > // Devices): the dialog doesn't open and the app says why. Import stays. if (request.kind === "export" && !downloadAllowed("export", null)) return false; + if (request.kind === "local-export" && !downloadAllowed("export", request.account)) return false; if (isJobActive(get().job)) { set({ visible: true }); return false; @@ -103,6 +110,11 @@ export function openMailImport(request: Omit = {}): void useMailTransferStore.getState().open({ kind: "import", ...request }); } +/** Opens the export of what an account keeps on this device. */ +export function openLocalExport(account: string): void { + useMailTransferStore.getState().open({ kind: "local-export", account }); +} + /** Opens the export dialog for a folder, or for the whole account. */ export function openMailExport(request: Omit = {}): void { useMailTransferStore.getState().open({ kind: "export", ...request });