diff --git a/components/calendar/__tests__/calendar-dialogs.test.tsx b/components/calendar/__tests__/calendar-dialogs.test.tsx index 4b408c769..8800bf806 100644 --- a/components/calendar/__tests__/calendar-dialogs.test.tsx +++ b/components/calendar/__tests__/calendar-dialogs.test.tsx @@ -79,6 +79,7 @@ describe('import preview', () => { const parsing = { getAccountId: () => 'acct', getCalendarsAccountId: () => 'acct', + getServerUrl: () => 'https://mail.example.com', uploadBlob: vi.fn().mockResolvedValue({ blobId: 'b1' }), parseCalendarEvents: vi.fn().mockResolvedValue([weekly]), } as unknown as IJMAPClient; diff --git a/components/email/__tests__/composer-draft-login.test.tsx b/components/email/__tests__/composer-draft-login.test.tsx index 0e8ef5923..7f6e1a4b3 100644 --- a/components/email/__tests__/composer-draft-login.test.tsx +++ b/components/email/__tests__/composer-draft-login.test.tsx @@ -4,6 +4,12 @@ import React from 'react'; import { EmailComposer, type ComposerDraftData } from '../email-composer'; import { useAuthStore } from '@/stores/auth-store'; +const offlineQueue = vi.hoisted(() => ({ noteDraftReplacedElsewhere: vi.fn(async () => {}) })); +vi.mock('@/lib/offline/queue', async (importOriginal) => ({ + ...(await importOriginal()), + noteDraftReplacedElsewhere: offlineQueue.noteDraftReplacedElsewhere, +})); + // ─── Heavy component mocks (mirrors composer-close-guard.test.tsx) ──────────── vi.mock('@/components/email/rich-text-editor', () => ({ @@ -293,6 +299,19 @@ describe('a draft and the login it was saved with', () => { expect(onDiscardDraft).toHaveBeenCalledWith('draft-b2', { accountId: undefined, localAccountId: 'login-b' }); }); + it('leaves the old version in its own login while the new one is only on the device', async () => { + const composer = open(DRAFT); + await composer.editSubject('Hello', 'Hello 2'); + (clientB.createDraft as ReturnType).mockReset(); + (clientB.createDraft as ReturnType).mockResolvedValue('local-standin'); + composer.switchFrom('login-b::id-b'); + await composer.editSubject('Hello 2', 'Hello 3'); + + expect(clientB.createDraft).toHaveBeenCalledTimes(1); + expect(clientA.deleteEmail).not.toHaveBeenCalled(); + expect(offlineQueue.noteDraftReplacedElsewhere).toHaveBeenCalledWith('login-b', 'local-standin', { login: 'login-a', jmap: undefined, id: 'draft-a2' }); + }); + it('keeps replacing versions through createDraft while the login stays the same', async () => { const composer = open({ ...DRAFT, draftLocalAccountId: 'login-a' }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 06ce32889..44a474b99 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -90,6 +90,8 @@ import { FilePickerDialog } from "@/components/files/file-picker-dialog"; import { DirectoryPickerDialog } from "@/components/contacts/directory-picker-dialog"; import type { DirectoryEntry } from "@/stores/contact-store"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import { isStandInId } from "@/lib/jmap/stand-in-ids"; +import { noteDraftReplacedElsewhere } from "@/lib/offline/queue"; import type { FileNode } from "@/lib/jmap/types"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { renderTemplateWithPlugins, notifyTemplateApplied } from "@/stores/template-store"; @@ -560,6 +562,7 @@ export function EmailComposer({ const tViewer = useTranslations('email_viewer'); const tClassification = useTranslations('classification'); const tProofreading = useTranslations('proofreading'); + const tOfflineDrafts = useTranslations('offlineDrafts'); const tOffline = useTranslations('offline'); const tOfflineNotes = useTranslations('offlineConflicts'); const tSendGuards = useTranslations('send_guards'); @@ -1050,6 +1053,8 @@ export function EmailComposer({ const [draftLocalAccountId, setDraftLocalAccountId] = useState(initialData?.draftLocalAccountId); const draftLocalAccountIdRef = useRef(initialData?.draftLocalAccountId); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); + // The last save stayed on this device: the server wasn't there. + const [savedOnDevice, setSavedOnDevice] = useState(false); const saveTimeoutRef = useRef(null); const saveCloseAttemptRef = useRef<{ cancelled: boolean; saving: boolean } | null>(null); const [isSavingAndClosing, setIsSavingAndClosing] = useState(false); @@ -2998,7 +3003,12 @@ export function EmailComposer({ { priority, accountId: draftAccountId, previousDraftAccountId: replacedElsewhere ? undefined : previousDraftAccountId, classification: classificationRef.current.draftChoice }, ); if (replacedElsewhere) { - await removeDraftVersion(savedDraft.draftId!, previousDraftLogin, previousDraftAccountId); + if (isStandInId(savedDraftId) && savingLogin) { + // The new version is only on this device: the old one stays until it's up. + await noteDraftReplacedElsewhere(savingLogin, savedDraftId, { login: previousDraftLogin!, jmap: previousDraftAccountId, id: savedDraft.draftId! }); + } else { + await removeDraftVersion(savedDraft.draftId!, previousDraftLogin, previousDraftAccountId); + } } // Update the ref synchronously so a queued save sees the new id and @@ -3050,6 +3060,7 @@ export function EmailComposer({ } } + setSavedOnDevice(isStandInId(savedDraftId)); setSaveStatus('saved'); saveFailedRef.current = false; lastDraftIdRef.current = savedDraftId; @@ -4671,7 +4682,7 @@ export function EmailComposer({ {saveStatus === 'saved' && (
- {t('draft_saved')} + {savedOnDevice ? tOfflineDrafts('saved_on_device') : t('draft_saved')}
)} {saveStatus === 'error' && ( diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index d475f4f2c..a87fb51aa 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -55,6 +55,7 @@ import { useAuthStore, redirectToLogin, saveRedirectAfterLogin, canEnterLoginInP import { useRouter } from '@/i18n/navigation'; import { useSettingsStore } from "@/stores/settings-store"; import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout"; +import { useOfflineStore } from "@/lib/offline/state"; import { useContactStore } from "@/stores/contact-store"; import { useIdentityStore } from "@/stores/identity-store"; import { useUIStore, SIDEBAR_MIN, SIDEBAR_MAX, EMAIL_LIST_MIN, EMAIL_LIST_MAX, EMAIL_LIST_HEIGHT_MIN, EMAIL_LIST_HEIGHT_MAX } from "@/stores/ui-store"; @@ -2704,6 +2705,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { console.error('Cancelled the scheduled send, but opening the draft failed:', error); }; + // A draft that's only on this device, opened from the outbox: the server + // doesn't list it, so nothing else can open it while online. + const draftToOpen = useOfflineStore((s) => s.draftToOpen); + useEffect(() => { + if (!draftToOpen || !client) return; + useOfflineStore.setState({ draftToOpen: null }); + const draftClient = useAuthStore.getState().getClientForAccount(draftToOpen.account) ?? client; + void draftClient.getEmail(draftToOpen.id, draftToOpen.jmap) + .then((draft) => (draft ? handleEditDraft(draft, { accountId: draftToOpen.jmap, localAccountId: draftToOpen.account }) : undefined)) + .catch((error) => console.error('Opening a draft kept on this device failed:', error)); + // eslint-disable-next-line react-hooks/exhaustive-deps -- runs when the outbox asks, with the handler of that render + }, [draftToOpen, client]); + /** * "Cancel and edit" from the list or the viewer, neither of which waits for * it: the draft to open (null for S/MIME, which is composed again), or diff --git a/components/offline/__tests__/offline-settings.test.tsx b/components/offline/__tests__/offline-settings.test.tsx index 5a9e35962..98a6b24f1 100644 --- a/components/offline/__tests__/offline-settings.test.tsx +++ b/components/offline/__tests__/offline-settings.test.tsx @@ -78,6 +78,30 @@ describe('Settings > Offline', () => { expect(screen.queryByTestId('offline-switch')).toBeNull(); }); + it('asks before turning off when a draft is only on this device, and counts both lists', () => { + useOfflineStore.setState({ + enabled: ['acc'], + localDrafts: [{ account: 'acc', localId: 'local-1', jmap: 'j', subject: 'Only here', status: 'waiting', createdAt: 0 }], + outbox: [{ id: 1, account: 'acc', createdAt: 0, subject: 'Queued', to: [], status: 'waiting' }], + }); + render(); + fireEvent.click(screen.getByTestId('offline-switch')); + expect(offline.disableOffline).not.toHaveBeenCalled(); + expect(screen.getByText('off_confirm_title')).toBeInTheDocument(); + useOfflineStore.setState({ localDrafts: [], outbox: [] }); + }); + + it('asks for one draft alone', () => { + useOfflineStore.setState({ + enabled: ['acc'], + localDrafts: [{ account: 'acc', localId: 'local-1', jmap: 'j', subject: 'Only here', status: 'waiting', createdAt: 0 }], + }); + render(); + fireEvent.click(screen.getByTestId('offline-switch')); + expect(offline.disableOffline).not.toHaveBeenCalled(); + useOfflineStore.setState({ localDrafts: [] }); + }); + it('lists the outbox', () => { useOfflineStore.setState({ outbox: [{ id: 1, account: 'acc', createdAt: Date.now(), subject: 'Quarterly', to: ['b@example.org'], status: 'failed', error: { text: 'Rejected' } }] }); render(); diff --git a/components/offline/__tests__/outbox-drafts.test.tsx b/components/offline/__tests__/outbox-drafts.test.tsx new file mode 100644 index 000000000..498bb9546 --- /dev/null +++ b/components/offline/__tests__/outbox-drafts.test.tsx @@ -0,0 +1,125 @@ +import 'fake-indexeddb/auto'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api'; +import { useOfflineStore } from '@/lib/offline/state'; +import type { LocalDraftRow } from '@/lib/offline/queue'; +import { useAuthStore } from '@/stores/auth-store'; +import { OutboxList } from '../outbox-list'; +import { OfflineBanner } from '../offline-banner'; + +const push = vi.fn(); +let pathname = '/settings/backup/offline'; + +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push }), + usePathname: () => pathname, +})); + +const draft = (overrides: Partial = {}): LocalDraftRow => ({ + account: 'acc', + localId: 'local-1', + jmap: 'j1', + subject: 'Only here', + status: 'waiting', + createdAt: Date.UTC(2026, 10, 3), + ...overrides, +}); + +let area: ReturnType; + +beforeEach(() => { + push.mockClear(); + pathname = '/settings/backup/offline'; + area = vi.fn(async (_account: string, name: string) => { + if (name === 'outbox.read') return { patches: 0, entries: [], conflicts: [] }; + if (name === 'drafts.list') return []; + return undefined; + }); + setStoreBackend({ supported: () => true, area } as unknown as StoreBackend); + useAuthStore.setState({ activeAccountId: 'acc' }); + useOfflineStore.setState({ enabled: ['acc'], outbox: [], pendingChanges: 0, conflicts: [], localDrafts: [], draftToOpen: null, browserOffline: false, downServers: [], startedOffline: false }); +}); + +afterEach(() => { + setStoreBackend(null); + useOfflineStore.setState({ enabled: [], conflicts: [], localDrafts: [], draftToOpen: null }); +}); + +describe('drafts that are only on this device', () => { + it('shows nothing when there are none', () => { + render(); + expect(screen.queryByTestId('offline-local-drafts')).toBeNull(); + }); + + it('shows a refused one with the server\'s reason, and a way to open and retry it', () => { + useOfflineStore.setState({ localDrafts: [draft({ status: 'refused', error: { type: 'overQuota', text: 'No room left' } })] }); + render(); + const row = screen.getByTestId('offline-local-draft-row'); + expect(row.getAttribute('data-status')).toBe('refused'); + expect(row.textContent).toContain('Only here'); + expect(row.textContent).toContain('status_refused'); + expect(screen.getByRole('button', { name: 'open_label' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'retry_label' })).toBeTruthy(); + }); + + it('offers no retry for one that waits by itself', () => { + useOfflineStore.setState({ localDrafts: [draft()] }); + render(); + expect(screen.getByTestId('offline-local-draft-row').textContent).toContain('status_waiting'); + expect(screen.queryByRole('button', { name: 'retry_label' })).toBeNull(); + }); + + it('opens it on the mail page, going there from elsewhere', () => { + useOfflineStore.setState({ localDrafts: [draft()] }); + const onOpenDraft = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'open_label' })); + expect(useOfflineStore.getState().draftToOpen).toEqual({ account: 'acc', jmap: 'j1', id: 'local-1' }); + expect(onOpenDraft).toHaveBeenCalled(); + expect(push).toHaveBeenCalledWith('/mail'); + }); + + it('stays on the mail page when it\'s already there', () => { + pathname = '/mail'; + useOfflineStore.setState({ localDrafts: [draft()] }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'open_label' })); + expect(push).not.toHaveBeenCalled(); + }); + + it('asks the store to try again, and reads the queue', async () => { + useOfflineStore.setState({ localDrafts: [draft({ status: 'refused' })] }); + render(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'retry_label' })); + }); + expect(area).toHaveBeenCalledWith('acc', 'drafts.retry', { localId: 'local-1' }); + expect(area).toHaveBeenCalledWith('acc', 'drafts.list'); + expect(useOfflineStore.getState().localDrafts).toEqual([]); + }); + + it('says both versions are kept for a draft changed elsewhere', () => { + useOfflineStore.setState({ + conflicts: [ + { id: 1, account: 'acc', recordId: 'r', createdAt: 0, summary: { subject: 'A', op: 'draft', reason: 'changed' } }, + { id: 2, account: 'acc', recordId: 's', createdAt: 0, summary: { subject: 'B', op: 'draft', reason: 'gone' } }, + ], + }); + render(); + const rows = screen.getAllByTestId('offline-conflict-row'); + expect(rows[0].textContent).toContain('conflict_changed'); + expect(rows[1].textContent).toContain('conflict_gone'); + }); + + it('says in the banner while online that drafts wait, and which were refused', () => { + useOfflineStore.setState({ localDrafts: [draft()] }); + const { rerender } = render(); + expect(screen.queryByTestId('offline-banner-drafts')).toBeNull(); + expect(screen.getByTestId('offline-banner-drafts-waiting').textContent).toContain('banner_waiting'); + act(() => useOfflineStore.setState({ localDrafts: [draft({ status: 'refused' })] })); + rerender(); + expect(screen.getByTestId('offline-banner-drafts').textContent).toContain('banner_refused'); + expect(screen.queryByTestId('offline-banner-drafts-waiting')).toBeNull(); + }); +}); diff --git a/components/offline/offline-banner.tsx b/components/offline/offline-banner.tsx index 2174bfb43..7706303ea 100644 --- a/components/offline/offline-banner.tsx +++ b/components/offline/offline-banner.tsx @@ -25,10 +25,13 @@ export function OfflineBanner() { const serverDown = useOfflineStore((s) => !!server && s.downServers.includes(serverKey(server))); const waiting = useOfflineStore((s) => s.outbox.length); const conflicts = useOfflineStore((s) => s.conflicts.length + s.areaConflicts.length); + const refusedDrafts = useOfflineStore((s) => s.localDrafts.filter((draft) => draft.status === 'refused').length); + const waitingDrafts = useOfflineStore((s) => s.localDrafts.filter((draft) => draft.status !== 'refused').length); const tConflicts = useTranslations('offlineConflicts'); + const tDrafts = useTranslations('offlineDrafts'); const [open, setOpen] = useState(false); const offline = browserOffline || serverDown; - if (IS_LITE || !active || (!offline && waiting === 0 && conflicts === 0 && !open)) return null; + if (IS_LITE || !active || (!offline && waiting === 0 && conflicts === 0 && refusedDrafts === 0 && waitingDrafts === 0 && !open)) return null; return ( <>
)} + {waitingDrafts > 0 && ( + + )} + {refusedDrafts > 0 && ( + + )}
setOpen(false)} title={t('outbox.title')} testId="offline-outbox-dialog"> - + setOpen(false)} /> ); diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx index 23208b1d8..f86d066a3 100644 --- a/components/offline/offline-host.tsx +++ b/components/offline/offline-host.tsx @@ -30,6 +30,9 @@ export function OfflineHost() { const router = useRouter(); const routerRef = useRef(router); routerRef.current = router; + const tDrafts = useTranslations('offlineDrafts'); + const tDraftsRef = useRef(tDrafts); + tDraftsRef.current = tDrafts; const { dialogProps, confirm } = useConfirmDialog(); const confirmRef = useRef(confirm); confirmRef.current = confirm; @@ -59,6 +62,14 @@ export function OfflineHost() { case 'conflict': toast.warning(tConflictsRef.current('toast', { count: notice.count })); break; + case 'draft-refused': + toast.error(tDraftsRef.current('toast_refused', { subject: notice.subject || tr('outbox.no_subject') }), { + message: notice.error.code ? tDraftsRef.current(notice.error.code) : notice.error.text, + }); + break; + case 'draft-conflict': + toast.warning(tDraftsRef.current('toast_conflict', { subject: notice.subject || tr('outbox.no_subject') })); + break; case 'storage-failed': toast.warning(tStorageRef.current('storage_full_title'), { message: tStorageRef.current('storage_full_text'), duration: 0, action: openStorage }); break; @@ -70,7 +81,7 @@ export function OfflineHost() { break; case 'store-reset': toast.warning(tConflictsRef.current('store_reset'), { - message: notice.lost > 0 ? tStorageRef.current('evicted_lost', { count: notice.lost }) : undefined, + message: notice.lost ? tStorageRef.current('evicted_lost', { count: notice.lost }) : undefined, duration: 0, action: openStorage, }); diff --git a/components/offline/outbox-list.tsx b/components/offline/outbox-list.tsx index 37ef47d71..add7732e9 100644 --- a/components/offline/outbox-list.tsx +++ b/components/offline/outbox-list.tsx @@ -2,11 +2,12 @@ import { useState } from 'react'; import { useFormatter, useTranslations } from 'next-intl'; +import { usePathname, useRouter } from '@/i18n/navigation'; import { Button } from '@/components/ui/button'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { deleteOutboxEntry, retryOutboxEntry } from '@/lib/offline/flush'; import { dismissAreaConflict, refreshQueueState, scheduleFlushAll } from '@/lib/offline/offline-mode'; -import { dismissConflict, type ConflictEntry } from '@/lib/offline/queue'; +import { dismissConflict, retryLocalDraft, type ConflictEntry, type LocalDraftRow } from '@/lib/offline/queue'; import { isOfflineNow, useOfflineStore, type AreaConflict, type OutboxRow } from '@/lib/offline/state'; import { useAccountStore } from '@/stores/account-store'; @@ -16,13 +17,19 @@ import { useAccountStore } from '@/stores/account-store'; * be sent again or deleted; one whose connection broke on the way is never * sent again on its own (it may have gone out); one held because the * organisation's rules changed can only be deleted (and written again). - * Changes that didn't go through ("changed on another device") are listed too. + * Changes that didn't go through ("changed on another device") are listed too, + * and drafts that are only on this device: on their way up, or refused by the + * server, in which case they can still be opened and tried again. */ -export function OutboxList({ heading = true }: { heading?: boolean }) { +export function OutboxList({ heading = true, onOpenDraft }: { heading?: boolean; onOpenDraft?: () => void }) { const t = useTranslations('offline.outbox'); const tErrors = useTranslations('offline.errors'); const tConflicts = useTranslations('offlineConflicts'); const tStorage = useTranslations('offlineStorage'); + const tDrafts = useTranslations('offlineDrafts'); + const router = useRouter(); + const pathname = usePathname(); + const localDrafts = useOfflineStore((s) => s.localDrafts); const format = useFormatter(); const rows = useOfflineStore((s) => s.outbox); const changes = useOfflineStore((s) => s.pendingChanges); @@ -48,7 +55,29 @@ export function OutboxList({ heading = true }: { heading?: boolean }) { if (!isOfflineNow()) scheduleFlushAll(0); }; + const draftStatus = (draft: LocalDraftRow) => { + switch (draft.status) { + case 'refused': return tDrafts('status_refused', { error: draft.error?.code ? tDrafts(draft.error.code) : (draft.error?.text ?? '') }); + case 'sending': return tDrafts('status_sending'); + case 'uncertain': return tDrafts('status_uncertain'); + default: return tDrafts('status_waiting'); + } + }; + + const openDraft = (draft: LocalDraftRow) => { + useOfflineStore.setState({ draftToOpen: { account: draft.account, jmap: draft.jmap, id: draft.localId } }); + onOpenDraft?.(); + if (!pathname.startsWith('/mail')) router.push('/mail'); + }; + + const retryDraft = async (draft: LocalDraftRow) => { + await retryLocalDraft(draft.account, draft.localId); + await refreshQueueState(); + if (!isOfflineNow()) scheduleFlushAll(0); + }; + const conflictText = (conflict: ConflictEntry) => { + if (conflict.summary.op === 'draft') return tDrafts(conflict.summary.reason === 'gone' ? 'conflict_gone' : 'conflict_changed'); if (conflict.summary.reason === 'refused') return tConflicts('refused', { reason: conflict.summary.detail ?? '' }); if (conflict.summary.reason === 'gone') return tConflicts('gone'); return tConflicts(conflict.summary.op === 'destroy' ? 'changed_delete' : 'changed'); @@ -97,6 +126,29 @@ export function OutboxList({ heading = true }: { heading?: boolean }) { ))} )} + {localDrafts.length > 0 && ( +
+

{tDrafts('description')}

+
    + {localDrafts.map((draft) => ( +
  • +
    +

    {draft.subject || t('no_subject')}

    +

    + {draftStatus(draft)} · {format.dateTime(draft.createdAt, { dateStyle: 'short', timeStyle: 'short' })} +

    +
    +
    + + {draft.status === 'refused' && ( + + )} +
    +
  • + ))} +
+
+ )} {changes > 0 && (

{t('changes', { count: changes })}

)} diff --git a/components/settings/__tests__/desktop-keystore-settings.test.tsx b/components/settings/__tests__/desktop-keystore-settings.test.tsx new file mode 100644 index 000000000..3a48141cb --- /dev/null +++ b/components/settings/__tests__/desktop-keystore-settings.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DesktopKeystoreSettings } from '../desktop-keystore-settings'; + +// Taking back a server's use of the certificates on this computer. The stored yes lives +// in the shell; this row is the way out, and only shows when there is something to take back. + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() })); +vi.mock('@/stores/toast-store', () => ({ toast })); +vi.mock('../settings-section', async (importOriginal) => ({ + ...(await importOriginal()), + SettingsSection: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +function bridge(state: string) { + const revokeAccess = vi.fn(async () => {}); + (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = { + version: 1, + platform: 'win32', + keystore: { accessState: vi.fn(async () => state), revokeAccess }, + }; + return revokeAccess; +} + +afterEach(() => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + toast.success.mockClear(); +}); + +describe('the keystore access row', () => { + it('takes back a stored yes through the shell, and goes away', async () => { + const revoke = bridge('stored'); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'revoke' })); + await waitFor(() => expect(revoke).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.queryByRole('button', { name: 'revoke' })).toBeNull()); + expect(toast.success).toHaveBeenCalledWith('revoked'); + }); + + it.each(['off', 'policy', 'app'])('shows nothing when the state is %s', async (state) => { + bridge(state); + const { container } = render(); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(container.textContent).toBe(''); + }); + + it('shows nothing in a browser', async () => { + const { container } = render(); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(container.textContent).toBe(''); + }); +}); diff --git a/components/settings/desktop-keystore-settings.tsx b/components/settings/desktop-keystore-settings.tsx new file mode 100644 index 000000000..381f502ab --- /dev/null +++ b/components/settings/desktop-keystore-settings.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { toast } from "@/stores/toast-store"; +import { SettingsSection, SettingItem } from "./settings-section"; + +/** + * In the desktop app on an instance: the user's yes to this server using certificates on + * this computer, stored in the shell, and the way to take it back. Nothing shows in a + * browser, in standalone, or when an organisation's policy set it. + */ +export function DesktopKeystoreSettings() { + const t = useTranslations("desktop_keystore"); + const [stored, setStored] = useState(false); + + useEffect(() => { + let live = true; + window.bulwarkDesktop?.keystore?.accessState?.().then( + (state) => { if (live) setStored(state === "stored"); }, + () => {}, + ); + return () => { live = false; }; + }, []); + + if (!stored) return null; + + const revoke = async () => { + await window.bulwarkDesktop?.keystore?.revokeAccess(); + setStored(false); + toast.success(t("revoked")); + }; + + return ( + + + + + + ); +} diff --git a/components/settings/general-settings.tsx b/components/settings/general-settings.tsx index 41d2ba072..0a03272bf 100644 --- a/components/settings/general-settings.tsx +++ b/components/settings/general-settings.tsx @@ -4,6 +4,7 @@ import { useTranslations } from 'next-intl'; import { useSettingsStore } from '@/stores/settings-store'; import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; import { LanguageSettings } from './language-settings'; +import { DesktopKeystoreSettings } from './desktop-keystore-settings'; import { ProtocolHandlerSettings } from './protocol-handler-settings'; import { useTour } from '@/components/tour/tour-provider'; import { Button } from '@/components/ui/button'; @@ -25,6 +26,7 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea {!IS_LITE && } + diff --git a/integration/tests/22-ent-offline.spec.ts b/integration/tests/22-ent-offline.spec.ts index 8f3a65e29..bc24e6fe0 100644 --- a/integration/tests/22-ent-offline.spec.ts +++ b/integration/tests/22-ent-offline.spec.ts @@ -2,6 +2,7 @@ import { test, expect, type Browser, type BrowserContext, type Page } from '@pla import { execSync } from 'node:child_process'; import { ENT, + ENT_PASSWORD, adminContext, deliver, getPolicy, @@ -12,7 +13,7 @@ import { shot, userPage, } from './helpers/ent'; -import { accountSwitcher } from './helpers/app'; +import { accountSwitcher, openFolder } from './helpers/app'; import { JMAP_URL } from './helpers/config'; /** @@ -27,6 +28,9 @@ import { JMAP_URL } from './helpers/config'; * - a change the server can no longer take shows as changed on another device; * - the Trash emptied offline is emptied on the server after; * - a send whose response got lost is looked up and goes out once; + * - a draft written offline with a file survives a reload without a network, + * is on the server once after reconnecting, with the file, and can be sent; + * - a kept message shows its inline image and gives its attachment offline; * - a sign-out the user starts asks first when unsent mail would be lost, * then wipes the device; the idle sign-out wipes it and the login page * names the lost message; @@ -524,6 +528,258 @@ test('a send of which only the draft arrived is submitted, and goes out once', a }); }); +interface ServerDraft { id: string; subject: string; blobId: string; attachments: Array<{ blobId: string; name: string; size: number }> } + +/** + * The account's Drafts folder as the server has it, for one subject. Polled, on + * an account the app under test and other runs use too: the server's rate limit + * says nothing about the draft, so it waits that out. + */ +async function serverDrafts(account: string, subject: string): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + const client = await jmapAs(account); + const boxes = await client.request([['Mailbox/get', { accountId: client.accountId, properties: ['id', 'role'] }, '0']]); + const drafts = (boxes.methodResponses[0][1].list as Array<{ id: string; role: string | null }>).find((box) => box.role === 'drafts'); + const r = await client.request([ + ['Email/query', { accountId: client.accountId, filter: { inMailbox: drafts?.id }, limit: 200 }, '0'], + ['Email/get', { accountId: client.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject', 'blobId', 'attachments'] }, '1'], + ]); + return (r.methodResponses[1][1].list as ServerDraft[]).filter((email) => email.subject === subject); + } catch (error) { + if (attempt >= 6 || !/\b429\b/.test(String(error))) throw error; + await new Promise((resolve) => setTimeout(resolve, 10_000)); + } + } +} + +test('a draft written offline with a file survives a reload, is on the server once after reconnecting, and can be sent', async ({ browser }) => { + const subject = `Offline draft ${RUN}`; + const fileText = `Attached offline ${RUN}`; + const page = await userPage(browser, ENT.alice); + await enableOffline(page); + await toMail(page); + const context = page.context(); + await context.setOffline(true); + + await compose(page, ENT.bob, subject, `First words ${RUN}`); + const composer = page.locator('[data-testid="email-composer"]'); + await composer.locator('input[type="file"]').first().setInputFiles({ name: 'offline-note.txt', mimeType: 'text/plain', buffer: Buffer.from(fileText) }); + await expect(composer).toContainText('offline-note.txt', { timeout: 15_000 }); + await expect(composer.getByText('Saved on this device').first()).toBeVisible({ timeout: 30_000 }); + await shot(page, 'f-offline-draft'); + // Saved again and again: still one draft in the end. + for (const word of [' second', ' third', ' last']) { + await composer.locator('[contenteditable="true"]').first().click(); + await page.keyboard.press('Control+End'); + await page.keyboard.type(word); + await expect(composer.getByTestId('composer-save-status').first()).toHaveAttribute('data-status', 'saved', { timeout: 30_000 }); + await page.waitForTimeout(2500); + } + await page.getByTestId('composer-close').first().click(); + await expect(composer).toBeHidden({ timeout: 30_000 }); + expect(await serverDrafts(ENT.alice, subject)).toHaveLength(0); + + // A start without a network: it's in Drafts, with its text and its file. + await page.reload(); + await expect(page.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); + await openFolder(page, { role: 'drafts' }); + const row = page.locator('[data-testid="email-list-item"], [data-thread-keys]', { hasText: subject }).first(); + await expect(row).toBeVisible({ timeout: 60_000 }); + await row.click(); + await page.getByTestId('edit-draft').click(); + await expect(composer).toBeVisible({ timeout: 20_000 }); + await expect(composer.locator('[contenteditable="true"]').first()).toContainText('last', { timeout: 20_000 }); + await expect(composer).toContainText('offline-note.txt'); + await page.getByTestId('composer-close').first().click(); + await expect(composer).toBeHidden({ timeout: 30_000 }); + expect(await serverDrafts(ENT.alice, subject)).toHaveLength(0); + + // Back online: one draft, with the latest text and the file. + await context.setOffline(false); + await expect.poll(async () => (await serverDrafts(ENT.alice, subject)).length, { timeout: 180_000, intervals: [2000] }).toBe(1); + await page.waitForTimeout(5000); + const [draft] = await serverDrafts(ENT.alice, subject); + expect(await serverDrafts(ENT.alice, subject)).toHaveLength(1); + expect(draft.attachments.map((part) => part.name)).toEqual(['offline-note.txt']); + expect(await rawMessage(ENT.alice, draft.attachments[0].blobId)).toContain(fileText); + expect(await rawMessage(ENT.alice, draft.blobId)).toContain('last'); + + // And it goes out from there, with the file. + await page.goto('/mail'); + await expect(page.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 }); + await openFolder(page, { role: 'drafts' }); + const again = page.locator('[data-testid="email-list-item"], [data-thread-keys]', { hasText: subject }).first(); + await expect(again).toBeVisible({ timeout: 60_000 }); + await again.click(); + await page.getByTestId('edit-draft').click(); + await expect(composer).toContainText('offline-note.txt', { timeout: 20_000 }); + await send(page); + await expect(composer).toBeHidden({ timeout: 30_000 }); + const got = await waitForMessage(ENT.bob, (m) => m.subject === subject); + expect(await rawMessage(ENT.bob, got.blobId)).toContain('offline-note.txt'); + await expect.poll(async () => (await serverDrafts(ENT.alice, subject)).length, { timeout: 30_000 }).toBe(0); + await context.close(); +}); + +test('an open composer keeps its file through a flush: the next save and the send still have it', async ({ browser }) => { + const subject = `Offline open composer ${RUN}`; + const fileText = `Stays attached ${RUN}`; + const page = await userPage(browser, ENT.alice); + await enableOffline(page); + await toMail(page); + const context = page.context(); + + // Saved online, so the composer holds a part of that message. + await compose(page, ENT.bob, subject, `Online words ${RUN}`); + const composer = page.locator('[data-testid="email-composer"]'); + await composer.locator('input[type="file"]').first().setInputFiles({ name: 'stays.txt', mimeType: 'text/plain', buffer: Buffer.from(fileText) }); + await expect(composer).toContainText('stays.txt', { timeout: 15_000 }); + await expect.poll(async () => (await serverDrafts(ENT.alice, subject)).length, { timeout: 30_000 }).toBe(1); + await expect.poll(async () => (await serverDrafts(ENT.alice, subject))[0]?.attachments.length ?? 0, { timeout: 30_000 }).toBe(1); + const before = (await serverDrafts(ENT.alice, subject))[0]; + await page.waitForTimeout(2500); + + // The server goes, and this edit stays on the device. + await blockMailServer(context); + await composer.locator('[contenteditable="true"]').first().click(); + await page.keyboard.press('Control+End'); + await page.keyboard.type(' offline edit'); + await expect(composer.getByText('Saved on this device').first()).toBeVisible({ timeout: 60_000 }); + expect(await serverDrafts(ENT.alice, subject)).toHaveLength(1); + + // Back: the new version replaces the old one, and the old one's part dies with it. + await context.unroute(`${new URL(JMAP_URL).origin}/**`); + await expect.poll(async () => (await serverDrafts(ENT.alice, subject)).map((draft) => draft.id).join(), { timeout: 180_000, intervals: [2000] }).not.toBe(before.id); + await expect.poll(async () => (await serverDrafts(ENT.alice, subject)).length, { timeout: 60_000 }).toBe(1); + + // The composer never closed. Saving again works, with the file. + await composer.locator('[contenteditable="true"]').first().click(); + await page.keyboard.press('Control+End'); + await page.keyboard.type(' later edit'); + await expect(composer.getByTestId('composer-save-status').first()).toHaveAttribute('data-status', 'saved', { timeout: 30_000 }); + await expect.poll(async () => { + const [draft] = await serverDrafts(ENT.alice, subject); + return draft ? (await rawMessage(ENT.alice, draft.blobId)).includes('later edit') : false; + }, { timeout: 30_000 }).toBe(true); + const [saved] = await serverDrafts(ENT.alice, subject); + expect(await rawMessage(ENT.alice, saved.attachments[0].blobId)).toContain(fileText); + + await send(page); + await expect(composer).toBeHidden({ timeout: 30_000 }); + const got = await waitForMessage(ENT.bob, (m) => m.subject === subject); + expect(await rawMessage(ENT.bob, got.blobId)).toContain('stays.txt'); + await expect.poll(async () => (await serverDrafts(ENT.alice, subject)).length, { timeout: 30_000 }).toBe(0); + await context.close(); +}); + +test('a draft whose answer got lost is on the server once, with the latest text', async ({ browser }) => { + const subject = `Offline draft lost answer ${RUN}`; + const page = await userPage(browser, ENT.alice); + await enableOffline(page); + await toMail(page); + const context = page.context(); + const origin = `${new URL(JMAP_URL).origin}/**`; + await blockMailServer(context); + await waitUntilOffline(page); + await compose(page, ENT.bob, subject, `Only one copy ${RUN}`); + const composer = page.locator('[data-testid="email-composer"]'); + await expect(composer.getByText('Saved on this device').first()).toBeVisible({ timeout: 30_000 }); + await page.getByTestId('composer-close').first().click(); + await expect(composer).toBeHidden({ timeout: 30_000 }); + + // The server comes back and takes the draft, but its answer never arrives. + let lost = 0; + await context.unroute(origin); + await context.route(origin, async (route) => { + const body = route.request().postData() ?? ''; + if (lost === 0 && body.includes('Email/set') && body.includes(subject)) { + lost += 1; + await route.fetch(); + await route.abort('connectionreset'); + return; + } + await route.continue(); + }); + await expect.poll(() => lost, { timeout: 180_000 }).toBe(1); + await expect.poll(async () => (await serverDrafts(ENT.alice, subject)).length, { timeout: 30_000 }).toBe(1); + // Looked up by its Message-ID and found, on this server's own figures: no second copy. + await expect(page.getByTestId('offline-banner-drafts-waiting')).toHaveCount(0, { timeout: 180_000 }); + await page.waitForTimeout(5000); + expect(await serverDrafts(ENT.alice, subject)).toHaveLength(1); + await context.close(); +}); + +const ONE_PIXEL = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAEAQH/7yMK/gAAAABJRU5ErkJggg==', 'base64'); + +async function uploadAs(account: string, data: Buffer, type: string): Promise { + const client = await jmapAs(account); + const res = await fetch(`${JMAP_URL}/jmap/upload/${client.accountId}/`, { + method: 'POST', + headers: { 'Content-Type': type, Authorization: 'Basic ' + Buffer.from(`${account}:${ENT_PASSWORD}`).toString('base64') }, + body: new Uint8Array(data), + }); + if (!res.ok) throw new Error(`upload: ${res.status}`); + return (await res.json() as { blobId: string }).blobId; +} + +test('a kept message shows its inline image and gives its attachment offline', async ({ browser }) => { + const subject = `Offline files ${RUN}`; + const fileText = `Kept attachment ${RUN}`; + const client = await jmapAs(ENT.alice); + const inbox = await client.mailboxByRole('inbox'); + const image = await uploadAs(ENT.alice, ONE_PIXEL, 'image/png'); + const file = await uploadAs(ENT.alice, Buffer.from(fileText), 'application/octet-stream'); + const made = await client.request([['Email/set', { + accountId: client.accountId, + create: { + m: { + mailboxIds: { [inbox!.id]: true }, + from: [{ email: ENT.bob }], + to: [{ email: ENT.alice }], + subject, + bodyValues: { t: { value: `Text ${RUN}` }, h: { value: `

Html ${RUN}

pixel` } }, + bodyStructure: { + type: 'multipart/mixed', + subParts: [ + { + type: 'multipart/alternative', + subParts: [ + { partId: 't', type: 'text/plain' }, + { type: 'multipart/related', subParts: [{ partId: 'h', type: 'text/html' }, { blobId: image, type: 'image/png', name: 'pixel.png', disposition: 'inline', cid: 'pixel@example.org' }] }, + ], + }, + { blobId: file, type: 'application/octet-stream', name: 'kept.bin', disposition: 'attachment' }, + ], + }, + }, + }, + }, '0']]); + expect(made.methodResponses[0][1].created?.m?.id, JSON.stringify(made.methodResponses[0][1].notCreated)).toBeTruthy(); + + const page = await userPage(browser, ENT.alice); + await enableOffline(page); + await toMail(page); + // Files come last in a pass, and yield to anything newer. This message's come first of them. + await page.waitForTimeout(10_000); + await page.context().setOffline(true); + await openMessage(page, subject); + + const pixel = page.frameLocator('iframe').locator('img[alt="pixel"]').first(); + // A broken image is 0 wide until the app's placeholder comes in, so ask where it came from. + await expect.poll(() => pixel.evaluate((img: HTMLImageElement) => (img.src.startsWith('blob:') ? img.naturalWidth : 0)).catch(() => 0), { timeout: 30_000 }).toBe(1); + + const chip = page.locator('[data-testid="attachment"][data-attachment-name="kept.bin"]').first(); + await expect(chip).toBeVisible({ timeout: 20_000 }); + const download = page.waitForEvent('download', { timeout: 30_000 }); + await chip.locator('button[title="Download"]').click(); + const saved = await (await download).createReadStream(); + const chunks: Buffer[] = []; + for await (const chunk of saved) chunks.push(chunk as Buffer); + expect(Buffer.concat(chunks).toString()).toBe(fileText); + await page.context().close(); +}); + test('signing out asks first when unsent mail would be lost, then wipes the device', async ({ browser }) => { const subject = `Unsent at sign-out ${RUN}`; const page = await userPage(browser, ENT.alice); diff --git a/lib/__tests__/plugin-keystore-runtime.test.ts b/lib/__tests__/plugin-keystore-runtime.test.ts new file mode 100644 index 000000000..ab1d59dec --- /dev/null +++ b/lib/__tests__/plugin-keystore-runtime.test.ts @@ -0,0 +1,114 @@ +/** + * api.keystore in the sandbox runtime. A PIN prompt can take minutes, so the facade + * must not give up after the RPC's usual 30 seconds, and errors have to reach the + * plugin as the bare code. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import React from 'react'; +import { cleanup, render, waitFor } from '@testing-library/react'; + +vi.mock('@/stores/toast-store', () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +const HOST_ORIGIN = window.location.origin; + +function post(source: unknown, data: unknown): void { + const ev = new MessageEvent('message', { data, origin: HOST_ORIGIN }); + Object.defineProperty(ev, 'source', { value: source }); + window.dispatchEvent(ev); +} + +interface KeystoreFacade { + list(): Promise; + sign(id: string, digest: Uint8Array, scheme: unknown): Promise; + decrypt(id: string, data: Uint8Array, scheme: unknown): Promise; + agree(id: string, peer: Uint8Array): Promise; +} + +describe('api.keystore in the sandbox', () => { + let hostWindow: { postMessage: ReturnType }; + const realParent = Object.getOwnPropertyDescriptor(window, 'parent'); + const g = globalThis as { __pluginApi?: { keystore: KeystoreFacade; host: { features: Record } } }; + + beforeEach(() => { + vi.resetModules(); + hostWindow = { postMessage: vi.fn() }; + Object.defineProperty(window, 'parent', { configurable: true, get: () => hostWindow as unknown as Window }); + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + }); + afterEach(() => { + vi.useRealTimers(); + cleanup(); + vi.unstubAllGlobals(); + delete g.__pluginApi; + if (realParent) Object.defineProperty(window, 'parent', realParent); + }); + + const requests = () => + hostWindow.postMessage.mock.calls.map((call) => call[0] as { type: string; id: string; method: string; args: unknown[] }).filter((message) => message.type === 'api-request'); + + async function boot(features: Record): Promise { + const bundle = 'module.exports = { activate: function (api) { globalThis.__pluginApi = api; } };'; + const { SandboxRuntime } = await import('@/lib/plugin-sandbox/runtime'); + render(React.createElement(SandboxRuntime)); + post(hostWindow, { + type: 'init', + payload: { + mode: 'background', pluginId: 'smime', tier: 'privileged', locale: 'en', + manifest: { id: 'smime', version: '1.0.0', permissions: ['crypto:full', 'keystore'], settings: {} }, + code: bundle, + hostFeatures: { externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false, ...features }, + }, + }); + await waitFor(() => expect(g.__pluginApi).toBeDefined()); + } + + it('the flag reaches the plugin, and is not there when the host does not send it', async () => { + await boot({ keystore: true }); + expect(g.__pluginApi!.host.features.keystore).toBe(true); + post(hostWindow, { type: 'host-change', features: { externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false } }); + expect(g.__pluginApi!.host.features.keystore).toBeFalsy(); + }, 30_000); + + it('a list has the ordinary RPC timeout: it never waits on a card', async () => { + await boot({ keystore: true }); + vi.useFakeTimers(); + const listing = g.__pluginApi!.keystore.list(); + const outcome = listing.then(() => 'answered', (error: Error) => error.message); + await vi.advanceTimersByTimeAsync(31_000); + await expect(outcome).resolves.toMatch(/timed out/); + }, 30_000); + + it('no key operation gives up while the host has not answered: a PIN can take minutes', async () => { + await boot({ keystore: true }); + vi.useFakeTimers(); + const api = g.__pluginApi!.keystore; + const digest = new Uint8Array(32); + const settled: string[] = []; + const watch = (name: string, promise: Promise) => promise.then(() => settled.push(name), () => settled.push(`${name} rejected`)); + watch('sign', api.sign('id', digest, { scheme: 'rsa-pkcs1', hash: 'SHA-256' })); + watch('decrypt', api.decrypt('id', new Uint8Array(256), { scheme: 'rsa-pkcs1' })); + watch('agree', api.agree('id', new Uint8Array(91))); + // Past the RPC's 30 s and past the network calls' 120 s. + await vi.advanceTimersByTimeAsync(600_000); + expect(settled).toEqual([]); + expect(requests().map((request) => request.method)).toEqual(['keystore.sign', 'keystore.decrypt', 'keystore.agree']); + }, 30_000); + + it('passes the bytes as bytes, and hands an error back with the code as its message', async () => { + await boot({ keystore: true }); + const api = g.__pluginApi!.keystore; + const digest = new Uint8Array(32).fill(9); + const scheme = { scheme: 'rsa-pss', hash: 'SHA-256' }; + const signing = api.sign('the-id', digest, scheme); + const sent = requests().at(-1)!; + expect(sent.args).toEqual(['the-id', digest, scheme]); + post(hostWindow, { type: 'api-response', id: sent.id, ok: false, error: 'keystore:cancelled' }); + await expect(signing).rejects.toThrow(/^keystore:cancelled$/); + + const decrypting = api.decrypt('the-id', new Uint8Array(256), { scheme: 'rsa-pkcs1' }); + post(hostWindow, { type: 'api-response', id: requests().at(-1)!.id, ok: true, result: new Uint8Array([1, 2]) }); + await expect(decrypting).resolves.toEqual(new Uint8Array([1, 2])); + }, 30_000); +}); diff --git a/lib/__tests__/plugin-keystore.test.ts b/lib/__tests__/plugin-keystore.test.ts new file mode 100644 index 000000000..3a1426d92 --- /dev/null +++ b/lib/__tests__/plugin-keystore.test.ts @@ -0,0 +1,344 @@ +/** + * api.keystore: the desktop app's OS certificate store, for a privileged plugin. The + * refusals are what matters here: an untrusted plugin never reaches the bridge, the + * flag only says yes when the call would work, and a call waiting on the OS's PIN + * prompt is not a hung hook. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { InstalledPlugin } from '../plugin-types'; +import { clearAllHooks, pluginErrorTracker, renderHooks, setHookDialogGate } from '../plugin-hooks'; +import { checkPluginManifest } from '../extension-package'; +import { dispatchApiCall, PERM_PER_METHOD } from '../plugin-sandbox/host-api'; +import { hasDialogFor, holdForUser, whenNoDialogFor } from '../plugin-sandbox/host-dialog'; +import { snapshotHostFeatures } from '../plugin-sandbox/host-features'; +import { keystoreFeature } from '../plugin-sandbox/host-api-keystore'; +import { HIGH_RISK_PERMISSIONS } from '../plugin-sandbox/permission-text'; + +vi.mock('@/stores/toast-store', () => ({ + toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }, +})); + +function plugin(overrides: Partial = {}): InstalledPlugin { + return { + id: 'smime', + name: 'S/MIME', + version: '1.0.0', + author: 'test', + description: '', + type: 'hook', + tier: 'privileged', + managed: true, + permissions: ['crypto:full', 'keystore'], + entrypoint: 'index.js', + enabled: true, + status: 'running', + settings: {}, + ...overrides, + }; +} + +const DIGEST = new Uint8Array(32).fill(7); +const SIGN = { scheme: 'rsa-pkcs1', hash: 'SHA-256' }; + +function fakeBridge() { + return { + list: vi.fn(async () => []), + sign: vi.fn(async () => new Uint8Array([1, 2, 3])), + decrypt: vi.fn(async () => new Uint8Array([4, 5, 6])), + agree: vi.fn(async () => new Uint8Array([7, 8, 9])), + }; +} + +function installBridge(bridge: ReturnType | undefined): void { + (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop = bridge ? { version: 1, platform: 'win32', keystore: bridge } : undefined; +} + +afterEach(() => { + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; +}); + +describe('who may call it', () => { + let bridge: ReturnType; + beforeEach(() => { + bridge = fakeBridge(); + installBridge(bridge); + }); + + const METHODS: Array<[string, unknown[]]> = [ + ['keystore.list', []], + ['keystore.sign', ['id', DIGEST, SIGN]], + ['keystore.decrypt', ['id', new Uint8Array(256), { scheme: 'rsa-pkcs1' }]], + ['keystore.agree', ['id', new Uint8Array(91)]], + ]; + + it.each(METHODS)('%s is refused for the untrusted tier, even when declared and granted', async (method, args) => { + const untrusted = plugin({ tier: 'untrusted', permissions: ['keystore'], grantedPermissions: ['keystore'], managed: true }); + await expect(dispatchApiCall(untrusted, method, args, { privileged: false })).rejects.toThrow(/requires the privileged plugin tier/); + expect(bridge.list).not.toHaveBeenCalled(); + expect(bridge.sign).not.toHaveBeenCalled(); + expect(bridge.decrypt).not.toHaveBeenCalled(); + expect(bridge.agree).not.toHaveBeenCalled(); + }); + + it.each(METHODS)('%s is refused without the permission, also in the privileged tier', async (method, args) => { + const noPermission = plugin({ permissions: ['crypto:full'] }); + await expect(dispatchApiCall(noPermission, method, args, { privileged: true })).rejects.toThrow(/lacks permission "keystore"/); + expect(bridge.sign).not.toHaveBeenCalled(); + }); + + it('is refused when a manifest declares it but neither an admin nor the user gave it', async () => { + const notGiven = plugin({ managed: false, grantedPermissions: [] }); + await expect(dispatchApiCall(notGiven, 'keystore.list', [], { privileged: true })).rejects.toThrow(/lacks permission "keystore"/); + }); + + it('passes for a privileged plugin holding it', async () => { + await expect(dispatchApiCall(plugin(), 'keystore.sign', ['id', DIGEST, SIGN], { privileged: true })).resolves.toEqual(new Uint8Array([1, 2, 3])); + expect(bridge.sign).toHaveBeenCalledWith('id', DIGEST, SIGN); + await expect(dispatchApiCall(plugin(), 'keystore.list', [], { privileged: true })).resolves.toEqual([]); + await expect(dispatchApiCall(plugin(), 'keystore.decrypt', ['id', new Uint8Array(256), { scheme: 'rsa-pkcs1' }], { privileged: true })).resolves.toEqual(new Uint8Array([4, 5, 6])); + await expect(dispatchApiCall(plugin(), 'keystore.agree', ['id', new Uint8Array(91)], { privileged: true })).resolves.toEqual(new Uint8Array([7, 8, 9])); + }); + + it('says it is unsupported in a browser or a shell without the helper', async () => { + installBridge(undefined); + await expect(dispatchApiCall(plugin(), 'keystore.list', [], { privileged: true })).rejects.toThrow('keystore:unsupported'); + }); + + it('every keystore method is in the permission table', () => { + for (const [method] of METHODS) expect(PERM_PER_METHOD[method]).toBe('keystore'); + }); + + it('is shown as high risk in the consent dialog', () => { + expect(HIGH_RISK_PERMISSIONS.has('keystore')).toBe(true); + }); +}); + +describe('what gets through to the shell', () => { + let bridge: ReturnType; + beforeEach(() => { + bridge = fakeBridge(); + installBridge(bridge); + }); + const call = (method: string, ...args: unknown[]) => dispatchApiCall(plugin(), method, args, { privileged: true }); + + it.each<[string, unknown[]]>([ + ['an id that is no string', [42, DIGEST, SIGN]], + ['an empty id', ['', DIGEST, SIGN]], + ['a long id', ['x'.repeat(65), DIGEST, SIGN]], + ['a digest that is no bytes', ['id', 'not bytes', SIGN]], + ['an empty digest', ['id', new Uint8Array(0), SIGN]], + ['a digest longer than any hash', ['id', new Uint8Array(65), SIGN]], + ['no scheme', ['id', DIGEST, null]], + ['a list as scheme', ['id', DIGEST, []]], + ['an unknown scheme', ['id', DIGEST, { scheme: 'raw', hash: 'SHA-256' }]], + ['an unknown hash', ['id', DIGEST, { scheme: 'rsa-pkcs1', hash: 'MD5' }]], + ['no hash', ['id', DIGEST, { scheme: 'rsa-pkcs1' }]], + ])('sign refuses %s', async (_name, args) => { + await expect(call('keystore.sign', ...args)).rejects.toThrow('keystore:invalid'); + expect(bridge.sign).not.toHaveBeenCalled(); + }); + + it('decrypt refuses data past any modulus, and a label too long', async () => { + await expect(call('keystore.decrypt', 'id', new Uint8Array(2049), { scheme: 'rsa-pkcs1' })).rejects.toThrow('keystore:invalid'); + await expect(call('keystore.decrypt', 'id', new Uint8Array(256), { scheme: 'rsa-oaep', hash: 'SHA-1', label: new Uint8Array(257) })).rejects.toThrow('keystore:invalid'); + await expect(call('keystore.decrypt', 'id', new Uint8Array(256), { scheme: 'rsa-oaep' })).rejects.toThrow('keystore:invalid'); + expect(bridge.decrypt).not.toHaveBeenCalled(); + }); + + it('passes only the fields it knows', async () => { + const data = new Uint8Array(256); + await call('keystore.decrypt', 'id', data, { scheme: 'rsa-oaep', hash: 'SHA-1', extra: 'x', label: new Uint8Array(0) }); + expect(bridge.decrypt).toHaveBeenCalledWith('id', data, { scheme: 'rsa-oaep', hash: 'SHA-1' }); + await call('keystore.sign', 'id', DIGEST, { ...SIGN, extra: 'x' }); + expect(bridge.sign).toHaveBeenCalledWith('id', DIGEST, SIGN); + }); + + it('agree refuses an oversized peer key', async () => { + await expect(call('keystore.agree', 'id', new Uint8Array(257))).rejects.toThrow('keystore:invalid'); + expect(bridge.agree).not.toHaveBeenCalled(); + }); + + it.each(['cancelled', 'not-found', 'unsupported', 'locked', 'failed'])('hands keystore:%s to the frame as the message', async (code) => { + bridge.sign.mockRejectedValueOnce(new Error(`Error invoking remote method: Error: keystore:${code}`)); + await expect(call('keystore.sign', 'id', DIGEST, SIGN)).rejects.toThrow(new RegExp(`^keystore:${code}$`)); + }); + + it('turns anything else into keystore:failed, not a stack or a path', async () => { + bridge.sign.mockRejectedValueOnce(new Error('C:\\secret\\path blew up')); + await expect(call('keystore.sign', 'id', DIGEST, SIGN)).rejects.toThrow(/^keystore:failed$/); + }); +}); + +describe('api.host.features.keystore', () => { + it('is true for a privileged plugin with the permission, in a shell that has it', () => { + installBridge(fakeBridge()); + expect(keystoreFeature(plugin())).toEqual({ keystore: true }); + expect(snapshotHostFeatures(plugin()).keystore).toBe(true); + }); + + it('is absent in a browser', () => { + expect(keystoreFeature(plugin())).toEqual({}); + expect(snapshotHostFeatures(plugin())).not.toHaveProperty('keystore'); + }); + + it('is absent for the untrusted tier, without the permission, and for a privileged claim that fails its gates', () => { + installBridge(fakeBridge()); + expect(keystoreFeature(plugin({ tier: 'untrusted' }))).toEqual({}); + expect(keystoreFeature(plugin({ permissions: ['crypto:full'] }))).toEqual({}); + // The tier resolver refuses these two: no admin install, no crypto:full. + expect(keystoreFeature(plugin({ managed: false, grantedPermissions: ['keystore'] }))).toEqual({}); + expect(keystoreFeature(plugin({ permissions: ['keystore'] }))).toEqual({}); + }); + + it('is absent when no plugin is named, so one plugin\'s flag is never everyone\'s', () => { + installBridge(fakeBridge()); + expect(snapshotHostFeatures()).not.toHaveProperty('keystore'); + }); +}); + +describe('the manifest', () => { + const BASE = { id: 'keystore-test', name: 'P', version: '1.0.0', author: 'a', description: 'd', type: 'hook' }; + + it('grants it to a privileged plugin as an optional permission', () => { + const { manifest, errors } = checkPluginManifest({ ...BASE, tier: 'privileged', permissions: ['crypto:full'], optionalPermissions: ['keystore'] }); + expect(errors).toEqual([]); + expect(manifest!.permissions).toContain('keystore'); + }); + + it('leaves it out for an untrusted plugin, with a warning, as optional and as plain', () => { + for (const declared of [{ optionalPermissions: ['keystore', 'email:read'] }, { permissions: ['keystore', 'email:read'] }]) { + const { manifest, errors, warnings } = checkPluginManifest({ ...BASE, ...declared }); + expect(errors).toEqual([]); + expect(manifest!.permissions).toEqual(['email:read']); + expect(warnings.join()).toMatch(/"keystore" permission: it needs the privileged tier/); + } + }); +}); + +describe('a keystore call and the hook timer', () => { + const ID = 'smime'; + + beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(console, 'error').mockImplementation(() => {}); + pluginErrorTracker.resetAll(); + setHookDialogGate({ hasOpenDialog: hasDialogFor, whenDialogsClosed: whenNoDialogFor }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + setHookDialogGate(null); + clearAllHooks(); + pluginErrorTracker.resetAll(); + }); + + it('a hold counts as an open dialog until released, however many there are', () => { + expect(hasDialogFor(ID)).toBe(false); + const first = holdForUser(ID); + const second = holdForUser(ID); + expect(hasDialogFor(ID)).toBe(true); + first(); + first(); // Releasing twice doesn't release the other's hold. + expect(hasDialogFor(ID)).toBe(true); + second(); + expect(hasDialogFor(ID)).toBe(false); + }); + + it('a PIN prompt longer than the hook budget neither times out nor counts as an error', async () => { + const bridge = fakeBridge(); + let answerPin: () => void = () => {}; + bridge.decrypt.mockImplementation(() => new Promise>((resolve) => { answerPin = () => resolve(new Uint8Array([1])); })); + installBridge(bridge); + + // What an S/MIME plugin does: unwrap the content key inside its render hook. + renderHooks.onRenderEmailBody.register(ID, async (body: object) => { + await dispatchApiCall(plugin(), 'keystore.decrypt', ['id', new Uint8Array(256), { scheme: 'rsa-pkcs1' }], { privileged: true }); + return { ...body, html: 'decrypted', handledBy: ID }; + }); + + const ran = renderHooks.onRenderEmailBody.transform({ html: '' }); + // Far past the 5 s budget: the user is looking for their card. + await vi.advanceTimersByTimeAsync(120_000); + expect(console.error).not.toHaveBeenCalled(); + expect(hasDialogFor(ID)).toBe(true); + + answerPin(); + await expect(ran).resolves.toEqual({ html: 'decrypted', handledBy: ID }); + expect(console.error).not.toHaveBeenCalled(); + expect(hasDialogFor(ID)).toBe(false); + expect(pluginErrorTracker.isDisabled(ID)).toBe(false); + }); + + it('the hold ends when the call fails too, so a later hang still times out', async () => { + const bridge = fakeBridge(); + bridge.sign.mockRejectedValueOnce(new Error('keystore:cancelled')); + installBridge(bridge); + await expect(dispatchApiCall(plugin(), 'keystore.sign', ['id', DIGEST, SIGN], { privileged: true })).rejects.toThrow('keystore:cancelled'); + expect(hasDialogFor(ID)).toBe(false); + + await expect(dispatchApiCall(plugin(), 'keystore.sign', [42], { privileged: true })).rejects.toThrow('keystore:invalid'); + expect(hasDialogFor(ID)).toBe(false); + }); + + it('a list takes no hold: it never waits on a card, and a hung list must not stop the hook timer', async () => { + const bridge = fakeBridge(); + bridge.list.mockImplementation(() => new Promise(() => {})); + installBridge(bridge); + renderHooks.onRenderEmailBody.register(ID, async (body: object) => { + await dispatchApiCall(plugin(), 'keystore.list', [], { privileged: true }); + return { ...body, html: 'never' }; + }); + const ran = renderHooks.onRenderEmailBody.transform({ html: 'as it was' }); + expect(hasDialogFor(ID)).toBe(false); + await vi.advanceTimersByTimeAsync(6000); + await expect(ran).resolves.toEqual({ html: 'as it was' }); + expect(console.error).toHaveBeenCalled(); + }); + + it('a call that never answers cannot hold the hook for ever: there is an absolute deadline', async () => { + const bridge = fakeBridge(); + bridge.decrypt.mockImplementation(() => new Promise(() => {})); + installBridge(bridge); + // This call never ends, so its hold never does: an id of its own. + const hung = plugin({ id: 'hung-plugin' }); + renderHooks.onRenderEmailBody.register(hung.id, async (body: object) => { + await dispatchApiCall(hung, 'keystore.decrypt', ['id', new Uint8Array(256), { scheme: 'rsa-pkcs1' }], { privileged: true }); + return { ...body, html: 'never' }; + }); + const ran = renderHooks.onRenderEmailBody.transform({ html: 'as it was' }); + let settled = false; + void ran.then(() => { settled = true; }); + // Longer than the shell's own 5 minute cap, which would normally end it first. + await vi.advanceTimersByTimeAsync(5 * 60_000); + expect(settled).toBe(false); + await vi.advanceTimersByTimeAsync(11 * 60_000); + expect(settled).toBe(true); + await expect(ran).resolves.toEqual({ html: 'as it was' }); + expect(console.error).toHaveBeenCalled(); + }); + + it('holds that keep coming and going do not reset the deadline', async () => { + renderHooks.onRenderEmailBody.register(ID, () => new Promise(() => {})); + let release = holdForUser(ID); + const ran = renderHooks.onRenderEmailBody.transform({ html: 'as it was' }); + let settled = false; + void ran.then(() => { settled = true; }); + for (let minute = 0; minute < 20 && !settled; minute += 1) { + await vi.advanceTimersByTimeAsync(4000); + release(); + release = holdForUser(ID); + await vi.advanceTimersByTimeAsync(60_000 - 4000); + } + release(); + expect(settled).toBe(true); + }); + + it('a hook that hangs with no keystore call still times out', async () => { + renderHooks.onRenderEmailBody.register(ID, () => new Promise(() => {})); + const ran = renderHooks.onRenderEmailBody.transform({ html: 'as it was' }); + await vi.advanceTimersByTimeAsync(6000); + await expect(ran).resolves.toEqual({ html: 'as it was' }); + expect(console.error).toHaveBeenCalled(); + }); +}); diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index e049b727b..5c60da8bf 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -111,6 +111,77 @@ export interface DesktopPluginsBridge { install: (slug: string, version?: string) => Promise; } +export type DesktopKeystoreHash = 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512'; + +export interface DesktopKeystoreCertificate { + /** Opaque: the only ids sign, decrypt and agree accept. */ + id: string; + /** The leaf, DER. */ + certificate: Uint8Array; + /** Intermediates, DER, without leaf and root. May be empty. */ + chain: Uint8Array[]; + subject: string; + issuer: string; + /** Hex. */ + serial: string; + /** Milliseconds since 1970. */ + notBefore: number; + notAfter: number; + /** keyUsage names. Empty without the extension. */ + usages: string[]; + /** extendedKeyUsage OIDs. Empty without the extension. */ + extendedUsages: string[]; + /** The key's algorithm OID. */ + algorithm: string; + keyType: 'rsa' | 'ec'; + curve?: 'P-256' | 'P-384' | 'P-521'; + keyBits: number; + /** A hint for the UI. */ + source: 'software' | 'smartcard' | 'keychain' | 'unknown'; + signSchemes: string[]; + decryptSchemes: string[]; + canAgree: boolean; + /** The key can also sign the user in somewhere: the user is asked per use. */ + askEachUse: boolean; +} + +/** Whether this origin may use the keystore at all. `app` is standalone. */ +export type DesktopKeystoreAccessState = 'app' | 'policy' | 'stored' | 'off'; + +export type DesktopKeystoreSignScheme = + | { scheme: 'rsa-pkcs1'; hash: DesktopKeystoreHash } + /** MGF1 on the same hash, salt as long as the hash. */ + | { scheme: 'rsa-pss'; hash: DesktopKeystoreHash } + /** `hash` names the digest's length. */ + | { scheme: 'ecdsa'; hash: DesktopKeystoreHash }; + +export type DesktopKeystoreDecryptScheme = + | { scheme: 'rsa-pkcs1' } + /** MGF1 on the same hash. */ + | { scheme: 'rsa-oaep'; hash: DesktopKeystoreHash; label?: Uint8Array }; + +/** + * The OS certificate store and smart cards. Present only where the native helper + * loaded and the OS has one. Failures reject with a bare code as the message: + * keystore:cancelled, not-found, unsupported, locked, invalid, failed. The shell asks the + * user in a native dialog before any of this, on top of which the OS has its own prompts, + * so a call may take a while. On an instance it stays off until policy or the user allows + * it. The key never leaves the OS. + */ +export interface DesktopKeystoreBridge { + /** Certificates fit for mail that have a private key. The OS doesn't prompt. */ + list: () => Promise; + /** RSA gives the raw signature, ECDSA a DER ECDSA-Sig-Value. */ + sign: (id: string, digest: Uint8Array, scheme: DesktopKeystoreSignScheme) => Promise; + /** Unwraps a CMS content key. */ + decrypt: (id: string, data: Uint8Array, scheme: DesktopKeystoreDecryptScheme) => Promise; + /** The raw shared secret, big-endian. The peer's key is an EC SubjectPublicKeyInfo. */ + agree: (id: string, peerPublicKey: Uint8Array) => Promise; + accessState: () => Promise; + /** Takes back a stored yes and this session's grants. Only ever narrows. */ + revokeAccess: () => Promise; +} + export interface BulwarkDesktopBridge { version: number; platform: 'win32' | 'darwin' | 'linux'; @@ -123,6 +194,7 @@ export interface BulwarkDesktopBridge { servers?: DesktopServersBridge; auth?: DesktopAuthBridge; plugins?: DesktopPluginsBridge; + keystore?: DesktopKeystoreBridge; } declare global { diff --git a/lib/extension-package.ts b/lib/extension-package.ts index 92942537f..baf286769 100644 --- a/lib/extension-package.ts +++ b/lib/extension-package.ts @@ -456,6 +456,13 @@ export function checkPluginManifest(m: Record, opts: { appVersi permissions = [...new Set([...permissions, ...asked.filter((p) => KNOWN_PERMISSIONS.has(p))])]; } } + // Only the privileged tier may reach the OS certificate store. Left out rather than + // refused, like an optional permission this version doesn't offer: the host would + // refuse the calls anyway, and consent shouldn't ask for what can't be used. + if (tier !== 'privileged' && permissions.includes('keystore')) { + warnings.push('Left out the "keystore" permission: it needs the privileged tier'); + permissions = permissions.filter((p) => p !== 'keystore'); + } if (tier === 'privileged' && !permissions.includes('crypto:full')) { errors.push('A privileged plugin must request the "crypto:full" permission'); } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 0c6e9c5fb..487b060e7 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -553,6 +553,7 @@ export interface IJMAPClient { accountId?: string; onProgress?: (loaded: number, total: number) => void; signal?: AbortSignal; + noHold?: boolean; }, ): Promise<{ blobId: string; size: number; type: string }>; getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index b57de870c..983431811 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -6,7 +6,8 @@ import { IncompleteDeleteError } from "./delete-error"; import { downloadAllowed, saveBlobAs, type DownloadKind } from "@/lib/download-guard"; import { ArchiveEmailsError } from "./archive-error"; import { generateUUID } from '@/lib/utils'; -import { standInsIn, withRealIds, withStandInsBack } from './stand-in-ids'; +import { isStandInId, standInsIn, withRealIds, withStandInsBack } from './stand-in-ids'; +import { isLocalBlobId } from '@/lib/store/outbox/items'; import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, DeliveryStatus, EmailSubmission, RejectedRecipient, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { BlobBatch, CalendarEventUpdateOptions, CalendarQueryOptions, FileNodeListings, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration, SearchEmailsResult, SearchRequestOptions, UploadedEmailImport, UploadedEmailImportResult } from "./client-interface"; @@ -55,7 +56,7 @@ import { toStorageQuota, type JMAPQuota, type StorageQuota } from "@/lib/quota"; import { messagePriorityHeaders, type MessagePriority } from "@/lib/message-priority"; import { customHeaderProperties, withLabelHeaders } from "@/lib/outgoing/custom-headers"; import { draftLabelProperty } from "@/lib/classification/labels"; -import { attachmentParts, HTML_PART_ID, relatedBodyStructure, TEXT_PART_ID, type OutgoingBodyPart } from "./outgoing-body"; +import { attachmentParts, HTML_PART_ID, relatedBodyStructure, repointAttachments, TEXT_PART_ID, type OutgoingBodyPart, type SavedPart } from "./outgoing-body"; import { addBodyMediaToAttachments } from "./body-media-attachments"; // Cap for the follow-up Email/get issued when a displayed body part comes @@ -2225,8 +2226,10 @@ export class JMAPClient implements IJMAPClient { * `networkOnly`: only the server's own word counts. A send or a change the * outbox hands over must never be answered by the device: the device would * queue it again, or answer for the server. + * `deviceOnly`: the other way round. What belongs to a draft saved on the + * device stays with it, even if the server came back in between. */ - private async request(methodCalls: JMAPMethodCall[], using?: string[], opts?: { transfer?: boolean; skipOfflineRecord?: boolean; networkOnly?: boolean }): Promise { + private async request(methodCalls: JMAPMethodCall[], using?: string[], opts?: { transfer?: boolean; skipOfflineRecord?: boolean; networkOnly?: boolean; deviceOnly?: boolean }): Promise { if (!this.apiUrl) { throw new Error('Not connected. Call connect() first.'); } @@ -2236,32 +2239,30 @@ export class JMAPClient implements IJMAPClient { // browser is offline or the network fails. Without offline mode no hooks // are set and this is the plain network request. const offline = getOfflineHooks(); - if (this.offlineOnly || offline?.skipNetwork(this)) { + // A stand-in id from an event or a draft created offline, still held by an + // open dialog or a list: the server knows it by its real id, or not yet at + // all. The store files what reached the server under the real id too. + const standIns = offline?.resolveStandIns ? standInsIn(methodCalls) : []; + let realIds: Record | null = null; + let onDeviceOnly = false; + if (standIns.length > 0) { + const resolved = await offline!.resolveStandIns!(this, standIns); + const known = Object.entries(resolved).filter((entry): entry is [string, string] => !!entry[1]); + if (known.length > 0) { + realIds = Object.fromEntries(known); + methodCalls = withRealIds(methodCalls, realIds); + } + onDeviceOnly = standIns.some((id) => !resolved[id]); + } + const asAsked = (response: JMAPResponse) => (realIds ? withStandInsBack(response, realIds) : response); + if (this.offlineOnly || opts?.deviceOnly || onDeviceOnly || offline?.skipNetwork(this)) { const local = offline && !opts?.networkOnly ? await offline.answer(this, methodCalls) : null; if (local) { this.lastAnswerFromDevice = true; - return local as JMAPResponse; + return asAsked(local as JMAPResponse); } throw new OfflineUnavailableError(); } - // A stand-in id from an event created offline, still held by an open dialog - // or a list: the server knows it by its real id, or not yet at all. - const standIns = offline?.resolveStandIns ? standInsIn(methodCalls) : []; - let realIds: Record | null = null; - if (standIns.length > 0) { - const resolved = await offline!.resolveStandIns!(this, standIns); - if (standIns.some((id) => !resolved[id])) { - const local = opts?.networkOnly ? null : await offline!.answer(this, methodCalls); - if (local) { - this.lastAnswerFromDevice = true; - return local as JMAPResponse; - } - throw new OfflineUnavailableError(); - } - realIds = resolved as Record; - methodCalls = withRealIds(methodCalls, realIds); - } - const asAsked = (response: JMAPResponse) => (realIds ? withStandInsBack(response, realIds) : response); let answer: JMAPResponse; try { answer = await this.networkRequest(methodCalls, using, opts); @@ -2269,7 +2270,7 @@ export class JMAPClient implements IJMAPClient { // Only when the server is really gone: a refusal without CORS // headers looks the same to the browser and must stay an error. if (offline && isNetworkFailure(error) && await offline.serverGone(this, error)) { - const local = opts?.networkOnly ? null : await offline.answer(this, methodCalls); + const local = opts?.networkOnly ? null : await offline.answer(this, methodCalls, { networkTried: true }); if (local) { this.lastAnswerFromDevice = true; return asAsked(local as JMAPResponse); @@ -5116,9 +5117,23 @@ export class JMAPClient implements IJMAPClient { throw new Error('No drafts mailbox found'); } + attachments = await this.withPartsOfFlushedDraft(attachments, draftId, options?.previousDraftAccountId); + + // Files attached offline go up now if the server answers. If it doesn't, + // the draft stays on the device with them, as when offline. + const hooks = getOfflineHooks(); + if (attachments?.some(att => isLocalBlobId(att.blobId)) && hooks?.resolveHeldAttachments && !this.offlineOnly && !hooks.skipNetwork(this)) { + try { + attachments = await hooks.resolveHeldAttachments(this, attachments, draftAccountId); + } catch (error) { + if (!(error instanceof Error) || error.name !== 'SendNotStartedError') throw error; + } + } + const emailId = `draft-${Date.now()}`; interface EmailDraft { + messageId: string[]; from: { name?: string; email: string }[]; to: { name?: string; email: string }[]; cc?: { name?: string; email: string }[]; @@ -5142,6 +5157,9 @@ export class JMAPClient implements IJMAPClient { const draftInReplyTo = inReplyTo?.map(stripMessageIdBrackets).filter(Boolean); const draftReferences = references?.map(stripMessageIdBrackets).filter(Boolean); const emailData: EmailDraft = { + // Fixed here: when the connection breaks on the way, this is how the + // device later finds out whether the server took the draft after all. + messageId: [generateMessageId(parseRecipientString(fromEmail || this.username).email)], from: [{ ...(sanitizedFromName ? { name: sanitizedFromName } : {}), email: fromEmail || this.username }], // "Name " must be split into the two JMAP fields: storing the whole // mailbox as the address makes the server treat the display name as part @@ -5168,14 +5186,17 @@ export class JMAPClient implements IJMAPClient { // Images the HTML shows by cid: go into a multipart/related beside it // (#1451), so the draft and the mail sent from it display them. - const draftStructure = relatedBodyStructure(htmlBody, attachments); - if (draftStructure) { - delete emailData.textBody; - delete emailData.htmlBody; - emailData.bodyStructure = draftStructure; - } else if (attachments?.length) { - emailData.attachments = attachmentParts(attachments); - } + const putParts = (parts: typeof attachments) => { + const draftStructure = relatedBodyStructure(htmlBody, parts); + if (draftStructure) { + delete emailData.textBody; + delete emailData.htmlBody; + emailData.bodyStructure = draftStructure; + } else if (parts?.length) { + emailData.attachments = attachmentParts(parts); + } + }; + putParts(attachments); // Destroy the previous draft version only AFTER the replacement was // created (#849). A combined `Email/set { create, destroy }` processes @@ -5183,12 +5204,23 @@ export class JMAPClient implements IJMAPClient { // previous version (re-opened draft with attachments) and fails with // blobNotFound, the destroy still went through - deleting the last good // copy of the draft. Worst case now is an orphaned old version. - const response = await this.request([ + const createVersion = () => this.request([ ["Email/set", { accountId: draftAccountId, create: { [emailId]: emailData }, }, "0"], ]); + let response = await createVersion(); + // A part that died with the message it belonged to: its file is in the draft + // under another id now. Once, with the parts the draft has. + if (draftId && response.methodResponses?.[0]?.[1]?.notCreated?.[emailId]?.type === 'blobNotFound') { + const fresh = await this.withPartsOfDraft(attachments, draftId, options?.previousDraftAccountId); + if (fresh !== attachments) { + attachments = fresh; + putParts(fresh); + response = await createVersion(); + } + } if (response.methodResponses?.[0]?.[0] === "Email/set") { const result = response.methodResponses[0][1]; @@ -5206,9 +5238,12 @@ export class JMAPClient implements IJMAPClient { if (draftId) { try { + // A version saved on the device replaces the old one when it + // reaches the server, not before: the old one stays there until then. + const onDevice = isStandInId(newDraftId); const destroyResponse = await this.request([ - ["Email/set", { accountId: options?.previousDraftAccountId || this.accountId, destroy: [draftId] }, "0"], - ]); + ["Email/set", { accountId: options?.previousDraftAccountId || this.accountId, destroy: [draftId], ...(onDevice ? { replacedBy: newDraftId } : {}) }, "0"], + ], undefined, onDevice ? { deviceOnly: true } : undefined); const destroyResult = destroyResponse.methodResponses?.[0]?.[1]; if (destroyResult?.notDestroyed) { console.warn('Failed to destroy old draft:', destroyResult.notDestroyed); @@ -5226,6 +5261,33 @@ export class JMAPClient implements IJMAPClient { throw new Error('Failed to save draft'); } + /** `attachments` pointed at the parts the draft has now. The same list when nothing changes. */ + private async withPartsOfDraft(attachments: T[] | undefined, draftId: string, accountId: string | undefined): Promise { + if (!attachments?.some((attachment) => !isLocalBlobId(attachment.blobId))) return attachments; + let email: Pick | undefined; + try { + const response = await this.request([["Email/get", { accountId: accountId || this.accountId, ids: [draftId], properties: ["attachments", "textBody", "htmlBody"] }, "0"]]); + email = response.methodResponses?.[0]?.[1]?.list?.[0]; + } catch (error) { + debug.warn('email', 'Could not read the parts of the draft:', error); + } + if (!email) return attachments; + const parts: SavedPart[] = addBodyMediaToAttachments(email).attachments ?? []; + const fresh = repointAttachments(attachments, parts, (attachment) => isLocalBlobId(attachment.blobId)); + return fresh.some((attachment, i) => attachment !== attachments[i]) ? fresh : attachments; + } + + /** + * A stand-in that has reached the server: the draft was created anew there, so + * parts the page got from an older version may be gone. + */ + private async withPartsOfFlushedDraft(attachments: T[] | undefined, draftId: string | undefined, accountId: string | undefined): Promise { + const offline = getOfflineHooks(); + if (!draftId || !isStandInId(draftId) || !offline?.resolveStandIns || this.offlineOnly || offline.skipNetwork(this)) return attachments; + const resolved = await offline.resolveStandIns(this, [draftId]); + return resolved[draftId] ? this.withPartsOfDraft(attachments, draftId, accountId) : attachments; + } + async sendEmail( to: string[], subject: string, @@ -5246,9 +5308,12 @@ export class JMAPClient implements IJMAPClient { // one (see createDraft). `messageId` and `existingCopyId` come from the // offline outbox: a Message-ID fixed when the message was queued, and the // copy already on the server that only needs submitting (lib/store/outbox/dedupe.ts). - options?: SubmissionSendOptions & { draftAccountId?: string; messageId?: string; existingCopyId?: string } + options?: SubmissionSendOptions & { draftAccountId?: string; messageId?: string; existingCopyId?: string; partsRepointed?: boolean } ): Promise { const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined; + if (!options?.partsRepointed && !options?.existingCopyId) { + attachments = await this.beforeSubmission(() => this.withPartsOfFlushedDraft(attachments, draftId, options?.draftAccountId)); + } const emailId = `send-${Date.now()}`; const targetAccountId = await this.beforeSubmission(() => this.resolveSendAccountId(fromEmail)); const mboxResp = await this.beforeSubmission(() => this.request([ @@ -5526,6 +5591,14 @@ export class JMAPClient implements IJMAPClient { if (failure) { console.error('[sendEmail] JMAP method error:', failure); await this.discardUnsentCopy(draftCopyId, targetAccountId); + // A part that died with its message: nothing went out, so once more with + // the parts the draft has now. + if (failure.type === 'blobNotFound' && draftId && !options?.partsRepointed) { + const fresh = await this.withPartsOfDraft(attachments, draftId, options?.draftAccountId); + if (fresh !== attachments) { + return this.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, fresh, inReplyTo, references, delayedUntil, envelopeMailFrom, { ...options, partsRepointed: true }); + } + } const propsHint = failure.properties?.length ? ` (properties: ${failure.properties.join(', ')})` : ''; throw jmapError(`${failure.description || `Failed to send email: ${failure.type}`}${propsHint}`, failure); } @@ -5639,12 +5712,16 @@ export class JMAPClient implements IJMAPClient { // a filing warning rather than a failed send (#849). The submission // record rides along, and nothing about it may turn into a warning. if (draftId && createdEmailId) { + // A draft still on the device is answered by the device, which can't + // remove a record on the server: that one goes on its own. + const recordAlongside = submissionToDestroy && !isStandInId(draftId) ? submissionToDestroy : undefined; + if (submissionToDestroy && !recordAlongside) await this.destroyEmailSubmissions([submissionToDestroy], submissionAccountId); try { const cleanupCalls: JMAPMethodCall[] = [ ["Email/set", { accountId: options?.draftAccountId || this.accountId, destroy: [draftId] }, "0"], ]; - if (submissionToDestroy) { - cleanupCalls.push(submissionDestroyCall([submissionToDestroy], submissionAccountId, "1")); + if (recordAlongside) { + cleanupCalls.push(submissionDestroyCall([recordAlongside], submissionAccountId, "1")); } const destroyResponse = await this.request(cleanupCalls); const destroyResult = destroyResponse.methodResponses?.find(([, , callId]) => callId === "0")?.[1]; @@ -5653,7 +5730,7 @@ export class JMAPClient implements IJMAPClient { const first = Object.values(destroyResult.notDestroyed as Record)[0]; filingError = filingError ?? (first?.description || first?.type || 'old draft cleanup failed'); } - if (submissionToDestroy) readSubmissionDestroy(destroyResponse, "1"); + if (recordAlongside) readSubmissionDestroy(destroyResponse, "1"); } catch (err) { console.error('[sendEmail] old draft cleanup failed:', err); filingError = filingError ?? 'old draft cleanup failed'; @@ -6316,6 +6393,8 @@ export class JMAPClient implements IJMAPClient { accountId?: string; onProgress?: (loaded: number, total: number) => void; signal?: AbortSignal; + /** A file already held on this device that's on its way up: an outage is an error, not a second copy. */ + noHold?: boolean; }, ): Promise<{ blobId: string; size: number; type: string }> { const opts = @@ -6338,7 +6417,7 @@ export class JMAPClient implements IJMAPClient { // is uploaded when the message it belongs to is sent from the outbox. const offline = getOfflineHooks(); if (this.offlineOnly || offline?.skipNetwork(this)) { - const held = offline ? await offline.holdUpload(this, file, targetAccountId) : null; + const held = offline && !opts.noHold ? await offline.holdUpload(this, file, targetAccountId) : null; if (held) return held; throw new OfflineUnavailableError(); } @@ -6373,7 +6452,7 @@ export class JMAPClient implements IJMAPClient { // The server is gone though the browser says it's online (#523): the // file waits on this device as when offline. Asking whether it is // gone tells an outage from a refusal (a too-large file, a quota). - if (offline && !opts.signal?.aborted && await offline.serverGone(this)) { + if (offline && !opts.noHold && !opts.signal?.aborted && await offline.serverGone(this)) { const held = await offline.holdUpload(this, file, targetAccountId); if (held) return held; } @@ -6554,7 +6633,8 @@ export class JMAPClient implements IJMAPClient { private async offlineAwareDownload(url: string, blobId: string, accountId: string | undefined): Promise { const offline = getOfflineHooks(); const account = accountId || this.accountId; - if (this.offlineOnly || offline?.skipNetwork(this)) { + // A file attached offline only exists on this device, whatever the network does. + if (this.offlineOnly || isLocalBlobId(blobId) || offline?.skipNetwork(this)) { const local = offline ? await offline.blob(this, blobId, account) : null; if (local) return local; throw new OfflineUnavailableError(); diff --git a/lib/jmap/offline-hooks.ts b/lib/jmap/offline-hooks.ts index fed004080..8330f462e 100644 --- a/lib/jmap/offline-hooks.ts +++ b/lib/jmap/offline-hooks.ts @@ -29,8 +29,12 @@ export interface OfflineClientRef { export interface OfflineHooks { /** Answer without trying the network: the browser says it is offline (or the client was started offline). */ skipNetwork(client: OfflineClientRef): boolean; - /** An answer from the device for a request the network couldn't take, or null. */ - answer(client: OfflineClientRef, methodCalls: OfflineMethodCall[]): Promise; + /** + * An answer from the device for a request the network couldn't take, or null. + * `networkTried`: the request went out and its connection broke, so the server + * may have taken it even though no answer came. + */ + answer(client: OfflineClientRef, methodCalls: OfflineMethodCall[], options?: { networkTried?: boolean }): Promise; /** What the server answered, for the cache. */ record(client: OfflineClientRef, methodCalls: OfflineMethodCall[], response: OfflineResponse): void; /** A request reached the server (true) or failed on the network (false). */ @@ -61,6 +65,11 @@ export interface OfflineHooks { * ids as they are. */ resolveStandIns?(client: OfflineClientRef, ids: string[]): Promise>; + /** + * Files attached offline, uploaded now that the server answers, for a save + * that names them. Throws SendNotStartedError when the upload hits an outage. + */ + resolveHeldAttachments?(client: OfflineClientRef, attachments: T[], accountId?: string): Promise; /** The user opened this file: the local store keeps recently opened files. */ fileOpened?(client: OfflineClientRef, accountId: string, id: string): void; } diff --git a/lib/jmap/outgoing-body.ts b/lib/jmap/outgoing-body.ts index ff7b7944d..0f0dbbc59 100644 --- a/lib/jmap/outgoing-body.ts +++ b/lib/jmap/outgoing-body.ts @@ -40,6 +40,38 @@ export function attachmentParts(attachments: OutgoingAttachment[]): OutgoingBody return attachments.map(attachmentPart); } +/** A part of a saved message, as Email/get lists it. */ +export interface SavedPart { + blobId?: string | null; + name?: string | null; + size?: number; +} + +/** + * The attachments of a message pointed at the parts of its saved version, by name + * and size. Every new message gets new part ids, and old ones die with their + * message. One whose size isn't known goes by name. `keep` leaves one as it is. + */ +export function repointAttachments( + attachments: T[], + parts: readonly SavedPart[], + keep: (attachment: T) => boolean, +): T[] { + const free = parts.filter((part) => part.blobId); + const take = (test: (part: SavedPart) => boolean): SavedPart | undefined => { + const at = free.findIndex(test); + return at < 0 ? undefined : free.splice(at, 1)[0]; + }; + // What's still alive keeps its part, so a file of the same name can't take it. + const alive = attachments.filter((attachment) => !keep(attachment) && take((part) => part.blobId === attachment.blobId)); + return attachments.map((attachment) => { + if (keep(attachment) || alive.includes(attachment)) return attachment; + const found = take((part) => part.name === attachment.name && part.size === attachment.size) + ?? (attachment.size ? undefined : take((part) => part.name === attachment.name)); + return found ? { ...attachment, blobId: found.blobId as string } : attachment; + }); +} + function referencedByHtml(html: string, cid: string): boolean { const ref = `cid:${normalizeCid(cid)}`.toLowerCase(); const lower = html.toLowerCase(); diff --git a/lib/jmap/stand-in-ids.ts b/lib/jmap/stand-in-ids.ts index 00da263e6..302664c72 100644 --- a/lib/jmap/stand-in-ids.ts +++ b/lib/jmap/stand-in-ids.ts @@ -1,7 +1,8 @@ /** - * Stand-in ids: what the local store hands out for an event created offline. + * Stand-in ids: what the local store hands out for an event or a draft created + * offline. * - * A page can hold one long after the event reached the server: an open dialog, + * A page can hold one long after the record reached the server: an open dialog, * a list not refetched yet, a plugin. So the client swaps them for the real ids * on the way to the network, and swaps them back in the answer, because callers * look up the outcome under the id they asked about. @@ -19,14 +20,17 @@ export function newStandInId(): string { return `${STAND_IN_PREFIX}${crypto.randomUUID()}`; } +const SET_METHODS = new Set(['CalendarEvent/set', 'Email/set']); +const GET_METHODS = new Set(['CalendarEvent/get', 'Email/get']); + /** The stand-in ids a request names. */ export function standInsIn(calls: readonly OfflineMethodCall[]): string[] { const found = new Set(); for (const [name, args] of calls) { - if (name === 'CalendarEvent/set') { + if (SET_METHODS.has(name)) { for (const id of Object.keys((args.update ?? {}) as object)) if (isStandInId(id)) found.add(id); for (const id of (args.destroy ?? []) as string[]) if (isStandInId(id)) found.add(id); - } else if (name === 'CalendarEvent/get') { + } else if (GET_METHODS.has(name)) { for (const id of (args.ids ?? []) as string[]) if (isStandInId(id)) found.add(id); } } @@ -42,13 +46,13 @@ export function withRealIds(calls: readonly T[], re const rename = (id: string) => real[id] ?? id; return calls.map((call) => { const [name, args, callId] = call; - if (name === 'CalendarEvent/set') { + if (SET_METHODS.has(name)) { const next = { ...args }; if (args.update) next.update = renamedKeys(args.update as Record, rename); if (args.destroy) next.destroy = (args.destroy as string[]).map(rename); return [name, next, callId] as unknown as T; } - if (name === 'CalendarEvent/get' && args.ids) { + if (GET_METHODS.has(name) && args.ids) { return [name, { ...args, ids: (args.ids as string[]).map(rename) }, callId] as unknown as T; } return call; @@ -62,7 +66,7 @@ export function withStandInsBack(response: T, real: R return { ...response, methodResponses: response.methodResponses.map(([name, result, callId]) => { - if (name !== 'CalendarEvent/set' && name !== 'CalendarEvent/get') return [name, result, callId]; + if (!SET_METHODS.has(name) && !GET_METHODS.has(name)) return [name, result, callId]; const next = { ...result }; for (const key of ['updated', 'notUpdated', 'notDestroyed']) { if (next[key]) next[key] = renamedKeys(next[key], rename); diff --git a/lib/offline/__tests__/drafts-flush.test.ts b/lib/offline/__tests__/drafts-flush.test.ts new file mode 100644 index 000000000..f9139cf7d --- /dev/null +++ b/lib/offline/__tests__/drafts-flush.test.ts @@ -0,0 +1,323 @@ +import 'fake-indexeddb/auto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAuthStore } from '@/stores/auth-store'; +import { useAccountStore } from '@/stores/account-store'; +import { usePolicyStore } from '@/stores/policy-store'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { setStoreBackend } from '@/lib/store/client/api'; +import { FakeJmapDrafts } from '@/lib/store/__tests__/helpers/fake-drafts'; +import { JMAP_ACCOUNT } from '@/lib/store/__tests__/helpers/fake-jmap'; +import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store'; +import { consumeLost } from '../lost'; +import { offlineNotify, refreshQueueState, setOfflineNotifier, startOfflineMode, unsentCount, wipeAccount, wipeEverything } from '../offline-mode'; +import { flushQueue, forgetOrphanCheck } from '../flush'; +import { heldFile, holdFile, noteDraftReplacedElsewhere, queueSend, readLocalDrafts, retryLocalDraft } from '../queue'; +import { forgetLegacyMove } from '../migrate-legacy'; +import { useOfflineStore, writeEnabled } from '../state'; +import type { JMAPClient } from '@/lib/jmap/client'; + +let server: FakeJmapDrafts; +let store: InProcessStore; + +function fakeClient(overrides: Record = {}) { + return { + getServerUrl: () => 'https://mail.example.org', + getUsername: () => 'alice@example.org', + getAccountId: () => JMAP_ACCOUNT, + isOfflineOnly: () => false, + getSessionSnapshot: () => null, + getAuthHeader: () => 'Basic x', + keywordSortInverted: () => null, + sendEmail: vi.fn(async () => ({ scheduled: false, emailId: 'sent-1', emailSubmissionId: 'sub-1' })), + uploadBlob: vi.fn(async (file: File) => server.upload(JMAP_ACCOUNT, new Uint8Array(await file.arrayBuffer()))), + requestNetworkOnly: vi.fn((calls: never) => server.request(calls)), + resolveSendAccountId: vi.fn(async () => JMAP_ACCOUNT), + submissionAccountFor: () => JMAP_ACCOUNT, + pruneEmailSubmissions: vi.fn(async () => {}), + ...overrides, + }; +} + +let stop: () => void = () => {}; + +async function saveOffline(subject: string, attachments?: Array>): Promise { + const answer = await store.answer('acc', [['Email/set', { + accountId: JMAP_ACCOUNT, + create: { + c: { + messageId: [`${subject}@example.org`], + subject, + keywords: { $seen: true, $draft: true }, + mailboxIds: { drafts: true }, + bodyValues: { 1: { value: 'text' } }, + textBody: [{ partId: '1' }], + ...(attachments ? { attachments } : {}), + }, + }, + }, 's']]); + return answer?.methodResponses[0][1].created.c.id as string; +} + +beforeEach(async () => { + server = new FakeJmapDrafts({ mailboxes: [{ id: 'inbox', role: 'inbox' }, { id: 'drafts', role: 'drafts' }, { id: 'sent', role: 'sent' }] }); + server.now = Date.now(); + store = new InProcessStore(server); + setStoreBackend(store); + forgetLegacyMove(); + forgetOrphanCheck(); + localStorage.clear(); + useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, conflicts: [], localDrafts: [], browserOffline: false, downServers: [], startedOffline: false }); + usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } }); + useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] }); + const client = fakeClient(); + useAuthStore.setState({ client: client as never, activeAccountId: 'acc' }); + writeEnabled(['acc']); + stop = startOfflineMode(); + await store.sync('acc'); +}); + +afterEach(async () => { + stop(); + await wipeEverything(); + setStoreBackend(null); +}); + +describe('flushQueue and drafts', () => { + it('uploads a draft\'s files, creates it, tells the page, and leaves nothing behind', async () => { + const client = fakeClient(); + const held = await holdFile('acc', new File(['hello'], 'note.txt', { type: 'text/plain' }), 'note.txt'); + await saveOffline('With a file', [{ blobId: held.blobId, type: 'text/plain', name: 'note.txt' }]); + const onDraftSent = vi.fn(); + await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true, onDraftSent }); + expect(server.drafts().map((email) => email.subject)).toEqual(['With a file']); + expect(client.uploadBlob).toHaveBeenCalledWith(expect.any(File), { accountId: JMAP_ACCOUNT, noHold: true }); + expect(onDraftSent).toHaveBeenCalledTimes(1); + expect(await readLocalDrafts('acc')).toEqual([]); + }); + + it('holds a large file in pieces and reads it back whole', async () => { + const size = 2 * 1024 * 1024 + 3; + const held = await holdFile('acc', new File([new Uint8Array(size).fill(4)], 'big.bin'), 'big.bin'); + expect(held.size).toBe(size); + const area = vi.spyOn(store, 'area'); + const file = await heldFile('acc', held.blobId); + expect(file?.name).toBe('big.bin'); + expect(file?.size).toBe(size); + expect(new Uint8Array(await (file as File).arrayBuffer()).every((value) => value === 4)).toBe(true); + area.mockRestore(); + }); + + it('tells the user about a refused draft, keeps it, and lists it with its error', async () => { + const client = fakeClient(); + server.refuseCreate = { type: 'overQuota', description: 'No room' }; + const id = await saveOffline('Too much'); + const notify = vi.fn(); + await flushQueue('acc', client as unknown as JMAPClient, { notify, allowed: () => true }); + expect(notify).toHaveBeenCalledWith({ kind: 'draft-refused', subject: 'Too much', error: { type: 'overQuota', text: 'No room' } }); + await refreshQueueState(); + expect(useOfflineStore.getState().localDrafts).toMatchObject([{ account: 'acc', localId: id, status: 'refused' }]); + + server.refuseCreate = null; + await retryLocalDraft('acc', id); + await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect(server.drafts()).toHaveLength(1); + }); + + it('stops on an outage and carries on with what waited after it', async () => { + const client = fakeClient(); + await saveOffline('One'); + server.offline = true; + const sendEmail = client.sendEmail; + const entry = await queueSend('acc', { subject: 'Queued', to: [] }, { kind: 'email', args: [['b@example.org'], 'Queued', 'x'] }); + await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect(sendEmail).not.toHaveBeenCalled(); + server.offline = false; + await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect(server.drafts()).toHaveLength(1); + expect(sendEmail).toHaveBeenCalledTimes(1); + expect(entry).toBeGreaterThan(0); + }); + + it('sends a draft kept here through the send path with its files, and the draft doesn\'t go up as well', async () => { + const held = await holdFile('acc', new File(['hello'], 'note.txt', { type: 'text/plain' }), 'note.txt'); + const id = await saveOffline('To send', [{ blobId: held.blobId, type: 'text/plain', name: 'note.txt' }]); + const client = fakeClient({ + // As the client does: the draft goes once the message is out. + sendEmail: vi.fn(async () => { + await store.answer('acc', [['Email/set', { accountId: JMAP_ACCOUNT, destroy: [id] }, 'd']]); + return { scheduled: false, emailId: 'sent-1', emailSubmissionId: 'sub-1' }; + }), + }); + await queueSend('acc', { subject: 'To send', to: [] }, { + kind: 'email', + args: [['b@example.org'], 'To send', 'x', undefined, undefined, 'i', 'alice@example.org', id, undefined, undefined, [{ blobId: held.blobId, name: 'note.txt', type: 'text/plain', size: 5 }]], + }); + await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect(client.sendEmail).toHaveBeenCalledTimes(1); + expect(((client.sendEmail as ReturnType).mock.calls[0] as unknown[])[10]).toMatchObject([{ blobId: server.uploads[0].blobId }]); + expect(server.drafts()).toEqual([]); + expect(await readLocalDrafts('acc')).toEqual([]); + }); + + it('drops the draft of a send the lookup found went out', async () => { + const id = await saveOffline('Went out'); + const client = fakeClient(); + server.addEmail({ id: 'in-sent', subject: 'Went out', mailboxIds: { sent: true }, messageId: ['fixed@example.org'], receivedAt: new Date().toISOString() }); + const entryId = await queueSend('acc', { subject: 'Went out', to: [] }, { + kind: 'email', + args: [['b@example.org'], 'Went out', 'x', undefined, undefined, 'i', 'alice@example.org', id], + }, undefined, 'fixed@example.org'); + await store.area('acc', 'outbox.claim', { id: entryId }); + await store.area('acc', 'outbox.update', { id: entryId, status: 'uncertain' }); + await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect(await readLocalDrafts('acc')).toEqual([]); + expect(server.drafts()).toEqual([]); + }); + + it('cleans held files nothing needs, only long after', async () => { + const client = fakeClient(); + const orphan = await holdFile('acc', new File(['a'], 'a.txt'), 'a.txt'); + await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect(await heldFile('acc', orphan.blobId)).not.toBeNull(); + const now = Date.now(); + const clock = vi.spyOn(Date, 'now').mockReturnValue(now + 8 * 24 * 60 * 60 * 1000); + await flushQueue('acc', client as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + clock.mockRestore(); + expect(await heldFile('acc', orphan.blobId)).toBeNull(); + }); +}); + +describe('a notice before the host is there', () => { + it('waits for it, and comes once', async () => { + vi.resetModules(); + const fresh = await import('../offline-mode'); + fresh.offlineNotify({ kind: 'store-reset' }); + const show = vi.fn(); + fresh.setOfflineNotifier(show); + expect(show).toHaveBeenCalledTimes(1); + expect(show).toHaveBeenCalledWith({ kind: 'store-reset' }); + fresh.setOfflineNotifier(vi.fn()); + expect(show).toHaveBeenCalledTimes(1); + vi.resetModules(); + }); + + it('goes straight to a host that\'s there', () => { + const show = vi.fn(); + setOfflineNotifier(show); + offlineNotify({ kind: 'store-reset' }); + expect(show).toHaveBeenCalledTimes(1); + }); +}); + +describe('what a sign-out asks about', () => { + it('counts a draft only on this device', async () => { + await saveOffline('Only here'); + expect(await unsentCount('acc')).toMatchObject({ messages: 1 }); + }); + + it('counts a draft a waiting send names once, as that send', async () => { + const id = await saveOffline('Being sent'); + await queueSend('acc', { subject: 'Being sent', to: [] }, { kind: 'email', args: [['b@example.org'], 'Being sent', 'x', undefined, undefined, 'i', 'alice@example.org', id] }); + expect(await unsentCount('acc')).toMatchObject({ messages: 1 }); + }); +}); + +describe('a wipe and local drafts', () => { + it('names them as lost when the user didn\'t agree', async () => { + // A second account stays, so the wipe doesn't take the whole store. + await saveOffline('Only here'); + await refreshQueueState(); + consumeLost(); + await wipeAccount('acc'); + expect(consumeLost()).toEqual(['Only here']); + }); +}); + +describe('a flush that stops', () => { + it('tries again soon when a passing failure stopped it', async () => { + await saveOffline('One'); + server.offline = true; + const retryLater = vi.fn(); + await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true, retryLater }); + expect(retryLater).toHaveBeenCalled(); + }); + + it('doesn\'t when nothing stopped it', async () => { + await saveOffline('One'); + const retryLater = vi.fn(); + await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true, retryLater }); + expect(retryLater).not.toHaveBeenCalled(); + }); +}); + +describe('a reload in the middle of a flush', () => { + const nav = navigator as Navigator & { locks?: unknown }; + afterEach(() => { + Object.defineProperty(nav, 'locks', { configurable: true, value: undefined }); + }); + + async function leftSending() { + const id = await saveOffline('Left on the way'); + expect(await store.area('acc', 'drafts.claim', { localId: id })).toBe(true); + return id; + } + + it('goes on at once under the flush lock: nobody else can be sending it', async () => { + Object.defineProperty(nav, 'locks', { configurable: true, value: { request: (_name: string, run: () => Promise) => run() } }); + await leftSending(); + await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect(server.drafts()).toHaveLength(1); + }); + + it('waits the minute without Web Locks, because another tab may be on it', async () => { + await leftSending(); + await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect(server.drafts()).toEqual([]); + }); +}); + +describe('an old version in another login', () => { + function clients() { + const other = { ...fakeClient(), deleteEmail: vi.fn(async () => {}) }; + useAuthStore.setState({ getClientForAccount: ((id: string) => (id === 'other' ? other : undefined)) as never }); + return other; + } + + it('stays until the new version is on the server, then goes through its own login', async () => { + const other = clients(); + server.addEmail({ id: 'R', subject: 'Elsewhere', mailboxIds: { drafts: true }, keywords: { $draft: true } }); + const id = await saveOffline('New'); + await noteDraftReplacedElsewhere('acc', id, { login: 'other', jmap: JMAP_ACCOUNT, id: 'R' }); + expect(other.deleteEmail).not.toHaveBeenCalled(); + server.refuseCreate = { type: 'overQuota' }; + await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect(other.deleteEmail).not.toHaveBeenCalled(); + server.refuseCreate = null; + await retryLocalDraft('acc', id); + await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect(other.deleteEmail).toHaveBeenCalledWith('R', JMAP_ACCOUNT); + }); + + it('stays when it\'s no longer a draft there', async () => { + const other = clients(); + server.addEmail({ id: 'R', subject: 'Sent elsewhere', mailboxIds: { drafts: true }, keywords: {} }); + const id = await saveOffline('New'); + await noteDraftReplacedElsewhere('acc', id, { login: 'other', jmap: JMAP_ACCOUNT, id: 'R' }); + await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect(other.deleteEmail).not.toHaveBeenCalled(); + }); + + it('carries over to the next save, and waits for that login to be there', async () => { + useAuthStore.setState({ getClientForAccount: (() => undefined) as never }); + server.addEmail({ id: 'R', subject: 'Elsewhere', mailboxIds: { drafts: true }, keywords: { $draft: true } }); + const first = await saveOffline('First'); + await noteDraftReplacedElsewhere('acc', first, { login: 'other', jmap: JMAP_ACCOUNT, id: 'R' }); + const second = await saveOffline('Second'); + await store.answer('acc', [['Email/set', { accountId: JMAP_ACCOUNT, destroy: [first], replacedBy: second }, 'd']]); + await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect(server.emails.has('R')).toBe(true); + const other = clients(); + await flushQueue('acc', fakeClient() as unknown as JMAPClient, { notify: vi.fn(), allowed: () => true }); + expect(other.deleteEmail).toHaveBeenCalledWith('R', JMAP_ACCOUNT); + }); +}); diff --git a/lib/offline/__tests__/migrate-legacy.test.ts b/lib/offline/__tests__/migrate-legacy.test.ts index 90ac665f9..e05d2c692 100644 --- a/lib/offline/__tests__/migrate-legacy.test.ts +++ b/lib/offline/__tests__/migrate-legacy.test.ts @@ -124,9 +124,9 @@ describe('moving the old outbox into the store', () => { expect(outbox[0].messageId).toMatch(/@example\.org$/); expect(outbox[1].messageId).toBeUndefined(); expect(outbox[3].messageId).toBe('raw-1@example.org'); - const file = await store.area<{ name: string; data: Uint8Array }>('acc', 'outbox.heldFile', { blobId: 'bulwark-local-raw' }); + const file = await store.area<{ name: string; blob: Blob }>('acc', 'outbox.heldFile', { blobId: 'bulwark-local-raw' }); expect(file.name).toBe('message.eml'); - expect(new TextDecoder().decode(file.data)).toBe(RAW); + expect(await file.blob.text()).toBe(RAW); expect(await oldExists()).toBe(false); expect(store.has('stranger')).toBe(false); }); diff --git a/lib/offline/flush.ts b/lib/offline/flush.ts index 3aa05b411..812d91ddd 100644 --- a/lib/offline/flush.ts +++ b/lib/offline/flush.ts @@ -29,11 +29,15 @@ import type { JMAPClient } from '@/lib/jmap/client'; import { isNetworkFailure } from '@/lib/jmap/offline-hooks'; import type { SendEmailResult } from '@/lib/jmap/types'; +import { isStandInId } from '@/lib/jmap/stand-in-ids'; +import { useAuthStore } from '@/stores/auth-store'; +import type { DraftElsewhere } from '@/lib/store/drafts/calls'; import { getStore } from '@/lib/store/client/api'; +import { flushDrafts, type DraftFlushIo } from '@/lib/store/drafts/flush'; import { isStorageFailure } from '@/lib/store/storage'; import type { MethodCall } from '@/lib/store/engine/transport'; import { decideRetry, type LookupIo, type RetryDecision } from '@/lib/store/outbox/dedupe'; -import { flushPatches, type PatchIo, type PatchOutcome, type PendingPatch } from '@/lib/store/outbox/flush-patches'; +import { flushPatches, isRequestRefusal, type PatchIo, type PatchOutcome, type PendingPatch } from '@/lib/store/outbox/flush-patches'; import { claimForSending, heldFile, @@ -56,6 +60,8 @@ export interface FlushDeps { onScheduled?: (entry: OutboxEntry, result: SendEmailResult) => void; /** A send's connection broke though the server may be fine: look it up again soon. */ retryLater?: () => void; + /** A draft saved on this device reached the server: lists and open pages read again. */ + onDraftSent?: () => void; } /** A message that just went may not show in a lookup yet, which would send it twice. */ @@ -113,7 +119,7 @@ async function uploadHeld(account: string, client: JMAPClient, blobId: string): if (!file) throw new OutboxPrepError('file_missing'); let uploaded: string; try { - uploaded = (await client.uploadBlob(file)).blobId; + uploaded = (await client.uploadBlob(file, { noHold: true })).blobId; } catch (error) { if (isNetworkFailure(error)) throw notStartedError('upload failed'); throw error; @@ -167,8 +173,8 @@ async function sendEntry(account: string, client: JMAPClient, entry: OutboxEntry /** Sends what waits for an account, one tab at a time. */ export async function flushQueue(account: string, client: JMAPClient, deps: FlushDeps): Promise { - const run = () => flushLocked(account, client, deps); const locks = typeof navigator !== 'undefined' ? (navigator as Navigator & { locks?: LockManager }).locks : undefined; + const run = () => flushLocked(account, client, deps, Boolean(locks?.request)); if (locks?.request) { await locks.request(`bulwark-offline-flush:${account}`, run); return; @@ -200,6 +206,52 @@ export async function settleStaleSends(account?: string, now = Date.now()): Prom await Promise.allSettled(accounts.map((id) => getStore().area(id, 'outbox.settleStale', { before: now - SENDING_STALE_MS }))); } +/** The drafts saved on this device. False when the run stopped on an outage. */ +function sendDrafts(account: string, client: JMAPClient, deps: FlushDeps, locked: boolean): Promise { + const store = getStore(); + const io: DraftFlushIo = { + store: { + // Under the lock, what says it's on its way was left by a tab that's gone. + read: () => store.area(account, 'drafts.read', { before: locked ? Date.now() + 1 : Date.now() - SENDING_STALE_MS }), + claim: (localId) => store.area(account, 'drafts.claim', { localId }), + failed: (localId, status, error, neverSent) => store.area(account, 'drafts.failed', { localId, status, error, neverSent }), + created: (result) => store.area(account, 'drafts.created', result), + ghostsLooked: (jmap, messageIds, found) => store.area(account, 'drafts.ghostsLooked', { jmap, messageIds, found }), + }, + request: (calls, using) => client.requestNetworkOnly(calls, using), + upload: async (jmap, blobId) => { + const file = await heldFile(account, blobId); + return file ? (await client.uploadBlob(file, { accountId: jmap, noHold: true })).blobId : null; + }, + // An upload's refusal has no JMAP problem document, only its status. + isRefusal: (error) => isRequestRefusal(error) || (error instanceof Error && /^Failed to upload file: (400|403|404|413|422)\b/.test(error.message)), + onRefused: (subject, error) => deps.notify({ kind: 'draft-refused', subject, error }), + onConflict: (subject) => deps.notify({ kind: 'draft-conflict', subject }), + onSent: () => deps.onDraftSent?.(), + }; + return flushDrafts(io); +} + +/** + * Old versions in another login, now that their replacement is up. Best effort, + * and only while still a draft there: an old copy is the worst that can come of it. + */ +async function removeVersionsElsewhere(account: string): Promise { + const store = getStore(); + for (const row of await store.area(account, 'drafts.elsewhereReady')) { + const other = useAuthStore.getState().getClientForAccount?.(row.login); + if (!other) continue; + try { + const response = await other.requestNetworkOnly([['Email/get', { accountId: row.jmap ?? other.getAccountId(), ids: [row.recordId], properties: ['keywords'] }, '0']]); + const email = response.methodResponses?.[0]?.[1]?.list?.[0] as { keywords?: Record } | undefined; + if (email?.keywords?.$draft) await other.deleteEmail(row.recordId, row.jmap ?? undefined); + await store.area(account, 'drafts.elsewhereDone', { id: row.id }); + } catch { + // That login's server may be down too. It waits. + } + } +} + /** The changes queued before `upToSeq` (all, with null). False when the run stopped on an outage. */ async function sendPatches(account: string, client: JMAPClient, deps: FlushDeps, upToSeq: number | null): Promise { const pending = await getStore().area(account, 'outbox.pendingPatches', { upToSeq }); @@ -232,8 +284,14 @@ async function lookUp(client: JMAPClient, entry: OutboxEntry): Promise { if (entry.item.kind !== 'email' || typeof entry.item.args[7] !== 'string') return; const options = entry.item.args[15] as { draftAccountId?: string } | undefined; + const jmap = options?.draftAccountId || client.getAccountId(); try { - await client.requestNetworkOnly([['Email/set', { accountId: options?.draftAccountId || client.getAccountId(), destroy: [entry.item.args[7]] }, '0']]); + if (isStandInId(entry.item.args[7])) { + // Never reached the server: it only has to stop going there. + await getStore().area(entry.account, 'drafts.discard', { jmap, id: entry.item.args[7] }); + return; + } + await client.requestNetworkOnly([['Email/set', { accountId: jmap, destroy: [entry.item.args[7]] }, '0']]); } catch { // An orphan in Drafts, nothing worse. } @@ -278,11 +336,19 @@ async function settleUncertain(account: string, client: JMAPClient, deps: FlushD return true; } -async function flushLocked(account: string, client: JMAPClient, deps: FlushDeps): Promise { +async function flushLocked(account: string, client: JMAPClient, deps: FlushDeps, locked: boolean): Promise { if (flushing.has(account) || !deps.allowed()) return; flushing.add(account); try { await settleStaleSends(account); + // A draft goes before the changes that may be waiting: what it replaces + // only starts to go once it's there. + if (!(await sendDrafts(account, client, deps, locked))) { + // Nothing else starts it again if the server only stumbled. + if (!stillOffline()) deps.retryLater?.(); + return; + } + await removeVersionsElsewhere(account); if (!(await settleUncertain(account, client, deps))) return; // In the order they were made: a reply's $answered, queued after the // reply, goes after it. diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 8966c614b..08237e835 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -31,6 +31,7 @@ import { getSendGuardSource, guardState, guardsSending } from '@/lib/plugin-send import { emitPushStateChange } from '@/lib/push-events'; import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync'; 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'; import { purgeStoreFiles } from '@/lib/store/client/purge'; import type { StoreSession } from '@/lib/store/engine/transport'; @@ -40,7 +41,7 @@ import { useEmailStore } from '@/stores/email-store'; import { usePolicyStore, whenPolicySettled } from '@/stores/policy-store'; import { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup'; import { generateMessageId } from '@/lib/jmap/client'; -import { SendNotStartedError, setOfflineHooks, type OfflineClientRef, type OfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks'; +import { isNetworkFailure, SendNotStartedError, setOfflineHooks, type OfflineClientRef, type OfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks'; import type { SendEmailResult } from '@/lib/jmap/types'; import { setOutboxHooks, type OutboxHooks } from './outbox-hooks'; import { @@ -48,6 +49,7 @@ import { holdFile, isLocalBlobId, queueSend, + readLocalDrafts, readQueue, type OutboxEntry, type OutboxError, @@ -97,9 +99,11 @@ export type OfflineNotice = | { kind: 'held'; subject: string } | { kind: 'lost'; subjects: string[] } | { kind: 'conflict'; count: number } + | { kind: 'draft-refused'; subject: string; error: DraftError } + | { kind: 'draft-conflict'; subject: string } | { kind: 'storage-failed' } | { kind: 'store-unavailable' } - | { kind: 'store-reset'; lost: number } + | { kind: 'store-reset'; lost?: number } | { kind: 'legacy-stranded' } | { kind: 'default-on' } | { kind: 'refused-delete' } @@ -107,19 +111,22 @@ export type OfflineNotice = | { kind: 'forbidden' } | { kind: 'back-online-reload' }; +let notifier: ((notice: OfflineNotice) => void) | null = null; /** - * The auth store opens the local store at start, and that can be before the - * host component has mounted: what's said until then waits for it. + * The store opens at a start from the local copy, before the host component has + * mounted. What it has to say by then, like "this store couldn't be read", waits. */ const earlyNotices: OfflineNotice[] = []; -const holdNotice = (notice: OfflineNotice) => { - if (earlyNotices.length < 20) earlyNotices.push(notice); -}; -let notify: (notice: OfflineNotice) => void = holdNotice; +const MOST_EARLY_NOTICES = 10; -/** Where the notices go (the host component turns them into toasts). Null: none is there at the moment. */ +function notify(notice: OfflineNotice): void { + if (notifier) notifier(notice); + else if (earlyNotices.length < MOST_EARLY_NOTICES) earlyNotices.push(notice); +} + +/** Where the notices go (the host component turns them into toasts). Null: nothing is there at the moment. */ export function setOfflineNotifier(fn: ((notice: OfflineNotice) => void) | null): void { - notify = fn ?? holdNotice; + notifier = fn; if (fn) for (const notice of earlyNotices.splice(0)) fn(notice); } @@ -316,12 +323,16 @@ const hooks: OfflineHooks = { if (typeof navigator === 'undefined' || navigator.onLine !== false) return false; return offlineActive(accountForClient(client)); }, - async answer(client, methodCalls) { + async answer(client, methodCalls, options) { const account = accountForClient(client); if (!offlineActive(account) || !account) return null; let answered: OfflineResponse | null = null; try { - answered = await getStore().answer(account, methodCalls); + // A draft whose connection broke may be on the server already. + const calls = options?.networkTried + ? methodCalls.map((call): OfflineMethodCall => (call[0] === 'Email/set' && call[1].create ? [call[0], { ...call[1], networkTried: true }, call[2]] : call)) + : methodCalls; + answered = await getStore().answer(account, calls); } catch { // The store couldn't start or lost its owner: as if nothing were kept. } @@ -335,7 +346,12 @@ const hooks: OfflineHooks = { async resolveStandIns(client, ids) { const account = accountForClient(client); if (!offlineActive(account) || !account) return {}; - return getStore().area>(account, 'calendar.resolveStandIns', { ids }).catch(() => ({})); + const ask = (call: string) => getStore().area>(account, call, { ids }).catch(() => ({} as Record)); + const [drafts, events] = await Promise.all([ask('drafts.resolveStandIns'), ask('calendar.resolveStandIns')]); + return Object.fromEntries(ids.map((id) => [id, events[id] ?? drafts[id] ?? null])); + }, + resolveHeldAttachments(client, attachments, accountId) { + return resolveHeld(client, attachments, accountId); }, fileOpened(client, accountId, id) { const account = accountForClient(client); @@ -400,7 +416,8 @@ const hooks: OfflineHooks = { // An attachment, or a file from the Files area. const kept = await getStore().area(account, 'mail.getBlob', { jmap, blobId }).catch(() => null) ?? await getStore().area(account, 'files.blob', { jmap, blobId }).catch(() => null); - return kept ? new Blob([kept.data as BlobPart], { type: kept.type || 'application/octet-stream' }) : null; + if (!kept) return null; + return new Blob(kept.blob ? [kept.blob] : [kept.data as BlobPart], { type: kept.type || 'application/octet-stream' }); }, recordBlob(client, blobId, jmap, blob) { const account = accountForClient(client); @@ -418,7 +435,31 @@ const hooks: OfflineHooks = { }, }; -interface KeptBlob { type: string | null; data: Uint8Array } +interface KeptBlob { type: string | null; blob?: Blob; data?: Uint8Array } + +/** Files attached offline, uploaded now that the server answers. An outage here means nothing went anywhere. */ +async function resolveHeld(client: OfflineClientRef, attachments: T[], accountId?: string): Promise { + const account = accountForClient(client); + if (!account || !attachments.some((a) => isLocalBlobId(a.blobId))) return attachments; + const real = onlineClientFor(account); + if (!real) return attachments; + const out: T[] = []; + for (const attachment of attachments) { + if (!isLocalBlobId(attachment.blobId)) { out.push(attachment); continue; } + const file = await heldFile(account, attachment.blobId); + if (!file) throw Object.assign(new Error('A file attached offline is no longer on this device'), { name: 'OfflineFileMissingError' }); + try { + const { blobId } = await real.uploadBlob(file, { accountId, noHold: true }); + // Held again after all: that's no upload. + if (isLocalBlobId(blobId)) throw new SendNotStartedError(); + out.push({ ...attachment, blobId }); + } catch (error) { + if (isNetworkFailure(error)) throw new SendNotStartedError(error); + throw error; + } + } + return out; +} /** Attachments and images opened while online are kept up to this size. */ const MAX_RECORDED_BLOB_BYTES = 5 * 1024 * 1024; @@ -515,22 +556,8 @@ const outbox: OutboxHooks = { notify({ kind: 'queued', subject: summary.subject }); return queuedResult(id); }, - async resolveHeldAttachments(client, attachments) { - const account = accountForClient(client); - if (!account || !attachments.some((a) => isLocalBlobId(a.blobId))) return attachments; - const real = onlineClientFor(account); - if (!real) return attachments; - const out = []; - for (const attachment of attachments) { - if (!isLocalBlobId(attachment.blobId)) { out.push(attachment); continue; } - const file = await heldFile(account, attachment.blobId); - if (!file) throw Object.assign(new Error('A file attached offline is no longer on this device'), { name: 'OfflineFileMissingError' }); - const { blobId } = await real.uploadBlob(file); - // The server went away just now, and offline mode held the file again. - if (isLocalBlobId(blobId)) throw new SendNotStartedError(); - out.push({ ...attachment, blobId }); - } - return out; + resolveHeldAttachments(client, attachments) { + return resolveHeld(client, attachments); }, }; @@ -567,13 +594,13 @@ export async function refreshQueueState(): Promise { // Nothing is kept for an account that hasn't it on; and opening the // store would create it (on the sign-in page, after a sign-out). if (visibleAccounts().length === 0) { - useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [] }); + useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], localDrafts: [] }); return; } try { // Sends of a tab closed on the way: uncertain, so they can be looked up. await settleStaleSends(); - const { failed, patches, patchesBy, outbox: entries, conflicts } = await readQueue(); + const [{ failed, patches, patchesBy, outbox: entries, conflicts }, drafts] = await Promise.all([readQueue(), readLocalDrafts()]); if (failed.length > 0) noteStoreUnavailable(); const rows: OutboxRow[] = entries.map((entry) => ({ id: entry.id, @@ -585,10 +612,12 @@ export async function refreshQueueState(): Promise { error: entry.error, })); // What an account's store can't tell right now is still there. - const unread = useOfflineStore.getState().outbox.filter((row) => failed.includes(row.account)); + const shown = useOfflineStore.getState(); + const unread = shown.outbox.filter((row) => failed.includes(row.account)); + const unreadDrafts = shown.localDrafts.filter((row) => failed.includes(row.account)); const accounts = visibleAccounts(); const areas = await Promise.all(accounts.map(readAreaState)); - useOfflineStore.setState({ outbox: [...unread, ...rows], pendingChanges: patches, conflicts, areaConflicts: areas.flatMap((area) => area.conflicts) }); + useOfflineStore.setState({ outbox: [...unread, ...rows], pendingChanges: patches, conflicts, localDrafts: [...unreadDrafts, ...drafts], areaConflicts: areas.flatMap((area) => area.conflicts) }); if (rows.some((row) => row.status === 'sending')) checkStaleSendsLater(); // What a browser clearing the store would lose. Counted here, and the // count is all that's kept outside the store. A store that couldn't be @@ -738,6 +767,13 @@ const AREA_FLUSHES = [ { call: 'contacts.flush', types: ['AddressBook', 'ContactCard'] }, ]; +/** The page reads these types again. */ +function emitTypesChanged(client: NonNullable>, types: string[]): void { + const role = (useAuthStore.getState().client as unknown) === client ? 'active' : 'background'; + const changed = { [client.getAccountId()]: Object.fromEntries(types.map((type) => [type, 'flushed'])) }; + emitPushStateChange({ '@type': 'StateChange', changed } as never, client, role); +} + async function flushAreas(account: string, client: NonNullable>): Promise { for (const { call, types } of AREA_FLUSHES) { // Contacts may not be there in this build: nothing to flush then. @@ -746,9 +782,7 @@ async function flushAreas(account: string, client: NonNullable 0) notify({ kind: 'conflict', count: conflicts }); if (!tally.sent && !conflicts) continue; - const role = (useAuthStore.getState().client as unknown) === client ? 'active' : 'background'; - const changed = { [client.getAccountId()]: Object.fromEntries(types.map((type) => [type, 'flushed'])) }; - emitPushStateChange({ '@type': 'StateChange', changed } as never, client, role); + emitTypesChanged(client, types); } } @@ -779,6 +813,7 @@ export async function flushAll(): Promise { stricterNow, onScheduled: offerUndo, retryLater: () => scheduleFlushAll(LOOKUP_AGAIN_MS), + onDraftSent: () => emitTypesChanged(client, ['Email', 'Mailbox', 'Thread']), }).catch(() => {}); await flushAreas(account, client); } @@ -909,13 +944,14 @@ export function confirmLoss(scope: string): void { } async function unsentSubjects(scope: string): Promise { - let rows = useOfflineStore.getState().outbox; + const known = useOfflineStore.getState(); + let rows: Array<{ account: string; subject: string }> = [...known.outbox, ...known.localDrafts]; // Reading an account's store would create it if it never had one. - if (scope !== 'all' && !useOfflineStore.getState().enabled.includes(scope)) return rows.filter((row) => row.account === scope).map((row) => row.subject); + if (scope !== 'all' && !known.enabled.includes(scope)) return rows.filter((row) => row.account === scope).map((row) => row.subject); try { - rows = (await readQueue(scope === 'all' ? undefined : scope)).outbox.map((entry) => ({ - id: entry.id, account: entry.account, createdAt: entry.createdAt, subject: entry.summary.subject, to: entry.summary.to, status: entry.status, - })); + const wanted = scope === 'all' ? undefined : scope; + const [{ outbox: entries }, drafts] = await Promise.all([readQueue(wanted), readLocalDrafts(wanted)]); + rows = [...entries.map((entry) => ({ account: entry.account, subject: entry.summary.subject })), ...drafts]; } catch { // No store: what the state last saw. } @@ -925,19 +961,24 @@ async function unsentSubjects(scope: string): Promise { /** How long a sign-out waits for the store's word on what would be lost. */ const UNSENT_READ_MS = 2_000; -/** What a sign-out of `scope` would delete: messages, and edits the server hasn't got. */ +/** + * What a sign-out of `scope` would delete: messages, drafts only on this device, + * and edits the server hasn't got. A draft a waiting send names isn't listed as a + * draft, so it counts once. + */ export async function unsentCount(scope: string, waitMs = UNSENT_READ_MS): Promise<{ messages: number; changes: number }> { const shown = useOfflineStore.getState(); - const known = { messages: shown.outbox.filter((row) => scope === 'all' || row.account === scope).length, changes: shown.pendingChanges }; + const known = { messages: [...shown.outbox, ...shown.localDrafts].filter((row) => scope === 'all' || row.account === scope).length, changes: shown.pendingChanges }; const accounts = (scope === 'all' ? visibleAccounts() : [scope]).filter((account) => shown.enabled.includes(account)); if (accounts.length === 0) return known; const pendingIn = (account: string, area: string) => getStore().area(account, `${area}.pending`).catch(() => 0); const read = (async () => { - const { failed, outbox, patches } = await readQueue(scope === 'all' ? undefined : scope); + const wanted = scope === 'all' ? undefined : scope; + const [{ failed, outbox, patches }, drafts] = await Promise.all([readQueue(wanted), readLocalDrafts(wanted)]); const edits = await Promise.all(accounts.flatMap((account) => [pendingIn(account, 'calendar'), pendingIn(account, 'contacts')])); return { // An account that can't answer counts with what it last said. - messages: outbox.length + shown.outbox.filter((row) => failed.includes(row.account)).length, + messages: outbox.length + drafts.length + [...shown.outbox, ...shown.localDrafts].filter((row) => failed.includes(row.account)).length, changes: patches + edits.reduce((sum, count) => sum + count, 0), }; })().catch(() => known); @@ -967,7 +1008,8 @@ export async function wipeAccount(account: string): Promise { syncTimers.delete(account); // Waited for a sign-in that never came: whoever is here now mustn't read its subjects. if (isEvicted(account)) named.add(account); - if (useOfflineStore.getState().enabled.includes(account) || useOfflineStore.getState().outbox.some((row) => row.account === account)) { + const before = useOfflineStore.getState(); + if (before.enabled.includes(account) || before.outbox.some((row) => row.account === account) || before.localDrafts.some((row) => row.account === account)) { await nameLost(account, useAuthStore.getState().isAuthenticated); } named.delete(account); @@ -1009,7 +1051,7 @@ export async function wipeEverything(): Promise { clearHandoffs(); confirmed.clear(); named.clear(); - useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], usageBytes: 0, syncing: [], lastSyncedAt: {} }); + useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], localDrafts: [], usageBytes: 0, syncing: [], lastSyncedAt: {} }); } /** The organisation's word on this account, from the server or the last fetch. Defaults aren't its word. */ @@ -1309,7 +1351,7 @@ onFullSignOut(({ keepLocalStore }) => { enabled: keepLocalStore ? s.enabled : [], choices: keepLocalStore ? s.choices : {}, health: keepLocalStore ? s.health : {}, - outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], usageBytes: 0, usage: {}, + outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], localDrafts: [], usageBytes: 0, usage: {}, storageFull: {}, progress: {}, syncing: [], lastSyncedAt: {}, startedOffline: false, })); }); diff --git a/lib/offline/queue.ts b/lib/offline/queue.ts index 34f0873bf..ff6bec1e4 100644 --- a/lib/offline/queue.ts +++ b/lib/offline/queue.ts @@ -8,6 +8,7 @@ import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync'; import { getStore } from '@/lib/store/client/api'; +import type { LocalDraftSummary } from '@/lib/store/drafts/calls'; import type { ConflictSummary, OutboxError, OutboxStatus, OutboxSummary, PolicyFingerprint, SendItem, StoredConflict, StoredOutboxEntry } from '@/lib/store/outbox/items'; import { visibleAccounts } from './state'; @@ -78,20 +79,55 @@ export function removeOutboxEntry(entry: Pick): P return getStore().area(entry.account, 'outbox.remove', { id: entry.id }); } +export interface LocalDraftRow extends LocalDraftSummary { + account: string; +} + +/** Drafts that are only on this device, oldest first. */ +export async function readLocalDrafts(account?: string): Promise { + const accounts = account ? [account] : visibleAccounts(); + // An account in trouble is what readQueue reports. Its drafts are still there. + const settled = await Promise.allSettled(accounts.map(async (id) => ((await getStore().area(id, 'drafts.list')) ?? []).map((row) => ({ ...row, account: id })))); + return settled.flatMap((result) => (result.status === 'fulfilled' ? result.value : [])); +} + +/** An old version in another login: removed through it once the draft here is on the server. */ +export function noteDraftReplacedElsewhere(account: string, localId: string, version: { login: string; jmap?: string; id: string }): Promise { + return getStore().area(account, 'drafts.noteElsewhere', { localId, ...version }); +} + +/** Asks the server again about a draft it refused. */ +export function retryLocalDraft(account: string, localId: string): Promise { + return getStore().area(account, 'drafts.retry', { localId }); +} + export function dismissConflict(account: string, id: number): Promise { return getStore().area(account, 'outbox.dismissConflict', { id }); } // ---- Files attached while offline ---- +/** A file on its way to the store is cut into pieces of this size, so it's never in memory as a whole. */ +const HOLD_CHUNK_BYTES = 1024 * 1024; + /** Keeps a file under a stand-in blob id until its message is sent. */ export async function holdFile(account: string, file: Blob, name: string, jmap?: string): Promise<{ blobId: string; size: number; type: string }> { - const data = new Uint8Array(await file.arrayBuffer()); - return getStore().area(account, 'outbox.holdFile', { jmap, name, type: file.type, data }); + const store = getStore(); + const { blobId } = await store.area<{ blobId: string }>(account, 'outbox.holdStart', { jmap, name, type: file.type, size: file.size }); + try { + for (let at = 0, seq = 0; at < file.size; at += HOLD_CHUNK_BYTES, seq += 1) { + const data = new Uint8Array(await file.slice(at, at + HOLD_CHUNK_BYTES).arrayBuffer()); + await store.area(account, 'outbox.holdChunk', { blobId, seq, data }); + } + return await store.area(account, 'outbox.holdEnd', { blobId }); + } catch (error) { + await store.area(account, 'outbox.holdAbort', { blobId }).catch(() => {}); + throw error; + } } export async function heldFile(account: string, blobId: string): Promise { - const held = await getStore().area<{ name: string; type: string; data: Uint8Array } | null>(account, 'outbox.heldFile', { blobId }); + const held = await getStore().area<{ name: string; type: string; blob: Blob } | null>(account, 'outbox.heldFile', { blobId }); if (!held) return null; - return new File([held.data as BlobPart], held.name, { type: held.type }); + return new File([held.blob], held.name, { type: held.type }); } diff --git a/lib/offline/state.ts b/lib/offline/state.ts index ef163e927..18449790b 100644 --- a/lib/offline/state.ts +++ b/lib/offline/state.ts @@ -8,7 +8,7 @@ import { create } from 'zustand'; import { DEFAULT_SCOPE, type StoreScope, type StoreUsage } from '@/lib/store/area'; -import type { ConflictEntry, OutboxEntry, OutboxError } from './queue'; +import type { ConflictEntry, LocalDraftRow, OutboxEntry, OutboxError } from './queue'; import type { PersistState } from './storage-health'; import type { SyncProgress } from './sync-progress'; @@ -80,6 +80,10 @@ interface OfflineState { conflicts: ConflictEntry[]; /** The same for calendar events and contacts. */ areaConflicts: AreaConflict[]; + /** Drafts that are only on this device: on their way up, or refused by the server. */ + localDrafts: LocalDraftRow[]; + /** A local draft the user asked to open, for the mail page to pick up. */ + draftToOpen: { account: string; jmap: string; id: string } | null; usageBytes: number; usage: Record; choices: Record; @@ -135,6 +139,8 @@ export const useOfflineStore = create()(() => ({ pendingChanges: 0, conflicts: [], areaConflicts: [], + localDrafts: [], + draftToOpen: null, usageBytes: 0, usage: {}, choices: readRecord(OFFLINE_SCOPE_KEY), diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 655ff4695..6686e7e37 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -71,6 +71,13 @@ const INTERCEPT_TIMEOUT_MS = 60_000; // counted them toward auto-disable. const BLOB_UPLOAD_TIMEOUT_MS = 15 * 60_000; +/** + * The longest a hook may stay waiting on a dialog or an OS prompt, from its start. A + * person answers well within this. A plugin whose dialog is never answered, or a call the + * OS never finishes, must not hold mail rendering or a send forever. + */ +const HELD_HOOK_DEADLINE_MS = 10 * 60_000; + class HookTimeoutError extends Error {} /** Tells the hook timer when a plugin is waiting on the user. */ @@ -97,20 +104,30 @@ function withTimeout(pluginId: string, promise: T | Promise, ms: number = if (!(promise instanceof Promise)) return Promise.resolve(promise); return new Promise((resolve, reject) => { let timer: ReturnType | undefined; + let deadline: ReturnType | undefined; let stopWaiting: (() => void) | undefined; + const started = Date.now(); const arm = () => { stopWaiting = undefined; timer = setTimeout(expire, ms); }; const expire = () => { if (dialogGate?.hasOpenDialog(pluginId)) { + // One deadline from the start, however often holds come and go. A bus with a + // longer budget of its own has at least that. + deadline ??= setTimeout(() => { + settle(); + reject(new HookTimeoutError(`Hook still waiting on the user after ${Date.now() - started}ms`)); + }, Math.max(0, started + Math.max(HELD_HOOK_DEADLINE_MS, ms) - Date.now())); stopWaiting = dialogGate.whenDialogsClosed(pluginId, arm); return; } + clearTimeout(deadline); reject(new HookTimeoutError(`Hook timed out after ${ms}ms`)); }; const settle = () => { clearTimeout(timer); + clearTimeout(deadline); stopWaiting?.(); }; arm(); diff --git a/lib/plugin-sandbox/host-api-keystore.ts b/lib/plugin-sandbox/host-api-keystore.ts new file mode 100644 index 000000000..f292cd6ad --- /dev/null +++ b/lib/plugin-sandbox/host-api-keystore.ts @@ -0,0 +1,132 @@ +// Host side of api.keystore: the OS certificate store and smart cards, for an +// S/MIME plugin, in the desktop app. Signing and decrypting happen in the OS. The +// private key never reaches the webmail, and the OS's own prompts stay on. +// +// Privileged tier only (host-api.ts), so a user-uploaded plugin never gets here. +// None of this is a security boundary: a privileged frame is same-origin and can reach +// window.parent.bulwarkDesktop directly. The checks are for the front door. What holds in +// both cases is in the shell: a native dialog in main (keystore-consent.ts) and the OS. +// +// RPC errors reach the frame as message strings only, so the code is the message: +// keystore:cancelled, keystore:not-found, keystore:unsupported, keystore:locked, +// keystore:invalid, keystore:failed. + +import type { InstalledPlugin } from '../plugin-types'; +import type { DesktopKeystoreBridge, DesktopKeystoreCertificate } from '../desktop/types'; +import { holdForUser } from './host-dialog'; +import { pluginHasPermission } from './permissions'; +import { resolvePluginTier } from './tier'; + +const MAX_ID = 64; +const MAX_DIGEST = 64; +// A 16384-bit modulus, the most anything issues. +const MAX_CIPHERTEXT = 2048; +const MAX_LABEL = 256; +const MAX_PEER_KEY = 256; + +const HASHES = new Set(['SHA-1', 'SHA-256', 'SHA-384', 'SHA-512']); +const SIGN_SCHEMES = new Set(['rsa-pkcs1', 'rsa-pss', 'ecdsa']); +const DECRYPT_SCHEMES = new Set(['rsa-pkcs1', 'rsa-oaep']); + +export const KEYSTORE_PERMS = { + 'keystore.list': 'keystore', + 'keystore.sign': 'keystore', + 'keystore.decrypt': 'keystore', + 'keystore.agree': 'keystore', +} as const; + +export const KEYSTORE_METHODS: readonly string[] = Object.keys(KEYSTORE_PERMS); + +function desktopKeystore(): DesktopKeystoreBridge | undefined { + return typeof window === 'undefined' ? undefined : window.bulwarkDesktop?.keystore; +} + +/** api.host.features.keystore for this plugin. Absent rather than false when it's not: older hosts don't name it. */ +export function keystoreFeature(plugin?: InstalledPlugin): { keystore?: true } { + if (!plugin || !desktopKeystore()) return {}; + if (resolvePluginTier(plugin).tier !== 'privileged' || !pluginHasPermission(plugin, 'keystore')) return {}; + return { keystore: true }; +} + +function invalid(): Error { + return new Error('keystore:invalid'); +} + +function readId(value: unknown): string { + if (typeof value !== 'string' || !value || value.length > MAX_ID) throw invalid(); + return value; +} + +function readBytes(value: unknown, max: number): Uint8Array { + const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : value; + if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0 || bytes.byteLength > max) throw invalid(); + return bytes; +} + +function readScheme(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw invalid(); + return value as Record; +} + +function readHash(value: unknown): string { + if (typeof value !== 'string' || !HASHES.has(value)) throw invalid(); + return value; +} + +function checkedSignScheme(value: unknown): { scheme: string; hash: string } { + const scheme = readScheme(value); + if (typeof scheme.scheme !== 'string' || !SIGN_SCHEMES.has(scheme.scheme)) throw invalid(); + return { scheme: scheme.scheme, hash: readHash(scheme.hash) }; +} + +function checkedDecryptScheme(value: unknown): { scheme: string; hash?: string; label?: Uint8Array } { + const scheme = readScheme(value); + if (typeof scheme.scheme !== 'string' || !DECRYPT_SCHEMES.has(scheme.scheme)) throw invalid(); + if (scheme.scheme === 'rsa-pkcs1') return { scheme: scheme.scheme }; + const label = scheme.label === undefined || (scheme.label instanceof Uint8Array && scheme.label.byteLength === 0) + ? undefined + : readBytes(scheme.label, MAX_LABEL); + return { scheme: scheme.scheme, hash: readHash(scheme.hash), ...(label ? { label } : {}) }; +} + +/** Only the code, whatever the bridge put around it. */ +function codeOnly(error: unknown): Error { + const code = /keystore:[a-z-]+/.exec(error instanceof Error ? error.message : String(error))?.[0]; + return new Error(code ?? 'keystore:failed'); +} + +export async function doKeystoreCall(plugin: InstalledPlugin, method: string, args: unknown[]): Promise { + const keystore = desktopKeystore(); + // A browser, a build without the helper, a plugin on an older shell. + if (!keystore) throw new Error('keystore:unsupported'); + + const run = (): Promise => { + switch (method) { + case 'keystore.list': + return keystore.list(); + case 'keystore.sign': + return keystore.sign(readId(args[0]), readBytes(args[1], MAX_DIGEST), checkedSignScheme(args[2]) as never); + case 'keystore.decrypt': + return keystore.decrypt(readId(args[0]), readBytes(args[1], MAX_CIPHERTEXT), checkedDecryptScheme(args[2]) as never); + case 'keystore.agree': + return keystore.agree(readId(args[0]), readBytes(args[1], MAX_PEER_KEY)); + default: + throw new Error('keystore:unsupported'); + } + }; + + // The OS may be showing a PIN dialog right now, which the host can't see. While it + // is, the plugin is waiting on the user, not hung: this holds the hook timer the + // way one of its own dialogs does, so a card's PIN during onRenderEmailBody doesn't + // count towards auto-disable. Not for a list: it never touches a card, and a plugin + // shouldn't be able to stop the timer with it. The hold has an absolute deadline + // (plugin-hooks.ts), and the shell gives up on a stuck call before that. + const release = method === 'keystore.list' ? () => {} : holdForUser(plugin.id); + try { + return await run(); + } catch (error) { + throw codeOnly(error); + } finally { + release(); + } +} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index f67a9b59a..f00530fca 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -80,6 +80,7 @@ import { wkdAllowed } from '../outgoing-hosts'; import { noThirdPartyTraffic } from '../third-party-mode'; import { openExternalLink } from '../plugin-hook-result'; import { ASSISTANT_PERMS, NOT_MINE, dispatchAssistant } from './assistant'; +import { KEYSTORE_METHODS, KEYSTORE_PERMS, doKeystoreCall } from './host-api-keystore'; /** * Methods only callable from the privileged (same-origin) tier. These expose @@ -115,11 +116,14 @@ const PRIVILEGED_ONLY_METHODS = new Set([ // This entry used to read `upfiles.set`, which matches no dispatched method // and therefore gated nothing - the dispatcher calls it `upfiles.save`. 'upfiles.save', + // The OS certificate store: never for an untrusted plugin, whatever it declares. + ...KEYSTORE_METHODS, ]); export const PERM_PER_METHOD: Readonly> = { // The AI assistant's host APIs, one table per area (lib/plugin-sandbox/assistant). ...ASSISTANT_PERMS, + ...KEYSTORE_PERMS, // storage is unscoped by the manifest - implicit. 'storage.get': null, 'storage.set': null, @@ -2047,6 +2051,8 @@ export async function dispatchApiCall( if (assistant !== NOT_MINE) return assistant; } + if (method in KEYSTORE_PERMS) return doKeystoreCall(plugin, method, args); + switch (method) { case 'proxy.fetch': return proxyFetch(plugin, args, opts?.sink ?? null); case 'proxy.cancel': proxyCancel(plugin, args[0]); return undefined; diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 9533ffc37..d99a2a634 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -15,6 +15,7 @@ import { withBasePath } from '../browser-navigation'; import { snapshotHostTheme, type ThemeSnapshot } from './host-theme'; import { snapshotHostFeatures, watchHostFeatures } from './host-features'; import { assistantFeatures } from './assistant'; +import { keystoreFeature } from './host-api-keystore'; import { effectiveSettings } from '../plugin-settings'; import { clampSlotHeight, clampSlotWidth, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout'; import { resolveDateLocale } from '../utils'; @@ -680,7 +681,7 @@ export class SandboxInstance { /** Push what api.host.features says now; before init it goes into the init payload. */ /** The flags of api.host.features that depend on this plugin. */ ownHostFeatures(): Partial { - return assistantFeatures(this.plugin); + return { ...assistantFeatures(this.plugin), ...keystoreFeature(this.plugin) }; } setHostFeatures(features: HostFeaturesView): void { diff --git a/lib/plugin-sandbox/host-dialog.ts b/lib/plugin-sandbox/host-dialog.ts index 500b4264f..d50008c98 100644 --- a/lib/plugin-sandbox/host-dialog.ts +++ b/lib/plugin-sandbox/host-dialog.ts @@ -171,9 +171,30 @@ export function cancelForPlugin(pluginId: string): void { if (changed) notify(); } -/** Whether the plugin has a dialog showing or waiting in the queue. */ +/** Calls waiting on a prompt the host can't see, per plugin (see holdForUser). */ +const userHolds = new Map(); + +/** + * The plugin is waiting on the user somewhere other than these dialogs, such as a + * smart card's PIN prompt from the OS. It counts as an open dialog for the hook timer + * until the returned function is called. Nothing is drawn for it. + */ +export function holdForUser(pluginId: string): () => void { + userHolds.set(pluginId, (userHolds.get(pluginId) ?? 0) + 1); + let released = false; + return () => { + if (released) return; + released = true; + const left = (userHolds.get(pluginId) ?? 1) - 1; + if (left > 0) userHolds.set(pluginId, left); + else userHolds.delete(pluginId); + notify(); + }; +} + +/** Whether the plugin has a dialog showing or waiting in the queue, or a hold. */ export function hasDialogFor(pluginId: string): boolean { - return queue.some((d) => d.pluginId === pluginId); + return userHolds.has(pluginId) || queue.some((d) => d.pluginId === pluginId); } /** diff --git a/lib/plugin-sandbox/host-features.ts b/lib/plugin-sandbox/host-features.ts index d8c6ba58c..50deeb4eb 100644 --- a/lib/plugin-sandbox/host-features.ts +++ b/lib/plugin-sandbox/host-features.ts @@ -14,6 +14,7 @@ import { noThirdPartyTraffic, subscribeThirdPartyMode } from '@/lib/third-party- import type { HostFeaturesView, InstalledPlugin } from '../plugin-types'; import type { OrganisationPolicy } from '@/lib/admin/organisation-policy'; import { assistantFeatures } from './assistant'; +import { keystoreFeature } from './host-api-keystore'; /** The external-mail-warning plugin, retired in favour of organisation.externalMail. */ export const EXTERNAL_MAIL_WARNING_PLUGIN_ID = 'external-mail-warning'; @@ -32,6 +33,7 @@ export function snapshotHostFeatures(plugin?: InstalledPlugin): HostFeaturesView ...hostFeaturesFor(usePolicyStore.getState().getOrganisation().externalMail, noThirdPartyTraffic()), // The AI assistant's host APIs this webmail offers (lib/plugin-sandbox/assistant). ...assistantFeatures(plugin), + ...keystoreFeature(plugin), }; } diff --git a/lib/plugin-sandbox/permission-text.ts b/lib/plugin-sandbox/permission-text.ts index fa044c762..6cac86eb4 100644 --- a/lib/plugin-sandbox/permission-text.ts +++ b/lib/plugin-sandbox/permission-text.ts @@ -11,6 +11,8 @@ import { ALL_PERMISSIONS, IMPLICIT_PERMISSIONS } from '../plugin-types'; */ export const HIGH_RISK_PERMISSIONS: ReadonlySet = new Set([ 'crypto:full', + // Signs and decrypts in the user's name with their certificates. + 'keystore', // email.get reads any message in the account, not only the open one. 'email:read', 'email:send', diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index cd69acd31..ed18e5a4e 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -506,6 +506,24 @@ function buildCoreApi(manifest: PluginManifest) { return () => { hostFeatureListeners.delete(listener); }; }, }, + /** + * The OS certificate store and smart cards, in the desktop app. Privileged tier + * with the `keystore` permission, when api.host.features.keystore is true. The + * shell asks the user first, so any call can answer keystore:cancelled. Key + * operations have no timeout here: the OS may be showing a PIN prompt, and the shell + * gives up by itself. Errors are bare codes: keystore:cancelled, not-found, + * unsupported, locked, invalid, failed. + */ + keystore: { + /** Certificates fit for mail that have a private key. The OS doesn't prompt, and it has the usual timeout. */ + list: () => callApi('keystore.list', []), + /** Signs a digest. scheme: { scheme: 'rsa-pkcs1' | 'rsa-pss' | 'ecdsa', hash }. RSA gives the raw signature, ECDSA DER. */ + sign: (id: string, digest: Uint8Array, scheme: unknown) => callApi('keystore.sign', [id, digest, scheme], 0) as Promise, + /** Unwraps a content key. scheme: { scheme: 'rsa-pkcs1' } or { scheme: 'rsa-oaep', hash, label? }. */ + decrypt: (id: string, data: Uint8Array, scheme: unknown) => callApi('keystore.decrypt', [id, data, scheme], 0) as Promise, + /** ECDH: the raw shared secret, big-endian, from the peer's SubjectPublicKeyInfo. */ + agree: (id: string, peerPublicKey: Uint8Array) => callApi('keystore.agree', [id, peerPublicKey], 0) as Promise, + }, /** The app the plugin runs in: product, the administrator's name for it, version, edition. No permission needed. */ app: { getInfo: () => callApi('app.getInfo', []) as Promise, diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 646c93c4d..520c594d8 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -980,6 +980,12 @@ export interface HostFeaturesView extends AssistantHostFeatures { * down. */ noThirdPartyTraffic: boolean; + /** + * The desktop app's OS certificate store and smart cards are there for this + * plugin: privileged tier, the `keystore` permission, and a shell that has them. + * Absent otherwise. See `api.keystore`. + */ + keystore?: boolean; } /** One setting `api.app.searchSettings` found, in the user's language. */ @@ -1546,6 +1552,10 @@ export const ALL_PERMISSIONS = [ // message bodies and private keys; only granted to a signed, admin-approved // privileged bundle after explicit high-risk consent. 'crypto:full', + // Sign and decrypt with the OS certificate store and smart cards, in the desktop + // app. Declare it in optionalPermissions: as a plain permission it would be an + // install error on a webmail that doesn't know it. Privileged tier only. + 'keystore', // Submit a fully-formed raw RFC822 message via JMAP (used after a plugin // signs/encrypts an outgoing message itself). 'email:raw-send', diff --git a/lib/store/__tests__/attachments.test.ts b/lib/store/__tests__/attachments.test.ts new file mode 100644 index 000000000..5899fe57c --- /dev/null +++ b/lib/store/__tests__/attachments.test.ts @@ -0,0 +1,534 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { DEFAULT_SCOPE, type StoreScope } from '../area'; +import { AccountEngine } from '../engine/account-engine'; +import { TransportError } from '../engine/transport'; +import { mailArea } from '../mail'; +import { outboxArea } from '../outbox'; +import { FakeJmapDrafts } from './helpers/fake-drafts'; +import { FAKE_SESSION, JMAP_ACCOUNT, type FakeEmail } from './helpers/fake-jmap'; +import { openMemoryStore } from './helpers/node-adapter'; + +const SCOPE: StoreScope = { ...DEFAULT_SCOPE, days: null }; +const RAW = 'message/rfc822'; + +/** Hands out files in pieces, so a test can tell a stream from a whole download. */ +class Files extends FakeJmapDrafts { + readonly downloads: string[] = []; + readonly rawDownloads: string[] = []; + readonly files = new Map(); + gone = new Set(); + breakAfter: { blobId: string; bytes: number } | null = null; + pieces = 0; + onDownload: ((blobId: string) => void) | null = null; + + private record(blobId: string, type: string): Uint8Array { + if (type === RAW) this.rawDownloads.push(blobId); + else this.downloads.push(blobId); + this.onDownload?.(blobId); + if (this.gone.has(blobId)) throw new TransportError('http', 'Gone', 404); + return this.files.get(blobId) ?? new Uint8Array(type === RAW ? [9, 9] : [1, 2, 3]); + } + + override async download(_jmap?: string, blobId?: string, type?: string): Promise { + return this.record(String(blobId), String(type)); + } + + async downloadStream(_jmap: string, blobId: string, type: string, _name: string, sink: (part: Uint8Array) => void): Promise { + const data = this.record(blobId, type); + const step = 256 * 1024; + for (let at = 0; at < data.length; at += step) { + if (this.breakAfter?.blobId === blobId && at >= this.breakAfter.bytes) throw new TransportError('network', 'The download broke off'); + this.pieces += 1; + sink(data.subarray(at, at + step)); + } + } +} + +function engineFor(server: Files, scope: StoreScope = SCOPE, areas = [mailArea]) { + const db = openMemoryStore(); + const engine = new AccountEngine({ + account: 'a@fake.test', + db, + session: FAKE_SESSION, + transport: server, + areas, + scope, + now: () => Date.UTC(2026, 10, 3), + sleep: async () => {}, + }); + return { engine, db }; +} + +const kept = (engine: AccountEngine, blobId: string) => engine.areaCall('mail.getBlob', { jmap: JMAP_ACCOUNT, blobId }) as Promise<{ type: string; size: number; blob: Blob } | null>; + +function withFile(server: Files, id: string, blobId: string, size = 3, day = 1, extra: Partial = {}): FakeEmail { + server.files.set(blobId, new Uint8Array(size).fill(7)); + return server.addEmail({ + id, + size: 100_000_000, + receivedAt: new Date(Date.UTC(2026, 9, day)).toISOString(), + attachments: [{ partId: '2', blobId, name: `${blobId}.pdf`, type: 'application/pdf', size }], + ...extra, + }); +} + +describe('what\'s fetched', () => { + it('goes newest mail first, after the rest of the pass', async () => { + const server = new Files(); + withFile(server, 'old', 'b-old', 3, 1); + withFile(server, 'new', 'b-new', 3, 20); + withFile(server, 'mid', 'b-mid', 3, 10); + const { engine } = engineFor(server); + await engine.requestSync(); + expect(server.downloads).toEqual(['b-new', 'b-mid', 'b-old']); + }); + + it('runs after every other syncer, and a newer pass goes first', async () => { + const server = new Files(); + withFile(server, 'one', 'b-1', 3, 2); + withFile(server, 'two', 'b-2', 3, 1); + const order: string[] = []; + const later = { name: 'later', migrations: [], methods: {}, syncers: [{ name: 'later', capability: 'urn:ietf:params:jmap:mail', run: async () => { order.push('later'); } }] }; + const { engine } = engineFor(server, SCOPE, [mailArea, later]); + let wanted = false; + server.onDownload = () => { + order.push('file'); + // Asked for while a file comes in: the next file waits for that pass. + if (!wanted) { + wanted = true; + void engine.requestSync(); + } + }; + await engine.requestSync(); + expect(order[0]).toBe('later'); + // later, file 1, then the wanted pass: later again before file 2. + expect(order.slice(0, 3)).toEqual(['later', 'file', 'later']); + expect(server.downloads).toEqual(['b-1', 'b-2']); + }); + + it('includes inline images that only the body lists, as Stalwart 1.0 answers', async () => { + const server = new Files(); + server.addEmail({ + id: 'm', + attachments: [], + htmlBody: [{ partId: '1', type: 'text/html' }, { partId: '2', blobId: 'b-inline', type: 'image/png', name: 'i.png', size: 3, cid: 'i', disposition: 'inline' }] as never, + bodyStructure: { + type: 'multipart/related', + subParts: [{ partId: '1', blobId: 'b-text', type: 'text/html', size: 10 }, { partId: '3', blobId: 'b-tree', type: 'image/gif', size: 3, cid: 'j', disposition: 'inline' }], + } as never, + }); + const { engine } = engineFor(server); + await engine.requestSync(); + expect(server.downloads.sort()).toEqual(['b-inline', 'b-tree']); + expect(await kept(engine, 'b-inline')).toMatchObject({ type: 'image/png' }); + }); + + it('comes through the stream in chunks that are each kept, and is readable whole', async () => { + const server = new Files(); + const size = 2 * 1024 * 1024 + 5; + withFile(server, 'm', 'b-big', size); + server.files.get('b-big')?.fill(3); + const { engine, db } = engineFor(server); + await engine.requestSync(); + expect(server.pieces).toBeGreaterThan(3); + expect(Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM blob_chunk WHERE blob_id = 'b-big'")?.n)).toBe(3); + const file = await kept(engine, 'b-big'); + expect(file?.size).toBe(size); + const bytes = new Uint8Array(await (file as { blob: Blob }).blob.arrayBuffer()); + expect(bytes.length).toBe(size); + expect(bytes.every((value) => value === 3)).toBe(true); + }); + + it('leaves nothing readable when a download breaks, and tries again next time', async () => { + const server = new Files(); + withFile(server, 'm', 'b-big', 2 * 1024 * 1024); + server.breakAfter = { blobId: 'b-big', bytes: 1024 * 1024 + 1 }; + const { engine, db } = engineFor(server); + await engine.requestSync(); + expect(await kept(engine, 'b-big')).toBeNull(); + expect(db.get("SELECT 1 AS x FROM blob_chunk WHERE blob_id = 'b-big'")).toBeUndefined(); + server.breakAfter = null; + await engine.requestSync(); + expect((await kept(engine, 'b-big'))?.size).toBe(2 * 1024 * 1024); + }); + + it('drops a file larger than it said, and doesn\'t fetch it again', async () => { + const server = new Files(); + withFile(server, 'm', 'b-liar', 3); + server.files.set('b-liar', new Uint8Array(5000)); + const { engine } = engineFor(server, { ...SCOPE, maxAttachmentBytes: 1000 }); + await engine.requestSync(); + await engine.requestSync(); + expect(await kept(engine, 'b-liar')).toBeNull(); + expect(server.downloads).toEqual(['b-liar']); + }); +}); + +describe('the cap', () => { + it('pushes out older mail\'s files for a newer message\'s', async () => { + const server = new Files(); + withFile(server, 'old', 'b-old', 40, 1); + const { engine } = engineFor(server, { ...SCOPE, maxTotalBytes: 60 }); + await engine.requestSync(); + expect(await kept(engine, 'b-old')).not.toBeNull(); + withFile(server, 'new', 'b-new', 40, 20); + await engine.requestSync(); + expect(await kept(engine, 'b-new')).not.toBeNull(); + expect(await kept(engine, 'b-old')).toBeNull(); + }); + + it('doesn\'t fetch an older file back that a newer one pushed out', async () => { + const server = new Files(); + withFile(server, 'old', 'b-old', 40, 1); + withFile(server, 'new', 'b-new', 40, 20); + const { engine } = engineFor(server, { ...SCOPE, maxTotalBytes: 60 }); + await engine.requestSync(); + await engine.requestSync(); + expect(server.downloads).toEqual(['b-new']); + }); + + it('doesn\'t let a file of older mail the user opened push out a newer one', async () => { + const server = new Files(); + withFile(server, 'new', 'b-new', 40, 20); + server.addEmail({ id: 'old', size: 100_000_000, receivedAt: new Date(Date.UTC(2026, 9, 1)).toISOString(), attachments: [{ partId: '2', blobId: 'b-old', size: 40, type: 'image/png' }] }); + server.gone.add('b-old'); + const { engine } = engineFor(server, { ...SCOPE, maxTotalBytes: 60 }); + await engine.requestSync(); + expect(await engine.areaCall('mail.recordBlob', { jmap: JMAP_ACCOUNT, blobId: 'b-old', type: null, data: new Uint8Array(40) })).toBe(false); + expect(await kept(engine, 'b-new')).not.toBeNull(); + }); + + it('keeps a smaller older file that still fits past one that doesn\'t', async () => { + const server = new Files(); + withFile(server, 'new', 'b-new', 40, 20); + withFile(server, 'mid', 'b-mid', 40, 10); + withFile(server, 'old', 'b-old', 10, 1); + const { engine } = engineFor(server, { ...SCOPE, maxTotalBytes: 60 }); + await engine.requestSync(); + expect(server.downloads).toEqual(['b-new', 'b-old']); + }); + + it('stops looking after a long run of files that don\'t fit, instead of reading the kept list for each', async () => { + const server = new Files(); + withFile(server, 'fills', 'b-fills', 50, 28); + for (let n = 0; n < 60; n += 1) withFile(server, `no-${n}`, `b-no-${n}`, 40, 27); + withFile(server, 'small-old', 'b-small-old', 5, 1); + const { engine } = engineFor(server, { ...SCOPE, maxTotalBytes: 60 }); + await engine.requestSync(); + expect(server.downloads).toEqual(['b-fills']); + }); + + it('never pushes out the files of a pinned message', async () => { + const server = new Files(); + withFile(server, 'pinned', 'b-pinned', 40, 1, { keywords: { $pinned: true } }); + const { engine } = engineFor(server, { ...SCOPE, maxTotalBytes: 60 }); + await engine.requestSync(); + withFile(server, 'new', 'b-new', 40, 20); + await engine.requestSync(); + expect(await kept(engine, 'b-pinned')).not.toBeNull(); + expect(await kept(engine, 'b-new')).toBeNull(); + }); + + it('never touches a file held for a draft', async () => { + const server = new Files(); + withFile(server, 'old', 'b-old', 40, 1); + const { engine } = engineFor(server, { ...SCOPE, maxTotalBytes: 60 }, [mailArea, outboxArea]); + const held = await engine.areaCall('outbox.holdFile', { name: 'mine', type: '', data: new Uint8Array(100) }) as { blobId: string }; + await engine.requestSync(); + withFile(server, 'new', 'b-new', 40, 20); + await engine.requestSync(); + expect(await kept(engine, 'b-old')).toBeNull(); + expect(await engine.areaCall('outbox.heldFile', { blobId: held.blobId })).not.toBeNull(); + }); + + it('applies a lowered total at once, oldest first', async () => { + const server = new Files(); + withFile(server, 'old', 'b-old', 40, 1); + withFile(server, 'new', 'b-new', 40, 20); + const { engine } = engineFor(server); + await engine.requestSync(); + engine.setScope({ ...SCOPE, maxTotalBytes: 50 }); + expect(await kept(engine, 'b-old')).toBeNull(); + expect(await kept(engine, 'b-new')).not.toBeNull(); + }); + + it('applies a lowered size limit at once', async () => { + const server = new Files(); + withFile(server, 'big', 'b-big', 40, 1); + withFile(server, 'small', 'b-small', 4, 2); + const { engine } = engineFor(server); + await engine.requestSync(); + engine.setScope({ ...SCOPE, maxAttachmentBytes: 10 }); + expect(await kept(engine, 'b-big')).toBeNull(); + expect(await kept(engine, 'b-small')).not.toBeNull(); + }); +}); + +describe('a policy forbidding attachments', () => { + it('fetches nothing, drops what\'s kept at once, and gives nothing out', async () => { + const server = new Files(); + withFile(server, 'm', 'b-1'); + const { engine, db } = engineFor(server); + await engine.requestSync(); + expect(await kept(engine, 'b-1')).not.toBeNull(); + + engine.setScope({ ...SCOPE, attachments: false }); + expect(db.get('SELECT 1 AS x FROM blob')).toBeUndefined(); + expect(db.get('SELECT 1 AS x FROM blob_chunk')).toBeUndefined(); + expect(await kept(engine, 'b-1')).toBeNull(); + + server.downloads.length = 0; + server.rawDownloads.length = 0; + server.addEmail({ id: 'more', attachments: [{ partId: '2', blobId: 'b-2', size: 3, type: 'application/pdf' }] }); + await engine.requestSync(); + expect(server.downloads).toEqual([]); + expect(server.rawDownloads).toEqual([]); + }); + + it('refuses a file the user opened online too', async () => { + const server = new Files(); + server.addEmail({ id: 'm', attachments: [{ partId: '2', blobId: 'b-1', size: 3, type: 'image/png' }] }); + const { engine } = engineFor(server, { ...SCOPE, attachments: false }); + await engine.requestSync(); + expect(await engine.areaCall('mail.recordBlob', { jmap: JMAP_ACCOUNT, blobId: 'b-1', type: null, data: new Uint8Array([1]) })).toBe(false); + }); + + it('applies when the store opens with it, without waiting for a sync', async () => { + const server = new Files(); + withFile(server, 'm', 'b-1'); + const { engine, db } = engineFor(server); + await engine.requestSync(); + expect(db.get('SELECT 1 AS x FROM blob')).toBeTruthy(); + new AccountEngine({ account: 'a@fake.test', db, session: FAKE_SESSION, transport: server, areas: [mailArea], scope: { ...SCOPE, attachments: false }, sleep: async () => {} }); + expect(db.get('SELECT 1 AS x FROM blob')).toBeUndefined(); + }); + + it('keeps a file the user attached to a draft: it\'s theirs, not a copy of mail', async () => { + const server = new Files(); + const { engine } = engineFor(server, { ...SCOPE, attachments: false }, [mailArea, outboxArea]); + const held = await engine.areaCall('outbox.holdFile', { name: 'mine', type: '', data: new Uint8Array(3) }) as { blobId: string }; + await engine.requestSync(); + engine.setScope({ ...SCOPE, attachments: false }); + expect(await engine.areaCall('outbox.heldFile', { blobId: held.blobId })).not.toBeNull(); + }); +}); + +describe('raw messages for an export', () => { + it('are kept with the attachments, and handed out by Email.blobId', async () => { + const server = new Files(); + server.addEmail({ id: 'm', blobId: 'raw-m', size: 2 }); + server.files.set('raw-m', new TextEncoder().encode('From: a\r\n\r\nx')); + const { engine } = engineFor(server); + await engine.requestSync(); + expect(server.rawDownloads).toEqual(['raw-m']); + const raw = await engine.areaCall('mail.rawBlob', { jmap: JMAP_ACCOUNT, blobId: 'raw-m' }) as { ok: true; type: string; blob: Blob }; + expect(raw.ok).toBe(true); + expect(raw.type).toBe(RAW); + expect(await raw.blob.text()).toBe('From: a\r\n\r\nx'); + expect(await engine.areaCall('mail.rawStatus', { jmap: JMAP_ACCOUNT })).toEqual({ total: 1, kept: 1 }); + }); + + it('come after the files of the same message', async () => { + const server = new Files(); + const order: string[] = []; + server.onDownload = (blobId) => order.push(blobId); + withFile(server, 'm', 'b-1', 3, 1, { blobId: 'raw-m', size: 3 }); + const { engine } = engineFor(server); + await engine.requestSync(); + expect(order).toEqual(['b-1', 'raw-m']); + }); + + it('are refused plainly with attachments off, and counted as not there', async () => { + const server = new Files(); + server.addEmail({ id: 'm', blobId: 'raw-m', mailboxIds: { inbox: true } }); + const { engine } = engineFor(server, { ...SCOPE, attachments: false }); + await engine.requestSync(); + expect(await engine.areaCall('mail.rawBlob', { jmap: JMAP_ACCOUNT, blobId: 'raw-m' })).toEqual({ ok: false, reason: 'attachments_off' }); + expect(await engine.areaCall('mail.rawStatus', { jmap: JMAP_ACCOUNT })).toEqual({ total: 1, kept: 0 }); + }); + + it('are refused when they aren\'t kept, whatever else is', async () => { + const server = new Files(); + server.addEmail({ id: 'm', blobId: 'raw-m', size: 5_000, attachments: [{ partId: '2', blobId: 'b-1', size: 3, type: 'image/png' }] }); + const { engine } = engineFor(server, { ...SCOPE, maxAttachmentBytes: 1000 }); + await engine.requestSync(); + expect(await engine.areaCall('mail.rawBlob', { jmap: JMAP_ACCOUNT, blobId: 'raw-m' })).toEqual({ ok: false, reason: 'not_kept' }); + // An attachment isn't a whole message. + expect(await engine.areaCall('mail.rawBlob', { jmap: JMAP_ACCOUNT, blobId: 'b-1' })).toEqual({ ok: false, reason: 'not_kept' }); + expect(await engine.areaCall('mail.rawStatus', { jmap: JMAP_ACCOUNT })).toEqual({ total: 1, kept: 0 }); + }); + + it('count per folder for an export of one', async () => { + const server = new Files({ mailboxes: [{ id: 'inbox', role: 'inbox' }, { id: 'other', name: 'Other' }] }); + server.addEmail({ id: 'a', blobId: 'raw-a', size: 2, mailboxIds: { inbox: true } }); + server.addEmail({ id: 'b', blobId: 'raw-b', size: 2, mailboxIds: { other: true } }); + server.gone.add('raw-b'); + const { engine } = engineFor(server); + await engine.requestSync(); + expect(await engine.areaCall('mail.rawStatus', { jmap: JMAP_ACCOUNT, mailboxId: 'inbox' })).toEqual({ total: 1, kept: 1 }); + expect(await engine.areaCall('mail.rawStatus', { jmap: JMAP_ACCOUNT, mailboxId: 'other' })).toEqual({ total: 1, kept: 0 }); + }); + + it('don\'t exist for a draft on the device', async () => { + const server = new Files({ mailboxes: [{ id: 'drafts', role: 'drafts' }] }); + const { engine, db } = engineFor(server, SCOPE, [mailArea, outboxArea]); + await engine.requestSync(); + const answer = await engine.answer([['Email/set', { create: { c: { keywords: { $draft: true }, mailboxIds: { drafts: true }, bodyValues: { 1: { value: 'x' } }, textBody: [{ partId: '1' }] } } }, 's']]); + const id = answer?.methodResponses[0][1].created.c.id as string; + expect(await engine.areaCall('mail.rawBlob', { jmap: JMAP_ACCOUNT, blobId: id })).toEqual({ ok: false, reason: 'not_kept' }); + expect(db.get('SELECT 1 AS x FROM email_part')).toBeUndefined(); + }); +}); + +describe('a kept file read back', () => { + it('isn\'t emptied by a download of the same file that the user opening it overtook', async () => { + const server = new Files(); + withFile(server, 'm', 'b-1', 2 * 1024 * 1024 + 5); + server.files.get('b-1')?.fill(3); + const { engine } = engineFor(server); + server.onDownload = () => { + server.onDownload = null; + void engine.areaCall('mail.recordBlob', { jmap: JMAP_ACCOUNT, blobId: 'b-1', type: 'application/pdf', data: new Uint8Array(2 * 1024 * 1024 + 5).fill(3) }); + }; + await engine.requestSync(); + const file = await kept(engine, 'b-1'); + expect(file?.size).toBe(2 * 1024 * 1024 + 5); + expect((file as { blob: Blob }).blob.size).toBe(2 * 1024 * 1024 + 5); + }); + + it('doesn\'t lose what a kept file has when the next pass looks at it', async () => { + const server = new Files(); + withFile(server, 'm', 'b-1', 300); + const { engine } = engineFor(server); + await engine.requestSync(); + server.addEmail({ id: 'n', attachments: [{ partId: '2', blobId: 'b-1', size: 300, type: 'application/pdf', name: 'same.pdf' }] }); + await engine.requestSync(); + expect((await kept(engine, 'b-1'))?.blob.size).toBe(300); + }); + + it('reads as not kept when its pieces don\'t add up, and is fetched again', async () => { + const server = new Files(); + withFile(server, 'm', 'b-1', 300); + const { engine, db } = engineFor(server); + await engine.requestSync(); + db.run("DELETE FROM blob_chunk WHERE blob_id = 'b-1'"); + expect(await kept(engine, 'b-1')).toBeNull(); + server.downloads.length = 0; + await engine.requestSync(); + expect(server.downloads).toEqual(['b-1']); + expect((await kept(engine, 'b-1'))?.blob.size).toBe(300); + }); +}); + +describe('raw messages come last', () => { + it('after every file, even of older mail', async () => { + const server = new Files(); + const order: string[] = []; + server.onDownload = (blobId) => order.push(blobId); + server.addEmail({ id: 'new', blobId: 'raw-new', size: 3, receivedAt: new Date(Date.UTC(2026, 9, 20)).toISOString() }); + withFile(server, 'old', 'b-old', 3, 1, { blobId: 'raw-old', size: 3 }); + const { engine } = engineFor(server); + await engine.requestSync(); + expect(order).toEqual(['b-old', 'raw-new', 'raw-old']); + }); + + it('never push out a file, however old', async () => { + const server = new Files(); + withFile(server, 'old', 'b-old', 40, 1); + const { engine } = engineFor(server, { ...SCOPE, maxTotalBytes: 60 }); + await engine.requestSync(); + const raw = new Uint8Array(40); + server.files.set('raw-new', raw); + server.addEmail({ id: 'new', blobId: 'raw-new', size: 40, receivedAt: new Date(Date.UTC(2026, 9, 20)).toISOString() }); + await engine.requestSync(); + expect(await kept(engine, 'b-old')).not.toBeNull(); + expect(await engine.areaCall('mail.rawBlob', { jmap: JMAP_ACCOUNT, blobId: 'raw-new' })).toEqual({ ok: false, reason: 'not_kept' }); + }); + + it('go first when a file needs the room, whatever their age', async () => { + const server = new Files(); + server.files.set('raw-newest', new Uint8Array(40)); + server.addEmail({ id: 'newest', blobId: 'raw-newest', size: 40, receivedAt: new Date(Date.UTC(2026, 9, 25)).toISOString() }); + withFile(server, 'old', 'b-old', 10, 1); + const { engine } = engineFor(server, { ...SCOPE, maxTotalBytes: 60 }); + await engine.requestSync(); + expect(await engine.areaCall('mail.rawBlob', { jmap: JMAP_ACCOUNT, blobId: 'raw-newest' })).toMatchObject({ ok: true }); + withFile(server, 'mid', 'b-mid', 30, 10); + await engine.requestSync(); + expect(await kept(engine, 'b-mid')).not.toBeNull(); + expect(await kept(engine, 'b-old')).not.toBeNull(); + expect(await engine.areaCall('mail.rawBlob', { jmap: JMAP_ACCOUNT, blobId: 'raw-newest' })).toEqual({ ok: false, reason: 'not_kept' }); + }); +}); + +describe('the pace', () => { + it('is slower for raw messages than for files', async () => { + const server = new Files(); + withFile(server, 'm', 'b-1', 3, 1, { blobId: 'raw-m', size: 3 }); + const waits: number[] = []; + const engine = new AccountEngine({ + account: 'a@fake.test', + db: openMemoryStore(), + session: FAKE_SESSION, + transport: server, + areas: [mailArea], + scope: SCOPE, + sleep: async (ms) => { waits.push(ms); }, + }); + await engine.requestSync(); + expect(waits).toEqual([200, 1000]); + }); +}); + +describe('what a server error means', () => { + it('doesn\'t give up on a file for a passing error, and stops for this pass', async () => { + const server = new Files(); + withFile(server, 'new', 'b-new', 3, 20); + withFile(server, 'old', 'b-old', 3, 1); + server.onDownload = (blobId) => { + if (blobId === 'b-new') throw new TransportError('http', 'Busy', 500); + }; + const { engine, db } = engineFor(server); + await engine.requestSync(); + expect(server.downloads).toEqual(['b-new']); + expect(db.get<{ refused: number }>("SELECT refused FROM email_part WHERE blob_id = 'b-new'")?.refused).toBe(0); + server.onDownload = null; + await engine.requestSync(); + expect(await kept(engine, 'b-new')).not.toBeNull(); + expect(await kept(engine, 'b-old')).not.toBeNull(); + }); + + it('gives up on one the server says won\'t come, and goes on', async () => { + for (const status of [403, 404, 410]) { + const server = new Files(); + withFile(server, 'new', 'b-new', 3, 20); + withFile(server, 'old', 'b-old', 3, 1); + server.onDownload = (blobId) => { + if (blobId === 'b-new') throw new TransportError('http', 'No', status); + }; + const { engine, db } = engineFor(server); + await engine.requestSync(); + expect(db.get<{ refused: number }>("SELECT refused FROM email_part WHERE blob_id = 'b-new'")?.refused).toBe(1); + expect(await kept(engine, 'b-old')).not.toBeNull(); + } + }); +}); + +describe('a transfer that breaks', () => { + it('doesn\'t end the pass: the next file still comes, and this one is tried again', async () => { + const server = new Files(); + withFile(server, 'new', 'b-new', 300, 20); + withFile(server, 'old', 'b-old', 300, 1); + server.onDownload = (blobId) => { + if (blobId === 'b-new') throw new TransportError('network', 'The download broke off', undefined, undefined, undefined, true); + }; + const { engine } = engineFor(server); + await engine.requestSync(); + expect(await kept(engine, 'b-old')).not.toBeNull(); + expect(await kept(engine, 'b-new')).toBeNull(); + server.onDownload = null; + await engine.requestSync(); + expect(await kept(engine, 'b-new')).not.toBeNull(); + }); +}); diff --git a/lib/store/__tests__/drafts-client.test.ts b/lib/store/__tests__/drafts-client.test.ts new file mode 100644 index 000000000..c25e07058 --- /dev/null +++ b/lib/store/__tests__/drafts-client.test.ts @@ -0,0 +1,383 @@ +/** + * The real JMAPClient's createDraft, getEmail and blob calls against the store + * while offline and a fake server online, through a stubbed fetch: what the + * composer does around a save. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { JMAPClient } from '@/lib/jmap/client'; +import { setOfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks'; +import { DEFAULT_SCOPE } from '../area'; +import type { DraftGhost, PendingDraft } from '../drafts/calls'; +import { flushDrafts } from '../drafts/flush'; +import { AccountEngine } from '../engine/account-engine'; +import { TransportError, type MethodCall } from '../engine/transport'; +import { mailArea } from '../mail'; +import { outboxArea } from '../outbox'; +import { flushPatches, type PatchOutcome, type PendingPatch } from '../outbox/flush-patches'; +import { FakeJmapDrafts } from './helpers/fake-drafts'; +import { JMAP_ACCOUNT } from './helpers/fake-jmap'; +import { openMemoryStore } from './helpers/node-adapter'; + +const NOW = Date.UTC(2026, 10, 3); +const MAIL = 'urn:ietf:params:jmap:mail'; +const SUBMISSION = 'urn:ietf:params:jmap:submission'; +const STALWART = 'urn:stalwart:jmap'; +const SESSION = { + capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500, maxObjectsInSet: 500 }, [MAIL]: {}, [SUBMISSION]: {}, [STALWART]: {} }, + accounts: { [JMAP_ACCOUNT]: { name: 'a@example.org', isPersonal: true, accountCapabilities: { [MAIL]: {}, [SUBMISSION]: {}, [STALWART]: {} } } }, + primaryAccounts: { [MAIL]: JMAP_ACCOUNT }, + username: 'a@example.org', + apiUrl: 'https://fake.test/api', + downloadUrl: 'https://fake.test/d/{accountId}/{blobId}/{name}?type={type}', + uploadUrl: 'https://fake.test/u/{accountId}', + eventSourceUrl: '', + state: 's', +}; + +function json(body: unknown): Response { + return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); +} + +let server: FakeJmapDrafts; +let engine: AccountEngine; +let online: boolean; +/** The server comes back right after the next create was answered on the device. */ +let backAfterCreate: boolean; +let spy: ReturnType; +let client: JMAPClient; +/** What reached the network. */ +let wire: MethodCall[]; + +beforeEach(async () => { + server = new FakeJmapDrafts({ mailboxes: [{ id: 'inbox', role: 'inbox' }, { id: 'drafts', role: 'drafts' }, { id: 'sent', role: 'sent' }] }); + server.now = NOW; + engine = new AccountEngine({ + account: 'a@fake.test', + db: openMemoryStore(), + session: SESSION as never, + transport: server, + areas: [mailArea, outboxArea], + scope: { ...DEFAULT_SCOPE, days: null, attachments: false }, + now: () => NOW, + sleep: async () => {}, + }); + online = true; + backAfterCreate = false; + wire = []; + spy = vi.spyOn(globalThis, 'fetch'); + spy.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (!online) throw new TypeError('Failed to fetch'); + if (url.includes('/u/')) { + const uploaded = await server.upload(JMAP_ACCOUNT, new Uint8Array(await (init?.body as Blob).arrayBuffer())); + return json(uploaded); + } + if (url.includes('/d/')) return new Response(new Uint8Array([9]), { status: 200 }); + if (!init?.body) return json(SESSION); + const calls = (JSON.parse(String(init.body)) as { methodCalls: MethodCall[] }).methodCalls; + wire.push(...calls); + return json(await server.request(calls)); + }); + setOfflineHooks({ + skipNetwork: () => !online, + answer: async (_client, calls: OfflineMethodCall[], options) => { + const marked = options?.networkTried + ? calls.map((call): OfflineMethodCall => (call[0] === 'Email/set' && call[1].create ? [call[0], { ...call[1], networkTried: true }, call[2]] : call)) + : calls; + const answer = await engine.answer(marked); + if (backAfterCreate && calls.some(([name, args]) => name === 'Email/set' && args.create)) online = true; + return answer as OfflineResponse | null; + }, + record: () => {}, + noteNetwork: () => {}, + serverGone: async () => true, + blob: async (_client, blobId) => { + const held = await engine.areaCall('outbox.heldFile', { blobId }) as { blob: Blob } | null; + return held?.blob ?? null; + }, + recordBlob: () => {}, + holdUpload: async (_client, file) => engine.areaCall('outbox.holdFile', { name: file.name, type: file.type, data: new Uint8Array(await file.arrayBuffer()) }) as Promise<{ blobId: string; size: number; type: string }>, + resolveStandIns: (_client, ids) => engine.areaCall('drafts.resolveStandIns', { ids }) as Promise>, + resolveHeldAttachments: async (_client, attachments) => { + const out = []; + for (const attachment of attachments) { + const held = await engine.areaCall('outbox.heldFile', { blobId: attachment.blobId }) as { blob: Blob } | null; + out.push(held ? { ...attachment, blobId: (await server.upload(JMAP_ACCOUNT, new Uint8Array(await held.blob.arrayBuffer()))).blobId } : attachment); + } + return out; + }, + }); + await engine.requestSync(); + client = new JMAPClient('https://fake.test', 'a@example.org', 'pw'); + await client.connect(); + wire.length = 0; +}); + +afterEach(() => { + spy.mockRestore(); + setOfflineHooks(null); +}); + +const saveDraft = (text: string, previous?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number }>) => + client.createDraft(['b@example.org'], 'Subject', text, undefined, undefined, undefined, 'a@example.org', previous, attachments); + +async function flushAll(): Promise { + await flushDrafts({ + store: { + read: () => engine.areaCall('drafts.read', { before: NOW - 60_000 }) as Promise<{ rows: PendingDraft[]; ghosts: DraftGhost[] }>, + claim: (localId) => engine.areaCall('drafts.claim', { localId }) as Promise, + failed: async (localId, status, error, neverSent) => { await engine.areaCall('drafts.failed', { localId, status, error, neverSent }); }, + created: (result) => engine.areaCall('drafts.created', result) as Promise<{ conflicts: string[] }>, + ghostsLooked: async (jmap, messageIds, found) => { await engine.areaCall('drafts.ghostsLooked', { jmap, messageIds, found }); }, + }, + request: (calls) => server.request(calls), + upload: async (jmap, blobId) => { + const held = await engine.areaCall('outbox.heldFile', { blobId }) as { blob: Blob } | null; + return held ? (await server.upload(jmap, new Uint8Array(await held.blob.arrayBuffer()))).blobId : null; + }, + isRefusal: (error) => error instanceof TransportError && error.kind === 'http', + onRefused: () => {}, + onConflict: () => {}, + onSent: () => {}, + }); + const pending = await engine.areaCall('outbox.pendingPatches', {}) as PendingPatch[]; + await flushPatches({ request: (calls) => server.request(calls) }, pending, async (outcomes: PatchOutcome[]) => { + await engine.areaCall('outbox.patchResults', { outcomes }); + }); +} + +describe('createDraft offline', () => { + it('saves on the device, and the composer can read the draft straight back', async () => { + online = false; + const id = await saveDraft('Written offline'); + expect(id).toMatch(/^local-/); + expect(server.drafts()).toEqual([]); + const email = await client.getEmail(id); + expect(email?.id).toBe(id); + expect(email?.bodyValues?.['1']?.value).toBe('Written offline'); + expect(email?.to?.[0]?.email).toBe('b@example.org'); + }); + + it('merges saves, so a hundred leave one draft on the server with the latest text', async () => { + online = false; + let id = await saveDraft('Save 0'); + for (let n = 1; n < 100; n += 1) id = await saveDraft(`Save ${n}`, id); + online = true; + await flushAll(); + expect(server.drafts()).toHaveLength(1); + expect((server.drafts()[0].bodyValues as Record)['1'].value).toBe('Save 99'); + }); + + it('leaves the old version alone on the server, even when it comes back between the two steps', async () => { + const real = await saveDraft('Online version'); + online = false; + backAfterCreate = true; + wire.length = 0; + const id = await saveDraft('Offline version', real); + expect(id).toMatch(/^local-/); + expect(online).toBe(true); + expect(server.emails.has(real)).toBe(true); + expect(wire.filter(([name, args]) => name === 'Email/set' && args.destroy)).toEqual([]); + await flushAll(); + expect(server.drafts().map((email) => (email.bodyValues as Record)['1'].value)).toEqual(['Offline version']); + }); + + it('gives each create a Message-ID of its own, online too, for finding it after a lost answer', async () => { + await saveDraft('One'); + const create = wire.find(([name]) => name === 'Email/set')?.[1].create as Record; + expect(Object.values(create)[0].messageId[0]).toMatch(/@example\.org$/); + }); + + it('marks a create whose connection broke, so the flush looks before it creates', async () => { + const original = spy.getMockImplementation(); + spy.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => { + const body = init?.body ? String(init.body) : ''; + if (body.includes('"Email/set"')) throw new TypeError('Failed to fetch'); + return original!(input, init); + }); + const id = await saveDraft('On the way'); + spy.mockImplementation(original!); + expect(id).toMatch(/^local-/); + expect(await engine.areaCall('drafts.list', {})).toMatchObject([{ status: 'uncertain' }]); + }); +}); + +describe('after a flush the page still holds the stand-in', () => { + it('reads the real message by it, and the answer says what the caller asked', async () => { + online = false; + const id = await saveDraft('Text'); + online = true; + await flushAll(); + const real = server.drafts()[0].id; + wire.length = 0; + const email = await client.getEmail(id); + expect(email?.id).toBe(real); + expect((wire.find(([name]) => name === 'Email/get')?.[1].ids as string[])).toEqual([real]); + }); + + it('replaces the real message on the next save, online', async () => { + online = false; + const id = await saveDraft('First'); + online = true; + await flushAll(); + const next = await saveDraft('Second', id); + expect(server.drafts().map((email) => email.id)).toEqual([next]); + }); + + it('also finds it offline again', async () => { + online = false; + const id = await saveDraft('First'); + online = true; + await flushAll(); + await engine.requestSync(); + online = false; + const email = await client.getEmail(id); + expect(email?.bodyValues?.['1']?.value).toBe('First'); + }); + + it('replaces the real message on the next save, offline, after the new one is up', async () => { + online = false; + const id = await saveDraft('First'); + online = true; + await flushAll(); + const real = server.drafts()[0].id; + online = false; + await saveDraft('Second', id); + expect(server.emails.has(real)).toBe(true); + online = true; + await flushAll(); + expect(server.emails.has(real)).toBe(false); + expect((server.drafts()[0].bodyValues as Record)['1'].value).toBe('Second'); + }); +}); + +describe('before the flush, with the server back', () => { + it('answers a read of the stand-in from the device, not the server', async () => { + online = false; + const id = await saveDraft('Text'); + online = true; + wire.length = 0; + expect((await client.getEmail(id))?.bodyValues?.['1']?.value).toBe('Text'); + expect(wire.filter(([name]) => name === 'Email/get')).toEqual([]); + }); + + it('replaces the stand-in with a save that goes straight to the server', async () => { + online = false; + const id = await saveDraft('Offline'); + online = true; + const real = await saveDraft('Online', id); + expect(real).not.toMatch(/^local-/); + await flushAll(); + expect(server.drafts().map((email) => email.id)).toEqual([real]); + expect(await engine.areaCall('drafts.list', {})).toEqual([]); + }); + + it('uploads files attached offline before the save goes to the server', async () => { + online = false; + const { blobId } = await client.uploadBlob(new File(['hello'], 'note.txt', { type: 'text/plain' })); + expect(blobId).toMatch(/^bulwark-local-/); + const id = await saveDraft('With a file', undefined, [{ blobId, name: 'note.txt', type: 'text/plain', size: 5 }]); + online = true; + await saveDraft('With a file, later', id, [{ blobId, name: 'note.txt', type: 'text/plain', size: 5 }]); + const [draft] = server.drafts(); + expect(new TextDecoder().decode(server.bytesOf((draft.attachments as Array<{ blobId: string }>)[0].blobId))).toBe('hello'); + }); +}); + +describe('a file attached offline', () => { + it('stays readable and downloadable, offline and once the server is back', async () => { + online = false; + const { blobId } = await client.uploadBlob(new File(['hello'], 'note.txt', { type: 'text/plain' })); + expect(await (await client.fetchBlob(blobId, 'note.txt', 'text/plain')).text()).toBe('hello'); + online = true; + expect(await (await client.fetchBlob(blobId, 'note.txt', 'text/plain')).text()).toBe('hello'); + expect(spy.mock.calls.filter(([url]: [unknown]) => String(url).includes('/d/'))).toEqual([]); + }); + + it('isn\'t held a second time when its own upload hits an outage', async () => { + const holdUpload = vi.fn(); + const hooks = { skipNetwork: () => false, answer: async () => null, record: () => {}, noteNetwork: () => {}, serverGone: async () => true, blob: async () => null, recordBlob: () => {}, holdUpload }; + setOfflineHooks(hooks as never); + online = false; + await expect(client.uploadBlob(new File(['x'], 'x.txt'), { noHold: true })).rejects.toThrow(); + expect(holdUpload).not.toHaveBeenCalled(); + }); +}); + +describe('an open composer\'s attachment after a flush', () => { + const FILE = { name: 'note.txt', type: 'text/plain', size: 5 }; + + /** A draft with a file on the server, edited offline with its part, and flushed: that part is dead now. */ + async function flushedWithOldPart() { + const { blobId: uploaded } = await client.uploadBlob(new File(['hello'], 'note.txt', { type: 'text/plain' })); + const first = await saveDraft('Online', undefined, [{ ...FILE, blobId: uploaded }]); + const part = (server.emails.get(first)?.attachments as Array<{ blobId: string }>)[0].blobId; + // The upload's id would still work: only the part is left in the composer. + server.blobs.delete(uploaded); + online = false; + const standIn = await saveDraft('Offline', first, [{ ...FILE, blobId: part }]); + online = true; + await flushAll(); + expect(server.emails.has(first)).toBe(false); + expect(server.bytesOf(part)).toBeUndefined(); + return { standIn, part }; + } + + const fileOnServer = () => new TextDecoder().decode(server.bytesOf((server.drafts()[0].attachments as Array<{ blobId: string }>)[0].blobId)); + + it('survives the next save, which reads the real draft through the map', async () => { + const { standIn, part } = await flushedWithOldPart(); + const saved = await saveDraft('Later', standIn, [{ ...FILE, blobId: part }]); + expect(server.drafts().map((email) => email.id)).toEqual([saved]); + expect(fileOnServer()).toBe('hello'); + }); + + it('survives the next send', async () => { + const { standIn, part } = await flushedWithOldPart(); + const result = await client.sendEmail(['b@example.org'], 'Subject', 'Text', undefined, undefined, undefined, 'a@example.org', standIn, undefined, undefined, [{ ...FILE, blobId: part }]); + expect(result.emailId).toBeTruthy(); + expect(server.drafts()).toEqual([]); + expect((server.emails.get(result.emailId as string)?.attachments as unknown[])).toHaveLength(1); + }); + + it('retries once when the server says a part is gone under a real id too', async () => { + const { part } = await flushedWithOldPart(); + const real = server.drafts()[0].id; + const saved = await saveDraft('Later', real, [{ ...FILE, blobId: part }]); + expect(server.drafts().map((email) => email.id)).toEqual([saved]); + expect(fileOnServer()).toBe('hello'); + }); + + it('retries a send that says a part is gone under a real id', async () => { + const { part } = await flushedWithOldPart(); + const real = server.drafts()[0].id; + const result = await client.sendEmail(['b@example.org'], 'Subject', 'Text', undefined, undefined, undefined, 'a@example.org', real, undefined, undefined, [{ ...FILE, blobId: part }]); + expect(result.emailId).toBeTruthy(); + }); + + it('still fails when no part of that name and size is there', async () => { + const { standIn, part } = await flushedWithOldPart(); + await expect(saveDraft('Later', standIn, [{ name: 'other.txt', type: 'text/plain', size: 5, blobId: part }])).rejects.toThrow(/blobNotFound/); + }); + + it('asks again at most once', async () => { + const { part } = await flushedWithOldPart(); + const real = server.drafts()[0].id; + server.refuseCreate = { type: 'blobNotFound' }; + wire.length = 0; + await expect(saveDraft('Later', real, [{ ...FILE, blobId: part }])).rejects.toThrow(/blobNotFound/); + expect(wire.filter(([name, args]) => name === 'Email/set' && args.create).length).toBeLessThanOrEqual(2); + }); +}); + +describe('an online send of a draft still on the device', () => { + it('drops the draft here and still removes the submission record on the server', async () => { + online = false; + const id = await saveDraft('Only here'); + online = true; + const result = await client.sendEmail(['b@example.org'], 'Subject', 'Text', undefined, undefined, undefined, 'a@example.org', id); + expect(result.emailId).toBeTruthy(); + expect(await engine.areaCall('drafts.list', {})).toEqual([]); + expect(server.submissionsDestroyed()).toHaveLength(1); + }); +}); diff --git a/lib/store/__tests__/drafts.test.ts b/lib/store/__tests__/drafts.test.ts new file mode 100644 index 000000000..ea6e45106 --- /dev/null +++ b/lib/store/__tests__/drafts.test.ts @@ -0,0 +1,881 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it } from 'vitest'; +import { DEFAULT_SCOPE } from '../area'; +import type { DraftError } from '../drafts/pending'; +import type { LocalDraftSummary, PendingDraft, DraftGhost } from '../drafts/calls'; +import { flushDrafts, type DraftFlushIo } from '../drafts/flush'; +import { AccountEngine } from '../engine/account-engine'; +import { TransportError, type MethodCall } from '../engine/transport'; +import { mailArea } from '../mail'; +import { outboxArea } from '../outbox'; +import { flushPatches, type PatchOutcome, type PendingPatch } from '../outbox/flush-patches'; +import type { StoredConflict } from '../outbox/items'; +import { FakeJmapDrafts } from './helpers/fake-drafts'; +import { FAKE_SESSION, JMAP_ACCOUNT, type FakeEmail } from './helpers/fake-jmap'; +import { openMemoryStore } from './helpers/node-adapter'; + +const NOW = Date.UTC(2026, 10, 3); +const BOXES = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 }, + { id: 'drafts', name: 'Drafts', role: 'drafts', totalEmails: 0, unreadEmails: 0 }, + { id: 'trash', name: 'Trash', role: 'trash', totalEmails: 0, unreadEmails: 0 }, +]; + +function setup() { + const server = new FakeJmapDrafts({ mailboxes: BOXES.map((box) => ({ ...box })) }); + server.now = NOW; + const db = openMemoryStore(); + const engine = new AccountEngine({ + account: 'a@fake.test', + db, + session: FAKE_SESSION, + transport: server, + areas: [mailArea, outboxArea], + scope: { ...DEFAULT_SCOPE, days: null, attachments: false }, + now: () => NOW, + sleep: async () => {}, + }); + return { server, db, engine }; +} + +let t: ReturnType; + +beforeEach(async () => { + t = setup(); + await t.engine.requestSync(); +}); + +interface SaveOptions { + subject?: string; + text?: string; + previous?: string; + attachments?: Array>; + networkTried?: boolean; + messageId?: string; +} + +let nextMessage = 0; + +/** What createDraft sends: a new version, then the old one goes. */ +async function save(options: SaveOptions = {}): Promise { + nextMessage += 1; + const answer = await t.engine.answer([['Email/set', { + accountId: JMAP_ACCOUNT, + ...(options.networkTried ? { networkTried: true } : {}), + create: { + c1: { + messageId: [options.messageId ?? `draft-${nextMessage}@example.org`], + from: [{ email: 'a@example.org' }], + to: [{ email: 'b@example.org' }], + subject: options.subject ?? 'A draft', + keywords: { $seen: true, $draft: true }, + mailboxIds: { drafts: true }, + bodyValues: { 1: { value: options.text ?? 'Text' } }, + textBody: [{ partId: '1' }], + ...(options.attachments ? { attachments: options.attachments } : {}), + }, + }, + }, 's']]); + const result = answer?.methodResponses[0][1]; + const id = result.created?.c1?.id as string; + if (!id) throw new Error(`not created: ${JSON.stringify(result.notCreated)}`); + if (options.previous) { + await t.engine.answer([['Email/set', { accountId: JMAP_ACCOUNT, destroy: [options.previous], replacedBy: id }, 'd']]); + } + return id; +} + +async function get(id: string, properties?: string[]) { + const answer = await t.engine.answer([['Email/get', { accountId: JMAP_ACCOUNT, ids: [id], fetchAllBodyValues: true, ...(properties ? { properties } : {}) }, 'g']]); + return answer?.methodResponses[0][1] as { list: Array>; notFound: string[] }; +} + +async function draftBox(): Promise { + const answer = await t.engine.answer([['Email/query', { accountId: JMAP_ACCOUNT, filter: { inMailbox: 'drafts' } }, 'q']]); + return answer?.methodResponses[0][1].ids; +} + +const list = () => t.engine.areaCall('drafts.list', {}) as Promise; +const conflicts = async () => (await t.engine.areaCall('outbox.read', {}) as { conflicts: StoredConflict[] }).conflicts; + +interface Run { + refused: Array<[string, DraftError]>; + conflicted: string[]; + sent: number; +} + +/** The flush as the page runs it, with the file upload on the fake server. */ +function ioFor(run: Run): DraftFlushIo { + const { engine, server } = t; + return { + store: { + read: () => engine.areaCall('drafts.read', { before: NOW - 60_000 }) as Promise<{ rows: PendingDraft[]; ghosts: DraftGhost[] }>, + claim: (localId) => engine.areaCall('drafts.claim', { localId }) as Promise, + failed: async (localId, status, error, neverSent) => { await engine.areaCall('drafts.failed', { localId, status, error, neverSent }); }, + created: (result) => engine.areaCall('drafts.created', result) as Promise<{ conflicts: string[] }>, + ghostsLooked: async (jmap, messageIds, found) => { await engine.areaCall('drafts.ghostsLooked', { jmap, messageIds, found }); }, + }, + request: (calls: MethodCall[]) => server.request(calls), + upload: async (jmap, blobId) => { + const held = await engine.areaCall('outbox.heldFile', { blobId }) as { blob: Blob } | null; + if (!held) return null; + return (await server.upload(jmap, new Uint8Array(await held.blob.arrayBuffer()))).blobId; + }, + isRefusal: (error) => error instanceof TransportError && error.kind === 'http', + onRefused: (subject, error) => run.refused.push([subject, error]), + onConflict: (subject) => run.conflicted.push(subject), + onSent: () => { run.sent += 1; }, + }; +} + +async function flush(): Promise { + const run: Run = { refused: [], conflicted: [], sent: 0 }; + const finished = await flushDrafts(ioFor(run)); + return { ...run, finished }; +} + +/** And what the flush left in the usual queue: the old versions go here. */ +async function flushQueue(): Promise { + const pending = await t.engine.areaCall('outbox.pendingPatches', {}) as PendingPatch[]; + return flushPatches({ request: (calls) => t.server.request(calls) }, pending, async (outcomes: PatchOutcome[]) => { + await t.engine.areaCall('outbox.patchResults', { outcomes }); + }); +} + +function serverDraft(subject: string, id = 'R') { + t.server.addEmail({ id, subject, mailboxIds: { drafts: true }, keywords: { $seen: true, $draft: true } }); +} + +async function hold(text: string, name = 'note.txt'): Promise { + const held = await t.engine.areaCall('outbox.holdFile', { name, type: 'text/plain', data: new TextEncoder().encode(text) }) as { blobId: string }; + return held.blobId; +} + +describe('a draft saved offline', () => { + it('gets a stand-in id and answers as a server would, without going to the server', async () => { + const requests = t.server.requests; + const id = await save({ subject: 'Offline', text: 'Written on a train' }); + expect(id).toMatch(/^local-/); + expect(t.server.requests).toBe(requests); + expect(await draftBox()).toEqual([id]); + + const [email] = (await get(id)).list; + expect(email.subject).toBe('Offline'); + expect(email.keywords).toEqual({ $seen: true, $draft: true }); + expect(email.preview).toBe('Written on a train'); + expect((email.bodyValues as Record)['1'].value).toBe('Written on a train'); + expect(email.textBody).toEqual([{ partId: '1', type: 'text/plain', size: 18 }]); + }); + + it('adds itself to the folder count', async () => { + await save(); + const answer = await t.engine.answer([['Mailbox/get', {}, 'm']]); + const drafts = (answer?.methodResponses[0][1].list as Array<{ id: string; totalEmails: number }>).find((box) => box.id === 'drafts'); + expect(drafts?.totalEmails).toBe(1); + }); + + it('can be found by its words', async () => { + const id = await save({ text: 'quarterly figures' }); + const answer = await t.engine.answer([['Email/query', { filter: { text: 'quarterly' } }, 'q']]); + expect(answer?.methodResponses[0][1].ids).toEqual([id]); + }); + + it('reads headers back the way the composer does', async () => { + const answer = await t.engine.answer([['Email/set', { + create: { c: { keywords: { $draft: true }, mailboxIds: { drafts: true }, subject: 's', 'header:X-Priority:asText': '1 (Highest)', bodyValues: { 1: { value: 'x' } }, textBody: [{ partId: '1' }] } }, + }, 's']]); + const id = answer?.methodResponses[0][1].created.c.id as string; + const [email] = (await get(id, ['header:X-Priority:asText'])).list; + expect(email['header:X-Priority:asText']).toBe('1 (Highest)'); + }); + + it('survives a full resync, which asks the server about what it didn\'t list', async () => { + const id = await save(); + t.server.addEmail({ id: 'new-mail', subject: 'Trigger' }); + t.server.truncateLog('Email'); + t.server.calls.length = 0; + await t.engine.requestSync(); + // It did list everything again, and asked about what it didn't see. + expect(t.server.calls.some(([name]) => name === 'Email/query')).toBe(true); + expect((await get(id)).list).toHaveLength(1); + }); + + it('takes a flag and a move, which go up with it', async () => { + const id = await save(); + await t.engine.answer([['Email/set', { update: { [id]: { 'keywords/$flagged': true } } }, 'u']]); + expect((await get(id, ['keywords'])).list[0].keywords).toEqual({ $seen: true, $draft: true, $flagged: true }); + await flush(); + expect(t.server.drafts()[0].keywords).toEqual({ $seen: true, $draft: true, $flagged: true }); + }); + + it('is refused as a draft when the same request sends it, or when it isn\'t in Drafts', async () => { + const sendLike = await t.engine.answer([ + ['Email/set', { create: { c: { keywords: { $draft: true }, mailboxIds: { drafts: true } } } }, '0'], + ['EmailSubmission/set', { create: { s: { emailId: '#c' } } }, '1'], + ]); + expect(sendLike?.methodResponses[0][1].notCreated.c.type).toBe('serverUnavailable'); + const elsewhere = await t.engine.answer([['Email/set', { create: { c: { keywords: { $draft: true }, mailboxIds: { inbox: true } } } }, '0']]); + expect(elsewhere?.methodResponses[0][1].notCreated.c.type).toBe('serverUnavailable'); + const noKeyword = await t.engine.answer([['Email/set', { create: { c: { keywords: {}, mailboxIds: { drafts: true } } } }, '0']]); + expect(noKeyword?.methodResponses[0][1].notCreated.c.type).toBe('serverUnavailable'); + expect(await list()).toEqual([]); + }); +}); + +describe('saving again', () => { + it('leaves one pending draft after a hundred saves, and one on the server with the latest text', async () => { + let id = await save({ text: 'Save 0' }); + for (let n = 1; n < 100; n += 1) id = await save({ text: `Save ${n}`, previous: id }); + expect(await list()).toHaveLength(1); + expect(await draftBox()).toEqual([id]); + + const { finished, sent } = await flush(); + expect(finished).toBe(true); + expect(sent).toBe(1); + expect(t.server.drafts()).toHaveLength(1); + expect((t.server.drafts()[0].bodyValues as Record)['1'].value).toBe('Save 99'); + expect(await list()).toEqual([]); + }); + + it('removes the older stand-in from everything that reads', async () => { + const first = await save(); + const second = await save({ previous: first }); + expect((await get(first)).notFound).toEqual([first]); + expect((await get(second)).list).toHaveLength(1); + }); +}); + +describe('a stand-in in a destroy', () => { + it('discards the draft, and nothing reaches the server', async () => { + const id = await save(); + const answer = await t.engine.answer([['Email/set', { destroy: [id] }, 'd']]); + expect(answer?.methodResponses[0][1].destroyed).toEqual([id]); + expect((await get(id)).notFound).toEqual([id]); + await flush(); + expect(t.server.drafts()).toEqual([]); + expect(await t.engine.areaCall('outbox.pendingPatches', {})).toEqual([]); + }); + + it('after the flush goes to the real message, by the map', async () => { + const id = await save(); + await flush(); + const real = t.server.drafts()[0].id; + expect(await t.engine.areaCall('drafts.resolveStandIns', { ids: [id, 'local-unknown'] })).toEqual({ [id]: real, 'local-unknown': null }); + await t.engine.answer([['Email/set', { destroy: [id] }, 'd']]); + await flushQueue(); + expect(t.server.emails.has(real)).toBe(false); + }); +}); + +describe('a draft from the server edited offline', () => { + beforeEach(async () => { + serverDraft('On the server'); + await t.engine.requestSync(); + }); + + it('hides the old version here and leaves it on the server until the new one has arrived', async () => { + const id = await save({ subject: 'Edited', previous: 'R' }); + expect(await draftBox()).toEqual([id]); + expect(t.server.emails.has('R')).toBe(true); + expect(await t.engine.areaCall('outbox.pendingPatches', {})).toEqual([]); + // A sync doesn't bring it back. + t.server.updateEmail('R', { keywords: { $seen: true, $draft: true } }); + t.server.coll.Email.push('updated', 'R'); + await t.engine.requestSync(); + expect(await draftBox()).toEqual([id]); + }); + + it('creates the new version first and destroys the old one after', async () => { + await save({ subject: 'Edited', previous: 'R' }); + await flush(); + expect(t.server.emails.has('R')).toBe(true); + await flushQueue(); + expect(t.server.log.filter((line) => /^(create|destroy)/.test(line))).toEqual(['create srv1', 'destroy R']); + expect(t.server.drafts().map((email) => email.subject)).toEqual(['Edited']); + }); + + it('keeps both when the destroy fails: nothing is lost, and it goes again', async () => { + await save({ subject: 'Edited', previous: 'R' }); + await flush(); + t.server.offline = true; + expect(await flushQueue()).toBe(false); + t.server.offline = false; + expect(t.server.drafts()).toHaveLength(2); + await flushQueue(); + expect(t.server.drafts().map((email) => email.subject)).toEqual(['Edited']); + }); + + it('keeps the old version when the create fails', async () => { + t.server.refuseCreate = { type: 'overQuota' }; + await save({ subject: 'Edited', previous: 'R' }); + await flush(); + await flushQueue(); + expect(t.server.emails.has('R')).toBe(true); + }); + + it('carries what an earlier save replaced through later saves', async () => { + const first = await save({ text: 'One', previous: 'R' }); + await save({ text: 'Two', previous: first }); + await flush(); + await flushQueue(); + expect(t.server.drafts()).toHaveLength(1); + expect((t.server.drafts()[0].bodyValues as Record)['1'].value).toBe('Two'); + }); + + it('removes the old version too when the new one is discarded before it went up', async () => { + const id = await save({ previous: 'R' }); + await t.engine.answer([['Email/set', { destroy: [id] }, 'd']]); + await flushQueue(); + expect(t.server.drafts()).toEqual([]); + }); + + it('keeps both and says so when another device changed it meanwhile', async () => { + await save({ subject: 'Mine', previous: 'R' }); + t.server.updateEmail('R', { keywords: { $seen: true, $draft: true, $flagged: true } }); + const { conflicted } = await flush(); + await flushQueue(); + expect(conflicted).toEqual(['On the server']); + expect(t.server.drafts().map((email) => email.subject).sort()).toEqual(['Mine', 'On the server']); + expect((await conflicts())[0].summary).toMatchObject({ op: 'draft', reason: 'changed', subject: 'On the server' }); + // The other version is back in view: nothing else would bring it. + expect((await draftBox()).sort()).toEqual(['R', 'srv1'].sort()); + }); + + it('keeps ours and says so when another device replaced or removed it', async () => { + await save({ subject: 'Mine', previous: 'R' }); + t.server.destroyEmail('R'); + t.server.addEmail({ id: 'R2', subject: 'Theirs', mailboxIds: { drafts: true }, keywords: { $seen: true, $draft: true } }); + const { conflicted } = await flush(); + await flushQueue(); + expect(conflicted).toEqual(['On the server']); + expect(t.server.drafts().map((email) => email.subject).sort()).toEqual(['Mine', 'Theirs']); + expect((await conflicts())[0].summary).toMatchObject({ op: 'draft', reason: 'gone' }); + }); +}); + +describe('a lost response', () => { + it('doesn\'t make a second draft: the copy is found by its Message-ID', async () => { + const id = await save({ messageId: 'fixed@example.org' }); + t.server.loseAnswerOf = 'Email/set'; + const first = await flush(); + expect(first.finished).toBe(false); + expect(t.server.drafts()).toHaveLength(1); + expect((await list())[0].status).toBe('uncertain'); + // It stays readable under its stand-in meanwhile. + expect((await get(id)).list).toHaveLength(1); + + const second = await flush(); + expect(second.finished).toBe(true); + expect(t.server.drafts()).toHaveLength(1); + expect(await list()).toEqual([]); + expect(await t.engine.areaCall('drafts.resolveStandIns', { ids: [id] })).toEqual({ [id]: t.server.drafts()[0].id }); + }); + + it('creates it when the lookup finds that it never arrived', async () => { + await save(); + t.server.offline = true; + await flush(); + t.server.offline = false; + await flush(); + expect(t.server.drafts()).toHaveLength(1); + }); + + it('removes the copy of a version replaced while the answer was missing', async () => { + const first = await save({ text: 'Old', messageId: 'old@example.org' }); + t.server.loseAnswerOf = 'Email/set'; + await flush(); + await save({ text: 'New', previous: first }); + await flush(); + await flushQueue(); + expect(t.server.drafts()).toHaveLength(1); + expect((t.server.drafts()[0].bodyValues as Record)['1'].value).toBe('New'); + expect(await t.engine.areaCall('drafts.read', { before: 0 })).toMatchObject({ ghosts: [] }); + }); + + it('looks a create up that came to the store after its own request broke', async () => { + t.server.addEmail({ id: 'landed', subject: 'A draft', mailboxIds: { drafts: true }, keywords: { $draft: true }, messageId: ['tried@example.org'], receivedAt: new Date(NOW).toISOString() }); + const id = await save({ messageId: 'tried@example.org', networkTried: true }); + expect((await list())[0].status).toBe('uncertain'); + await flush(); + expect(t.server.drafts().map((email) => email.id)).toEqual(['landed']); + expect(await t.engine.areaCall('drafts.resolveStandIns', { ids: [id] })).toEqual({ [id]: 'landed' }); + }); + + it('counts a tab closed on the way as uncertain', async () => { + const id = await save({ messageId: 'closed@example.org' }); + expect(await t.engine.areaCall('drafts.claim', { localId: id })).toBe(true); + await t.engine.areaCall('drafts.read', { before: NOW + 1 }); + expect((await list())[0].status).toBe('uncertain'); + }); + + it('removes a copy that arrives after the draft was replaced on the way, only once the new one is up', async () => { + const id = await save({ text: 'Old', messageId: 'late@example.org' }); + await t.engine.areaCall('drafts.claim', { localId: id }); + const next = await save({ text: 'New', previous: id }); + t.server.addEmail({ id: 'late', subject: 'A draft', mailboxIds: { drafts: true }, keywords: { $seen: true, $draft: true }, messageId: ['late@example.org'], size: 3, receivedAt: new Date(NOW).toISOString() }); + await t.engine.areaCall('drafts.created', { localId: id, seq: 1, jmap: JMAP_ACCOUNT, messageId: 'late@example.org', serverId: 'late', email: null, server: {}, ghostCopies: [] }); + expect(await t.engine.areaCall('outbox.pendingPatches', {})).toEqual([]); + expect((await get(next)).list).toHaveLength(1); + await flush(); + await flushQueue(); + expect(t.server.emails.has('late')).toBe(false); + expect((t.server.drafts()[0].bodyValues as Record)['1'].value).toBe('New'); + }); +}); + +describe('a refused draft', () => { + it('stays on the device with its error, and the composer can still open it', async () => { + t.server.refuseCreate = { type: 'overQuota', description: 'No room left' }; + const id = await save({ subject: 'Too much', text: 'Keep me' }); + const { refused, finished } = await flush(); + expect(finished).toBe(true); + expect(refused).toEqual([['Too much', { type: 'overQuota', text: 'No room left' }]]); + expect(await list()).toMatchObject([{ localId: id, status: 'refused', error: { type: 'overQuota', text: 'No room left' } }]); + expect((await get(id)).list[0].bodyValues).toMatchObject({ 1: { value: 'Keep me' } }); + expect(await draftBox()).toEqual([id]); + }); + + it('isn\'t asked about again by itself, and goes up when the user tries again', async () => { + t.server.refuseCreate = { type: 'overQuota' }; + const id = await save(); + await flush(); + t.server.refuseCreate = null; + await flush(); + expect(t.server.drafts()).toEqual([]); + await t.engine.areaCall('drafts.retry', { localId: id }); + await flush(); + expect(t.server.drafts()).toHaveLength(1); + expect(await list()).toEqual([]); + }); + + it('goes up again once it\'s edited', async () => { + t.server.refuseCreate = { type: 'overQuota' }; + const id = await save({ text: 'One' }); + await flush(); + t.server.refuseCreate = null; + await save({ text: 'Two', previous: id }); + await flush(); + expect((t.server.drafts()[0].bodyValues as Record)['1'].value).toBe('Two'); + }); + + it('says so when the request itself is refused for good', async () => { + const id = await save(); + const original = t.server.request.bind(t.server); + t.server.request = async () => { throw new TransportError('http', 'Too large', 413); }; + const { refused } = await flush(); + t.server.request = original; + expect(refused).toHaveLength(1); + expect((await list())[0]).toMatchObject({ localId: id, status: 'refused' }); + }); +}); + +describe('files attached offline', () => { + it('are shown on the draft with their size, stay readable, and go up on the flush', async () => { + const blobId = await hold('hello'); + const id = await save({ attachments: [{ blobId, type: 'text/plain', name: 'note.txt', disposition: 'attachment' }] }); + const [email] = (await get(id)).list; + expect(email.hasAttachment).toBe(true); + expect(email.attachments).toMatchObject([{ blobId, name: 'note.txt', size: 5, type: 'text/plain', disposition: 'attachment' }]); + const file = await t.engine.areaCall('outbox.heldFile', { blobId }) as { name: string; blob: Blob }; + expect(await file.blob.text()).toBe('hello'); + + await flush(); + const [draft] = t.server.drafts(); + expect(t.server.uploads).toHaveLength(1); + const [part] = draft.attachments as Array<{ blobId: string; size: number }>; + expect(part.size).toBe(5); + expect(new TextDecoder().decode(t.server.bytesOf(part.blobId))).toBe('hello'); + }); + + it('go up before the create that names them', async () => { + const blobId = await hold('hello'); + await save({ attachments: [{ blobId, type: 'text/plain', name: 'note.txt' }] }); + await flush(); + expect(t.server.log.slice(0, 2)).toEqual(['upload up1', 'create srv1']); + }); + + it('come in pieces, and a broken one leaves nothing readable', async () => { + const started = await t.engine.areaCall('outbox.holdStart', { name: 'big.bin', type: '', size: 6 }) as { blobId: string }; + await t.engine.areaCall('outbox.holdChunk', { blobId: started.blobId, seq: 0, data: new Uint8Array([1, 2, 3]) }); + expect(await t.engine.areaCall('outbox.heldFile', { blobId: started.blobId })).toBeNull(); + await t.engine.areaCall('outbox.holdChunk', { blobId: started.blobId, seq: 1, data: new Uint8Array([4, 5, 6]) }); + await t.engine.areaCall('outbox.holdEnd', { blobId: started.blobId }); + const file = await t.engine.areaCall('outbox.heldFile', { blobId: started.blobId }) as { blob: Blob }; + expect([...new Uint8Array(await file.blob.arrayBuffer())]).toEqual([1, 2, 3, 4, 5, 6]); + await t.engine.areaCall('outbox.holdAbort', { blobId: started.blobId }); + expect(await t.engine.areaCall('outbox.heldFile', { blobId: started.blobId })).toBeNull(); + expect(t.db.get('SELECT 1 AS x FROM upload_chunk')).toBeUndefined(); + }); + + it('wait when the upload hits an outage, without counting as maybe sent', async () => { + const blobId = await hold('hello'); + await save({ attachments: [{ blobId, type: 'text/plain', name: 'note.txt' }] }); + t.server.failUpload = true; + const { finished } = await flush(); + expect(finished).toBe(false); + expect(t.server.drafts()).toEqual([]); + expect((await list())[0].status).toBe('waiting'); + }); + + it('refuse the draft when a file is gone from the device', async () => { + const blobId = await hold('hello'); + const id = await save({ attachments: [{ blobId, type: 'text/plain', name: 'note.txt' }] }); + await t.engine.areaCall('outbox.holdAbort', { blobId }); + const { refused } = await flush(); + expect(refused).toEqual([['A draft', { code: 'file_missing' }]]); + expect((await list())[0]).toMatchObject({ localId: id, status: 'refused', error: { code: 'file_missing' } }); + }); + + it('count as needed for as long as a draft or a send names them', async () => { + const forDraft = await hold('a'); + const forNothing = await hold('b'); + const id = await save({ attachments: [{ blobId: forDraft, type: 'text/plain', name: 'a' }] }); + expect(await t.engine.areaCall('outbox.dropOrphans', { olderThan: NOW + 1 })).toBe(1); + expect(await t.engine.areaCall('outbox.heldFile', { blobId: forNothing })).toBeNull(); + expect(await t.engine.areaCall('outbox.heldFile', { blobId: forDraft })).not.toBeNull(); + await t.engine.answer([['Email/set', { destroy: [id] }, 'd']]); + expect(await t.engine.areaCall('outbox.dropOrphans', { olderThan: NOW + 1 })).toBe(1); + }); + + it('survive removing the send that shared them with a draft', async () => { + const blobId = await hold('a'); + await save({ attachments: [{ blobId, type: 'text/plain', name: 'a' }] }); + const entry = await t.engine.areaCall('outbox.queueSend', { + summary: { subject: 's', to: [] }, + item: { kind: 'email', args: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, [{ blobId }]] }, + }) as number; + await t.engine.areaCall('outbox.remove', { id: entry }); + expect(await t.engine.areaCall('outbox.heldFile', { blobId })).not.toBeNull(); + }); + + it('are found inside a body structure too, as a draft with inline images writes them', async () => { + const blobId = await hold('png'); + const answer = await t.engine.answer([['Email/set', { + create: { + c: { + keywords: { $draft: true }, + mailboxIds: { drafts: true }, + bodyValues: { text: { value: 'text' }, html: { value: '' } }, + bodyStructure: { + type: 'multipart/alternative', + subParts: [ + { partId: 'text', type: 'text/plain' }, + { type: 'multipart/related', subParts: [{ partId: 'html', type: 'text/html' }, { blobId, type: 'image/png', name: 'i.png', disposition: 'inline', cid: 'i' }] }, + ], + }, + }, + }, + }, 's']]); + const id = answer?.methodResponses[0][1].created.c.id as string; + const [email] = (await get(id)).list; + expect(email.hasAttachment).toBe(false); + expect(email.attachments).toMatchObject([{ blobId, cid: 'i', disposition: 'inline', size: 3 }]); + expect(email.htmlBody).toMatchObject([{ partId: 'html', type: 'text/html' }]); + await flush(); + const leaf = (t.server.calls.find(([name, args]) => name === 'Email/set' && args.create)?.[1].create as Record }> } }>).draft.bodyStructure.subParts[1].subParts[1]; + expect(leaf.blobId).toBe(t.server.uploads[0].blobId); + }); +}); + +describe('sending a draft that\'s on the device', () => { + it('leaves the draft alone while the send waits, and the send takes care of it', async () => { + const id = await save({ previous: undefined }); + await t.engine.areaCall('outbox.queueSend', { + summary: { subject: 's', to: [] }, + item: { kind: 'email', args: [['b@example.org'], 's', 'x', undefined, undefined, undefined, undefined, id] }, + }); + await flush(); + expect(t.server.drafts()).toEqual([]); + expect((await get(id)).list).toHaveLength(1); + // It's that message, in the outbox: not counted a second time as unsent. + expect(await list()).toEqual([]); + + await t.engine.areaCall('drafts.discard', { jmap: JMAP_ACCOUNT, id }); + expect((await get(id)).notFound).toEqual([id]); + }); + + it('goes up as a draft if the send fails, so nothing depends on the outbox', async () => { + const id = await save(); + const entry = await t.engine.areaCall('outbox.queueSend', { + summary: { subject: 's', to: [] }, + item: { kind: 'email', args: [['b@example.org'], 's', 'x', undefined, undefined, undefined, undefined, id] }, + }) as number; + await t.engine.areaCall('outbox.update', { id: entry, status: 'failed', error: { text: 'refused' } }); + await flush(); + expect(t.server.drafts()).toHaveLength(1); + }); +}); + +const valueOf = (email: FakeEmail) => (email.bodyValues as Record)['1'].value; + +describe('a draft naming a part that died with its message', () => { + function partOnServer(id: string, part: string, bytes: string) { + t.server.parts.set(part, new TextEncoder().encode(bytes)); + t.server.addEmail({ + id, + subject: 'On the server', + mailboxIds: { drafts: true }, + keywords: { $seen: true, $draft: true }, + attachments: [{ partId: part, blobId: part, name: 'a.txt', type: 'text/plain', size: bytes.length }] as never, + }); + } + + it('is pointed at the part of the version it replaces, and goes up', async () => { + partOnServer('R', 'part-live', 'abc'); + await t.engine.requestSync(); + await save({ previous: 'R', attachments: [{ blobId: 'part-dead', type: 'text/plain', name: 'a.txt' }] }); + const { refused, finished } = await flush(); + expect(refused).toEqual([]); + expect(finished).toBe(true); + await flushQueue(); + const [draft] = t.server.drafts(); + expect(t.server.drafts()).toHaveLength(1); + expect(new TextDecoder().decode(t.server.bytesOf((draft.attachments as Array<{ blobId: string }>)[0].blobId))).toBe('abc'); + }); + + it('keeps two files of one name apart: one that\'s alive keeps its part', async () => { + t.server.parts.set('p-short', new Uint8Array(3)); + t.server.parts.set('p-long', new Uint8Array(9)); + t.server.addEmail({ + id: 'R', + subject: 'Two', + mailboxIds: { drafts: true }, + keywords: { $draft: true }, + attachments: [ + { partId: 'p-short', blobId: 'p-short', name: 'a.txt', type: 'text/plain', size: 3 }, + { partId: 'p-long', blobId: 'p-long', name: 'a.txt', type: 'text/plain', size: 9 }, + ] as never, + }); + await t.engine.requestSync(); + await save({ previous: 'R', attachments: [{ blobId: 'p-long', type: 'text/plain', name: 'a.txt' }, { blobId: 'dead', type: 'text/plain', name: 'a.txt' }] }); + await flush(); + const parts = t.server.drafts().find((email) => email.id !== 'R')?.attachments as Array<{ size: number }>; + expect(parts.map((part) => part.size).sort()).toEqual([3, 9]); + }); + + it('stays here with its error when nothing matches', async () => { + partOnServer('R', 'part-live', 'abc'); + await t.engine.requestSync(); + const id = await save({ previous: 'R', attachments: [{ blobId: 'part-dead', type: 'text/plain', name: 'other.txt' }] }); + const { refused } = await flush(); + expect(refused[0][1]).toMatchObject({ type: 'blobNotFound' }); + expect((await list())[0]).toMatchObject({ localId: id, status: 'refused' }); + expect(t.server.emails.has('R')).toBe(true); + }); +}); + +describe('a version that may have reached the server, once replaced', () => { + async function lostThenReplaced(text = 'New') { + const first = await save({ text: 'Old', messageId: 'old@example.org' }); + t.server.loseAnswerOf = 'Email/set'; + await flush(); + return { first, copy: t.server.drafts()[0].id, next: () => save({ text, previous: first }) }; + } + + it('is removed when it\'s the copy this device made', async () => { + const { next } = await lostThenReplaced(); + await next(); + await flush(); + await flushQueue(); + expect(t.server.drafts().map(valueOf)).toEqual(['New']); + }); + + it('is left alone when another client saved again under that Message-ID', async () => { + const { copy, next } = await lostThenReplaced(); + t.server.destroyEmail(copy); + t.server.addEmail({ id: 'theirs', subject: 'Theirs now', mailboxIds: { drafts: true }, keywords: { $draft: true }, messageId: ['old@example.org'], size: 3, receivedAt: new Date(NOW).toISOString() }); + await next(); + await flush(); + await flushQueue(); + expect(t.server.emails.has('theirs')).toBe(true); + expect(t.server.drafts()).toHaveLength(2); + }); + + it('is left alone when it was received long after the attempt', async () => { + const { copy, next } = await lostThenReplaced(); + t.server.updateEmail(copy, { receivedAt: new Date(NOW + 6 * 60 * 60 * 1000).toISOString() }); + await next(); + await flush(); + await flushQueue(); + expect(t.server.emails.has(copy)).toBe(true); + }); + + it('is left alone when its size doesn\'t fit', async () => { + const { copy, next } = await lostThenReplaced(); + t.server.updateEmail(copy, { size: 5_000_000 }); + await next(); + await flush(); + await flushQueue(); + expect(t.server.emails.has(copy)).toBe(true); + }); + + it('is left alone when it\'s in another folder too, or no longer a draft', async () => { + const { copy, next } = await lostThenReplaced(); + t.server.updateEmail(copy, { mailboxIds: { drafts: true, inbox: true } }); + await next(); + await flush(); + await flushQueue(); + expect(t.server.emails.has(copy)).toBe(true); + + t = setup(); + await t.engine.requestSync(); + const again = await lostThenReplaced(); + t.server.updateEmail(again.copy, { keywords: { $seen: true } }); + await again.next(); + await flush(); + await flushQueue(); + expect(t.server.emails.has(again.copy)).toBe(true); + }); + + it('is left alone when it moves between the lookup and the destroy', async () => { + const { copy, next } = await lostThenReplaced(); + await next(); + await flush(); + t.server.updateEmail(copy, { mailboxIds: { trash: true } }); + await flushQueue(); + expect(t.server.emails.has(copy)).toBe(true); + expect((t.server.emails.get(copy)?.mailboxIds as Record).trash).toBe(true); + }); + + it('isn\'t touched while the newer version is refused, and goes once that one is up', async () => { + const { copy, next } = await lostThenReplaced(); + const newer = await next(); + t.server.refuseCreate = { type: 'overQuota' }; + await flush(); + expect(await t.engine.areaCall('outbox.pendingPatches', {})).toEqual([]); + await flushQueue(); + expect(t.server.emails.has(copy)).toBe(true); + + t.server.refuseCreate = null; + await t.engine.areaCall('drafts.retry', { localId: newer }); + await flush(); + await flushQueue(); + expect(t.server.emails.has(copy)).toBe(false); + expect(t.server.drafts().map(valueOf)).toEqual(['New']); + }); + + it('carries through later saves', async () => { + const { copy, first } = await lostThenReplaced(); + const second = await save({ text: 'Two', previous: first }); + await save({ text: 'Three', previous: second }); + await flush(); + await flushQueue(); + expect(t.server.emails.has(copy)).toBe(false); + expect(t.server.drafts().map(valueOf)).toEqual(['Three']); + }); + + it('is looked up and removed under the same rules when the draft is discarded', async () => { + const { first, copy } = await lostThenReplaced(); + await t.engine.answer([['Email/set', { destroy: [first] }, 'd']]); + await flush(); + await flushQueue(); + expect(t.server.emails.has(copy)).toBe(false); + + t = setup(); + await t.engine.requestSync(); + const again = await lostThenReplaced(); + t.server.updateEmail(again.copy, { subject: 'Renamed elsewhere' }); + await t.engine.answer([['Email/set', { destroy: [again.first] }, 'd']]); + await flush(); + await flushQueue(); + expect(t.server.emails.has(again.copy)).toBe(true); + }); + + it('isn\'t taken for this draft\'s own copy when another client saved under its Message-ID', async () => { + const id = await save({ text: 'Mine', messageId: 'same@example.org' }); + t.server.loseAnswerOf = 'Email/set'; + await flush(); + const copy = t.server.drafts()[0].id; + t.server.destroyEmail(copy); + t.server.addEmail({ id: 'theirs', subject: 'Theirs now', mailboxIds: { drafts: true }, keywords: { $draft: true }, messageId: ['same@example.org'], size: 3, receivedAt: new Date(NOW).toISOString() }); + await flush(); + expect(t.server.emails.has('theirs')).toBe(true); + expect(t.server.drafts().map((email) => email.subject).sort()).toEqual(['A draft', 'Theirs now']); + const resolved = await t.engine.areaCall('drafts.resolveStandIns', { ids: [id] }) as Record; + expect(resolved[id]).not.toBe('theirs'); + }); +}); + +describe('a version the device doesn\'t keep, replaced', () => { + it('goes after the new one when it\'s a plain draft, checked against what the server says then', async () => { + t.server.addEmail({ id: 'R', subject: 'Not kept', mailboxIds: { drafts: true }, keywords: { $draft: true } }); + await save({ subject: 'Mine', previous: 'R' }); + await flush(); + await flushQueue(); + expect(t.server.emails.has('R')).toBe(false); + }); + + it('stays, and the user is told, when it\'s no longer a plain draft', async () => { + t.server.addEmail({ id: 'R', subject: 'Moved', mailboxIds: { trash: true }, keywords: { $draft: true } }); + const { conflicted } = await (async () => { + await save({ subject: 'Mine', previous: 'R' }); + return flush(); + })(); + await flushQueue(); + expect(t.server.emails.has('R')).toBe(true); + expect(conflicted).toEqual(['Moved']); + }); + + it('stays when it moves between the read and the destroy', async () => { + t.server.addEmail({ id: 'R', subject: 'Moves', mailboxIds: { drafts: true }, keywords: { $draft: true } }); + await save({ subject: 'Mine', previous: 'R' }); + await flush(); + t.server.updateEmail('R', { mailboxIds: { trash: true } }); + await flushQueue(); + expect(t.server.emails.has('R')).toBe(true); + }); +}); + +describe('a lookup that fails', () => { + function queryFails(error: Error) { + const original = t.server.request.bind(t.server); + t.server.request = async (calls) => { + if (calls.some(([name]) => name === 'Email/query')) throw error; + return original(calls); + }; + return () => { t.server.request = original; }; + } + + it('waits on an outage', async () => { + const first = await save({ messageId: 'old@example.org' }); + t.server.loseAnswerOf = 'Email/set'; + await flush(); + const restore = queryFails(new TransportError('network', 'gone')); + const { finished } = await flush(); + restore(); + expect(finished).toBe(false); + expect((await list())[0].localId).toBe(first); + }); + + it('counts as no copies when the server refuses it for good, so the rest can go', async () => { + await save({ messageId: 'old@example.org' }); + t.server.loseAnswerOf = 'Email/set'; + await flush(); + const restore = queryFails(new TransportError('http', 'Refused', 400)); + const { finished } = await flush(); + restore(); + expect(finished).toBe(true); + expect(await list()).toEqual([]); + }); + + it('drops a ghost it can\'t ever look up', async () => { + const first = await save({ messageId: 'old@example.org' }); + t.server.loseAnswerOf = 'Email/set'; + await flush(); + const copy = t.server.drafts()[0].id; + await t.engine.answer([['Email/set', { destroy: [first] }, 'd']]); + const restore = queryFails(new TransportError('http', 'Refused', 400)); + await flush(); + restore(); + expect(await t.engine.areaCall('drafts.read', { before: 0 })).toMatchObject({ ghosts: [] }); + expect(t.server.emails.has(copy)).toBe(true); + }); +}); + +describe('a held file whose pieces don\'t add up', () => { + it('reads as gone, not as an empty file', async () => { + const blobId = await hold('hello'); + t.db.run('DELETE FROM upload_chunk WHERE local_id = ?', [blobId]); + expect(await t.engine.areaCall('outbox.heldFile', { blobId })).toBeNull(); + }); +}); diff --git a/lib/store/__tests__/helpers/fake-drafts.ts b/lib/store/__tests__/helpers/fake-drafts.ts new file mode 100644 index 000000000..8a8c69598 --- /dev/null +++ b/lib/store/__tests__/helpers/fake-drafts.ts @@ -0,0 +1,177 @@ +/** + * FakeJmapMail with what drafts talk to: Email/set create, uploads, blob + * downloads and a send. Follows Stalwart: every new message gets new part ids, + * and a part's id dies with its message, so a create naming one gets + * `blobNotFound`. An upload's id stays usable. Two creates with one Message-ID + * are both accepted, and each create makes a new message. + */ + +import { TransportError, type JmapResponse, type MethodCall } from '../../engine/transport'; +import { type FakeEmail } from './fake-jmap'; +import { FakeJmapMail } from './fake-jmap-mail'; + +interface Part { + partId?: string; + blobId?: string; + type?: string; + name?: string; + disposition?: string; + cid?: string; + size?: number; + subParts?: Part[]; +} + +export class FakeJmapDrafts extends FakeJmapMail { + /** What was uploaded, by blob id. */ + readonly blobs = new Map(); + /** The bytes of the parts of messages, by part id. */ + readonly parts = new Map(); + readonly uploads: Array<{ blobId: string; size: number }> = []; + /** The order things happened in, for tests about order. */ + readonly log: string[] = []; + /** Every create is refused with this. */ + refuseCreate: { type: string; description?: string } | null = null; + /** The server does the work of the next request naming this method, and the answer never arrives. */ + loseAnswerOf: string | null = null; + /** The next upload fails like a dropped connection. */ + failUpload = false; + now = Date.UTC(2026, 10, 3); + private nextCreated = 1; + private nextBlob = 1; + private nextPart = 1; + private createdInRequest = new Map(); + private readonly destroyedSubmissions: string[] = []; + + drafts(): FakeEmail[] { + return [...this.emails.values()].filter((email) => (email.mailboxIds as Record).drafts); + } + + submissionsDestroyed(): readonly string[] { + return this.destroyedSubmissions; + } + + private knows(blobId: string): boolean { + if (this.blobs.has(blobId)) return true; + return [...this.emails.values()].some((email) => (email.attachments as Part[] | undefined)?.some((part) => part.blobId === blobId)); + } + + /** The bytes behind an upload or a part still alive. */ + bytesOf(blobId: string): Uint8Array | undefined { + return this.blobs.get(blobId) ?? (this.knows(blobId) ? this.parts.get(blobId) : undefined); + } + + private partsOf(create: Record): Part[] { + const leaves = (part: Part): Part[] => (part.subParts ? part.subParts.flatMap(leaves) : [part]); + return [...((create.attachments ?? []) as Part[]), ...(create.bodyStructure ? leaves(create.bodyStructure as Part) : [])].filter((part) => part.blobId); + } + + private createOne(create: Record): { id: string; blobId: string; threadId: string; size: number } | { type: string; description?: string } { + if (this.refuseCreate) return this.refuseCreate; + const named = this.partsOf(create); + if (named.some((part) => !this.knows(part.blobId as string))) return { type: 'blobNotFound' }; + const id = `srv${this.nextCreated++}`; + const values = (create.bodyValues ?? {}) as Record; + const attachments = named.map((part) => { + const data = this.bytesOf(part.blobId as string) ?? new Uint8Array(); + const fresh = `part${this.nextPart++}`; + this.parts.set(fresh, data); + return { ...part, partId: fresh, blobId: fresh, size: data.length }; + }); + const size = Object.values(values).reduce((sum, value) => sum + value.value.length, 0) + attachments.reduce((sum, part) => sum + part.size, 0); + this.addEmail({ + id, + threadId: `t-${id}`, + mailboxIds: create.mailboxIds as Record, + keywords: create.keywords as Record, + subject: String(create.subject ?? ''), + from: create.from as never, + to: create.to as never, + messageId: create.messageId as never, + receivedAt: new Date(this.now).toISOString(), + size, + blobId: `raw-${id}`, + preview: '', + hasAttachment: named.length > 0, + textBody: (create.textBody ?? []) as never, + htmlBody: (create.htmlBody ?? create.textBody ?? []) as never, + bodyValues: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, { value: value.value, isTruncated: false, isEncodingProblem: false }])) as never, + attachments: attachments as never, + }); + this.log.push(`create ${id}`); + return { id, blobId: `raw-${id}`, threadId: `t-${id}`, size }; + } + + private submissionSet(args: Record): Record { + const created: Record = {}; + const notCreated: Record = {}; + for (const [key, create] of Object.entries((args.create ?? {}) as Record)) { + const emailId = create.emailId.startsWith('#') ? this.createdInRequest.get(create.emailId.slice(1)) : create.emailId; + if (!emailId || !this.emails.has(emailId)) { + notCreated[key] = { type: 'invalidProperties' }; + continue; + } + created[key] = { id: this.addSubmission(emailId, 'final').id, undoStatus: 'final' }; + this.log.push(`submit ${emailId}`); + const email = this.emails.get(emailId) as FakeEmail; + const keywords = { ...(email.keywords as Record) }; + delete keywords.$draft; + this.updateEmail(emailId, { keywords, mailboxIds: { sent: true } }); + } + const destroyed = (args.destroy ?? []) as string[]; + for (const id of destroyed) { + this.submissions.delete(id); + this.destroyedSubmissions.push(id); + } + return { + accountId: args.accountId, + ...(args.create ? { created } : {}), + ...(Object.keys(notCreated).length > 0 ? { notCreated } : {}), + ...(args.destroy ? { destroyed } : {}), + }; + } + + protected override handle(name: string, args: Record): Record { + if (name === 'EmailSubmission/set') return this.submissionSet(args); + if (name !== 'Email/set') return super.handle(name, args); + const created: Record = {}; + const notCreated: Record = {}; + for (const [creationId, create] of Object.entries((args.create ?? {}) as Record>)) { + const answer = this.createOne(create); + if ('id' in answer) { + created[creationId] = answer; + this.createdInRequest.set(creationId, answer.id); + } else { + notCreated[creationId] = answer; + } + } + for (const id of (args.destroy ?? []) as string[]) this.log.push(`destroy ${id}`); + return { + ...super.handle(name, args), + ...(args.create ? { created } : {}), + ...(Object.keys(notCreated).length > 0 ? { notCreated } : {}), + }; + } + + override async request(calls: MethodCall[]): Promise { + this.createdInRequest = new Map(); + const answer = await super.request(calls); + if (this.loseAnswerOf && calls.some(([name]) => name === this.loseAnswerOf)) { + this.loseAnswerOf = null; + throw new TransportError('network', 'The connection broke'); + } + return answer; + } + + override async upload(_jmap?: string, data?: Uint8Array): Promise<{ blobId: string; type: string; size: number }> { + if (this.offline || this.failUpload) { + this.failUpload = false; + throw new TransportError('network', 'Failed to fetch'); + } + const blobId = `up${this.nextBlob++}`; + const bytes = data ?? new Uint8Array(); + this.blobs.set(blobId, bytes); + this.uploads.push({ blobId, size: bytes.length }); + this.log.push(`upload ${blobId}`); + return { blobId, type: 'application/octet-stream', size: bytes.length }; + } +} diff --git a/lib/store/__tests__/helpers/in-process-store.ts b/lib/store/__tests__/helpers/in-process-store.ts index fb7119c2f..cb8402f9e 100644 --- a/lib/store/__tests__/helpers/in-process-store.ts +++ b/lib/store/__tests__/helpers/in-process-store.ts @@ -19,6 +19,19 @@ const NO_SERVER: JmapTransport = { upload: async () => ({ blobId: 'x', type: '', size: 0 }), }; +/** + * What crosses to the worker and back is a copy, except a Blob: that goes by + * reference there, and jsdom's Blob can't be cloned here. + */ +function acrossWorker(value: T): T { + if (value instanceof Blob) return value; + if (Array.isArray(value)) return value.map(acrossWorker) as T; + if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, acrossWorker(item)])) as T; + } + return structuredClone(value); +} + export class InProcessStore implements StoreBackend { readonly engines = new Map(); readonly dbs = new Map(); @@ -71,8 +84,8 @@ export class InProcessStore implements StoreBackend { async area(account: string, name: string, params?: unknown): Promise { if (this.failArea?.(name)) throw new Error(`${name} failed`); - const result = await this.engine(account).areaCall(name, structuredClone(params)); - return (result === undefined ? undefined : structuredClone(result)) as T; + const result = await this.engine(account).areaCall(name, acrossWorker(params)); + return (result === undefined ? undefined : acrossWorker(result)) as T; } async setScope(account: string, scope: StoreScope): Promise { diff --git a/lib/store/__tests__/mail-answers.test.ts b/lib/store/__tests__/mail-answers.test.ts index 980b6ea12..7cdcd3082 100644 --- a/lib/store/__tests__/mail-answers.test.ts +++ b/lib/store/__tests__/mail-answers.test.ts @@ -406,7 +406,9 @@ describe('attachments', () => { class Files extends FakeJmapMail { downloads: string[] = []; missing = new Set(); - override async download(_jmap?: string, blobId?: string): Promise { + override async download(_jmap?: string, blobId?: string, type?: string): Promise { + // Raw messages are kept too since exports read them; these tests are about attachments. + if (type === 'message/rfc822') return new Uint8Array([9]); this.downloads.push(String(blobId)); if (this.offline) throw new TransportError('network', 'Failed to fetch'); if (this.missing.has(String(blobId))) throw new TransportError('http', 'Gone', 404); @@ -426,9 +428,9 @@ describe('attachments', () => { const { engine, db } = engineFor(server, { ...EVERYTHING, maxAttachmentBytes: 1000 }); await engine.requestSync(); expect(server.downloads).toEqual(['b-small']); - const stored = await engine.areaCall('mail.getBlob', { jmap: JMAP_ACCOUNT, blobId: 'b-small' }) as { type: string; data: Uint8Array }; + const stored = await engine.areaCall('mail.getBlob', { jmap: JMAP_ACCOUNT, blobId: 'b-small' }) as { type: string; blob: Blob }; expect(stored.type).toBe('application/pdf'); - expect([...stored.data]).toEqual([1, 2, 3]); + expect([...new Uint8Array(await stored.blob.arrayBuffer())]).toEqual([1, 2, 3]); server.destroyEmail('small'); await engine.requestSync(); diff --git a/lib/store/__tests__/mail-sync.test.ts b/lib/store/__tests__/mail-sync.test.ts index 4d7b61185..0444c59ef 100644 --- a/lib/store/__tests__/mail-sync.test.ts +++ b/lib/store/__tests__/mail-sync.test.ts @@ -13,7 +13,8 @@ import { mailArea } from '../mail'; import { FAKE_SESSION, FakeJmap, JMAP_ACCOUNT } from './helpers/fake-jmap'; import { openFileStore, openMemoryStore } from './helpers/node-adapter'; -const EVERYTHING = { ...DEFAULT_SCOPE, days: null }; +// No files: raw messages would add a commit per message, and these tests are about mail. +const EVERYTHING = { ...DEFAULT_SCOPE, days: null, attachments: false }; const INBOX = { inMailbox: 'inbox' }; function engineFor(server: FakeJmap, db = openMemoryStore(), emit?: (e: StoreEvent) => void) { @@ -443,7 +444,7 @@ describe('query plans', () => { }, }; const answer = await answerRequest( - { account: 'a', db: spied as typeof db, session: FAKE_SESSION, scope: EVERYTHING, now: Date.now, emit: () => {}, overlay: (_jmap, _type, records) => records, keptByOthers: () => 0, requestSync: async () => {}, defaultJmap: JMAP_ACCOUNT, invertedKeywordSort: false }, + { account: 'a', db: spied as typeof db, session: FAKE_SESSION, scope: EVERYTHING, now: Date.now, emit: () => {}, overlay: (_jmap, _type, records) => records, keptByOthers: () => 0, sparedRecords: () => [], requestSync: async () => {}, defaultJmap: JMAP_ACCOUNT, invertedKeywordSort: false }, buildMethodTable([mailArea]), [['Email/query', { filter: INBOX, sort: [{ property: 'receivedAt', isAscending: false }], limit: 5 }, 'q']], ); diff --git a/lib/store/__tests__/outbox.test.ts b/lib/store/__tests__/outbox.test.ts index d820ab2d5..8afa94165 100644 --- a/lib/store/__tests__/outbox.test.ts +++ b/lib/store/__tests__/outbox.test.ts @@ -6,7 +6,6 @@ import { TransportError, type MethodCall } from '../engine/transport'; import { mailArea } from '../mail'; import { outboxArea } from '../outbox'; import { CLOCK_SKEW_MARGIN_MS, decideRetry, LOOKUP_TRUSTED_MS, type LookupArgs } from '../outbox/dedupe'; -import { addHeldFileSource } from '../outbox/calls'; import { flushPatches, type PatchOutcome, type PendingPatch } from '../outbox/flush-patches'; import type { StoredConflict, StoredOutboxEntry } from '../outbox/items'; import { mergePatches, patchConflicts } from '../outbox/patches'; @@ -485,9 +484,9 @@ describe('sends', () => { const held = await t.engine.areaCall('outbox.holdFile', { name: 'note.txt', type: 'text/plain', data }) as { blobId: string; size: number }; expect(held).toMatchObject({ size: 5, type: 'text/plain' }); expect(held.blobId).toMatch(/^bulwark-local-/); - const file = await t.engine.areaCall('outbox.heldFile', { blobId: held.blobId }) as { name: string; data: Uint8Array }; + const file = await t.engine.areaCall('outbox.heldFile', { blobId: held.blobId }) as { name: string; blob: Blob }; expect(file.name).toBe('note.txt'); - expect(new TextDecoder().decode(file.data)).toBe('hello'); + expect(await file.blob.text()).toBe('hello'); const id = await t.engine.areaCall('outbox.queueSend', { summary, item: { kind: 'raw', blobId: held.blobId, identityId: 'i' } }) as number; await t.engine.areaCall('outbox.remove', { id }); expect(await t.engine.areaCall('outbox.heldFile', { blobId: held.blobId })).toBeNull(); @@ -495,15 +494,6 @@ describe('sends', () => { expect((await read()).entries).toEqual([]); }); - it('leaves a file alone that another area holds', async () => { - const held = await t.engine.areaCall('outbox.holdFile', { name: 'draft.txt', type: 'text/plain', data: new Uint8Array(1) }) as { blobId: string }; - const stale = await t.engine.areaCall('outbox.holdFile', { name: 'old.txt', type: 'text/plain', data: new Uint8Array(1) }) as { blobId: string }; - addHeldFileSource(() => [held.blobId]); - expect(await t.engine.areaCall('outbox.dropOrphans', { olderThan: NOW + 1 })).toBe(1); - expect(await t.engine.areaCall('outbox.heldFile', { blobId: held.blobId })).not.toBeNull(); - expect(await t.engine.areaCall('outbox.heldFile', { blobId: stale.blobId })).toBeNull(); - }); - it('drops old files nothing needs', async () => { const data = new Uint8Array([1]); const orphan = await t.engine.areaCall('outbox.holdFile', { name: 'a', type: '', data }) as { blobId: string }; diff --git a/lib/store/__tests__/page-side.test.ts b/lib/store/__tests__/page-side.test.ts index a828a51d1..11e47f920 100644 --- a/lib/store/__tests__/page-side.test.ts +++ b/lib/store/__tests__/page-side.test.ts @@ -331,6 +331,42 @@ describe('the page\'s store', () => { expect(lost).toHaveBeenCalledWith('lost@x'); }); + it('still tells the page when a new owner takes over the open that found the key gone', async () => { + await open(); + const before = await getAccountRecord('lost@x'); + await forgetAccountKey('lost@x'); + configureStore({ + inputs: () => ({ session: FAKE_SESSION, auth: 'Basic x', invertedKeywordSort: true }), + refreshAuth: async () => null, + onEvent: () => {}, + onStoreLost: lost, + }); + const store = getStore(); + await store.ensureOpen('lost@x'); + const worker = FakeWorker.all[FakeWorker.all.length - 1]; + const post = worker.postMessage.bind(worker); + let dropped = false; + worker.postMessage = (message) => { + if (message.kind === 'open' && !dropped) { + // The first open never gets its answer: ownership moved under it. + dropped = true; + worker.posted.push(message); + return; + } + post(message); + }; + worker.becomeOwner(); + await vi.waitFor(() => expect(dropped).toBe(true)); + worker.becomeOwner(); + await vi.waitFor(() => expect(lost).toHaveBeenCalledWith('lost@x')); + expect(lost).toHaveBeenCalledTimes(1); + const after = await getAccountRecord('lost@x'); + const prunes = worker.posted.filter((m) => m.kind === 'prune-files') as unknown as Array<{ keep: string[] }>; + expect(prunes.at(-1)?.keep).toContain(after!.file); + expect(prunes.at(-1)?.keep).not.toContain(before!.file); + store.stop(); + }); + it('stays quiet on a normal reopen', async () => { await open(); const worker = await open(); diff --git a/lib/store/__tests__/scope.test.ts b/lib/store/__tests__/scope.test.ts index eea9d21be..4bb7472f3 100644 --- a/lib/store/__tests__/scope.test.ts +++ b/lib/store/__tests__/scope.test.ts @@ -4,6 +4,7 @@ import { DEFAULT_SCOPE, type StoreArea, type StoreScope } from '../area'; import { calendarArea } from '../calendar'; import { AccountEngine } from '../engine/account-engine'; import { mailArea } from '../mail'; +import { outboxArea } from '../outbox'; import { CALENDAR_SESSION, FakeCalendarJmap } from './helpers/fake-calendar'; import { FAKE_SESSION, FakeJmap, JMAP_ACCOUNT } from './helpers/fake-jmap'; import { openMemoryStore } from './helpers/node-adapter'; @@ -39,7 +40,7 @@ function setup(server: FakeJmap, scope: StoreScope, areas: StoreArea[] = [mailAr } const ids = (db: ReturnType, table = 'email') => db.all<{ id: string }>(`SELECT id FROM ${table} ORDER BY id`).map((row) => row.id); -const blobs = (db: ReturnType) => db.all<{ blob_id: string }>('SELECT blob_id FROM blob ORDER BY blob_id').map((row) => row.blob_id); +const blobs = (db: ReturnType) => db.all<{ blob_id: string }>("SELECT blob_id FROM blob WHERE blob_id LIKE 'b-%' ORDER BY blob_id").map((row) => row.blob_id); const count = (db: ReturnType, table: string) => Number(db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM ${table}`)?.n); describe('a tightened scope', () => { @@ -135,6 +136,44 @@ describe('a tightened scope', () => { expect(blobs(db)).toEqual(['b-1']); }); + it('takes the chunks of a forbidden attachment too, not only its row', async () => { + const server = new Files(); + server.withFile('m', 'b-1', 3 * 1024 * 1024, 1); + const { engine, db } = setup(server, EVERYTHING); + await engine.requestSync(); + expect(blobs(db)).toEqual(['b-1']); + expect(count(db, 'blob_chunk')).toBeGreaterThan(0); + engine.setScope({ ...EVERYTHING, attachments: false }); + await engine.requestSync(); + expect(count(db, 'blob')).toBe(0); + expect(count(db, 'blob_chunk')).toBe(0); + }); + + it('never takes the file of a pinned message for the total', async () => { + const server = new Files(); + server.withFile('pinned', 'b-pinned', 100, 3); + const pinned = server.emails.get('pinned') as { keywords?: Record }; + pinned.keywords = { ...pinned.keywords, $pinned: true }; + server.withFile('other', 'b-other', 100, 2); + const { engine, db } = setup(server, EVERYTHING); + await engine.requestSync(); + engine.setScope({ ...EVERYTHING, maxTotalBytes: 150 }); + await engine.requestSync(); + expect(blobs(db)).toEqual(['b-pinned']); + }); + + it('keeps an old draft that is only on this device', async () => { + const server = new FakeJmap(); + server.addEmail({ id: 'old', receivedAt: iso(100) }); + server.addEmail({ id: 'local-draft', receivedAt: iso(100) }); + const { engine, db } = setup(server, EVERYTHING, [mailArea, outboxArea]); + await engine.requestSync(); + db.run("INSERT INTO draft_pending (local_id, jmap, seq, create_json, message_id, created_at) VALUES ('local-draft', ?, 1, '{}', 'm@x', 1)", [JMAP_ACCOUNT]); + engine.setScope({ ...EVERYTHING, days: 30 }); + await engine.requestSync(); + expect(ids(db)).toEqual(['local-draft']); + }); + it('drops attachments over the new size', async () => { const server = new Files(); server.withFile('small', 'b-small', 10, 1); @@ -302,9 +341,10 @@ describe('what the storage page reads', () => { await engine.requestSync(); const before = await engine.areaCall('store.usage', undefined) as { bytes: number; parts: Array<{ part: string; bytes: number; items: number }> }; const parts = Object.fromEntries(before.parts.map((part) => [part.part, part])); - expect(parts.attachments).toEqual({ part: 'attachments', bytes: 50_000, items: 1 }); + // The file, and the raw messages an export reads. + expect(parts.attachments.bytes).toBeGreaterThanOrEqual(50_000); expect(parts.mail.items).toBe(2); - expect(parts.mail.bytes).toBe(before.bytes - 50_000); + expect(parts.mail.bytes).toBe(before.bytes - parts.attachments.bytes); engine.setScope({ ...EVERYTHING, days: 30, attachments: false }); await engine.requestSync(); diff --git a/lib/store/__tests__/transport-real.test.ts b/lib/store/__tests__/transport-real.test.ts new file mode 100644 index 000000000..dd0b0eecf --- /dev/null +++ b/lib/store/__tests__/transport-real.test.ts @@ -0,0 +1,116 @@ +// @vitest-environment node +/** The real FetchTransport against a real local HTTP server: what a fake can't tell about bodies. */ +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { DEFAULT_SCOPE } from '../area'; +import { AccountEngine } from '../engine/account-engine'; +import { FetchTransport, TransportError, type StoreSession } from '../engine/transport'; +import { mailArea } from '../mail'; +import { FakeJmapMail } from './helpers/fake-jmap-mail'; +import { FAKE_SESSION, JMAP_ACCOUNT } from './helpers/fake-jmap'; +import { openMemoryStore } from './helpers/node-adapter'; + +const BIG = 3 * 1024 * 1024 + 7; +let server: Server; +let base: string; +let auth: string[] = []; + +beforeAll(async () => { + server = createServer((req, res) => { + auth.push(String(req.headers.authorization)); + if (req.url?.includes('/whole/')) { + res.writeHead(200, { 'Content-Length': String(BIG) }); + res.end(Buffer.alloc(BIG, 5)); + return; + } + if (req.url?.includes('/broken/')) { + // Promises more than it sends, then the connection goes. + res.writeHead(200, { 'Content-Length': '500000' }); + res.write(Buffer.alloc(1000, 1), () => res.destroy()); + return; + } + if (req.url?.includes('/refused/')) { + res.writeHead(404); + res.end('nope'); + return; + } + res.writeHead(500); + res.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(() => { + server.closeAllConnections(); + server.close(); +}); + +function transport(): FetchTransport { + const session: StoreSession = { ...FAKE_SESSION, downloadUrl: `${base}/d/{accountId}/{blobId}/{name}?type={type}` }; + return new FetchTransport({ session: () => session, auth: () => 'Basic x', refreshAuth: async () => 'Basic x' }); +} + +describe('FetchTransport against a real server', () => { + it('hands a file over in pieces, none of them the whole file', async () => { + const pieces: number[] = []; + let bytes = 0; + await transport().downloadStream('j', 'whole', 'application/pdf', 'a.pdf', (part) => { + pieces.push(part.length); + bytes += part.length; + expect(part.every((value) => value === 5)).toBe(true); + }); + expect(bytes).toBe(BIG); + expect(pieces.length).toBeGreaterThan(1); + expect(Math.max(...pieces)).toBeLessThan(BIG); + expect(auth.at(-1)).toBe('Basic x'); + }); + + it('still gives a whole file from download', async () => { + expect((await transport().download('j', 'whole', 'application/pdf', 'a.pdf')).length).toBe(BIG); + }); + + it('calls a connection that drops halfway an interrupted transfer, in both', async () => { + const stream = await transport().downloadStream('j', 'broken', 'a/b', 'a', () => {}).catch((error) => error); + expect(stream).toBeInstanceOf(TransportError); + expect(stream).toMatchObject({ kind: 'network', interrupted: true }); + const whole = await transport().download('j', 'broken', 'a/b', 'a').catch((error) => error); + expect(whole).toMatchObject({ kind: 'network', interrupted: true }); + }); + + it('says what the server said for a refusal', async () => { + expect(await transport().downloadStream('j', 'refused', 'a/b', 'a', () => {}).catch((error) => error)).toMatchObject({ kind: 'http', status: 404 }); + }); + + it('lets an error from the sink out as it is, and ends the download', async () => { + class Stop extends Error {} + await expect(transport().downloadStream('j', 'whole', 'a/b', 'a', () => { throw new Stop(); })).rejects.toBeInstanceOf(Stop); + }); +}); + +describe('the attachment pass with it', () => { + it('keeps a file that comes in pieces, and carries on past one that breaks', async () => { + const real = transport(); + const fake = new FakeJmapMail({ mailboxes: [{ id: 'inbox', role: 'inbox' }] }); + fake.addEmail({ id: 'first', blobId: 'refused', size: 500_000_000, receivedAt: new Date(Date.UTC(2026, 9, 3)).toISOString(), attachments: [{ partId: '2', blobId: 'broken', size: 500000, type: 'a/b', name: 'broken' }] as never }); + fake.addEmail({ id: 'second', blobId: 'refused', size: 500_000_000, receivedAt: new Date(Date.UTC(2026, 9, 2)).toISOString(), attachments: [{ partId: '2', blobId: 'whole', size: BIG, type: 'a/b', name: 'whole' }] as never }); + const db = openMemoryStore(); + const engine = new AccountEngine({ + account: 'a', + db, + session: FAKE_SESSION, + transport: { request: (calls) => fake.request(calls), download: real.download.bind(real), downloadStream: real.downloadStream.bind(real), upload: real.upload.bind(real) }, + areas: [mailArea], + scope: { ...DEFAULT_SCOPE, days: null }, + sleep: async () => {}, + }); + await engine.requestSync(); + expect(await engine.areaCall('mail.getBlob', { jmap: JMAP_ACCOUNT, blobId: 'broken' })).toBeNull(); + const kept = await engine.areaCall('mail.getBlob', { jmap: JMAP_ACCOUNT, blobId: 'whole' }) as { blob: Blob }; + expect(kept.blob.size).toBe(BIG); + expect(Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM blob_chunk WHERE blob_id = 'whole'")?.n)).toBe(4); + // Not given up on: a broken transfer says nothing about the file. + expect(db.get<{ refused: number }>("SELECT refused FROM email_part WHERE blob_id = 'broken'")?.refused).toBe(0); + }); +}); diff --git a/lib/store/area.ts b/lib/store/area.ts index e5440d74b..a442ae641 100644 --- a/lib/store/area.ts +++ b/lib/store/area.ts @@ -82,6 +82,8 @@ export interface StoreContext { overlay(jmap: string, type: string, records: T[]): T[]; /** Attachments and files the other areas keep: `scope.maxTotalBytes` is for all of them together. */ keptByOthers(area: string): number; + /** Records of one type another area needs to stay, whatever the window says. */ + sparedRecords(jmap: string, type: string): string[]; /** Starts a sync pass, or one more after the running pass. Resolves when they're done. */ requestSync(): Promise; } @@ -98,6 +100,10 @@ export interface SyncContext extends StoreContext { transport: JmapTransport; /** True once the engine is closing or lost the network: stop at the next batch. */ stopped(): boolean; + /** A newer pass was asked for. Work that can wait stops, and the next pass carries on with it. */ + syncWanted(): boolean; + /** Waits, to stay polite to the server. */ + pause(ms: number): Promise; syncState(jmap: string, type: string): SyncStateRow; /** Call inside the transaction that writes the data, so the two can't disagree. */ setSyncState(jmap: string, type: string, row: Partial): void; @@ -108,6 +114,8 @@ export interface Syncer { name: string; /** The JMAP capability an account must advertise for this to run for it. */ capability: string; + /** Runs after every other syncer of the pass, for work that can wait. */ + low?: boolean; /** Runs one pass for one JMAP account. Must be safe to stop between batches and start again. */ run(ctx: SyncContext, jmap: string): Promise; } @@ -129,6 +137,8 @@ export interface MethodContext extends StoreContext { invertedKeywordSort: boolean; /** Earlier responses in this request, for creation-id references. */ createdIds: Map; + /** The whole request this call came in. */ + calls: readonly MethodCall[]; } export type MethodHandler = (ctx: MethodContext, args: Record, call: MethodCall) => MethodResult | Promise; @@ -158,4 +168,8 @@ export interface StoreArea { usage?: (ctx: Pick) => StoreUsagePart[]; /** Attachments and file contents it holds: what counts towards `scope.maxTotalBytes`. */ keptBytes?: (ctx: Pick) => number; + /** Records it needs, stored by another area, that no window may take away. */ + spares?: (ctx: Pick, jmap: string, type: string) => string[]; + /** At start and when the scope changes: a tighter policy applies at once, not at the next sync. */ + scopeChanged?(ctx: StoreContext): void; } diff --git a/lib/store/client/api.ts b/lib/store/client/api.ts index 846ac03b5..81c8d2e5b 100644 --- a/lib/store/client/api.ts +++ b/lib/store/client/api.ts @@ -84,6 +84,11 @@ class PageStore implements StoreBackend { private readonly opening = new Map>(); /** The engine can't start in this browser: don't keep every caller waiting for an owner. */ private broken = false; + /** + * Accounts whose key was just made, until their store has opened. A new owner + * can start the open again, and the second try finds the key the first one made. + */ + private readonly keyJustMade = new Set(); /** What the owner's engine was last told to keep. */ private readonly sentScope = new Map(); @@ -136,8 +141,9 @@ class PageStore implements StoreBackend { const run = (async () => { const client = this.started(); const { keyHex, record, fresh } = await openAccountKey(account, this.hooks()?.keyLayers?.() ?? []); + if (fresh) this.keyJustMade.add(account); // The old file, if the key alone went, can never be read again. - if (fresh) await client.pruneFiles(await knownStoreFiles()).catch(() => {}); + if (this.keyJustMade.has(account)) await client.pruneFiles(await knownStoreFiles()).catch(() => {}); const inputs = this.hooks()?.inputs(account) ?? { session: null, auth: null, invertedKeywordSort: null }; if (inputs.scope) this.sentScope.set(account, JSON.stringify(inputs.scope)); const opened = await client.openAccount({ @@ -149,7 +155,8 @@ class PageStore implements StoreBackend { invertedKeywordSort: inputs.invertedKeywordSort, auth: inputs.auth, }); - if (fresh || opened?.created) { + // Whichever try of them gets here first says so. + if (this.keyJustMade.delete(account) || opened?.created) { if (readOpenedAccounts().includes(account)) this.hooks()?.onStoreLost?.(account); } rememberOpened(account, true); diff --git a/lib/store/drafts/calls.ts b/lib/store/drafts/calls.ts new file mode 100644 index 000000000..581dcdf9a --- /dev/null +++ b/lib/store/drafts/calls.ts @@ -0,0 +1,245 @@ +/** What the page's flush asks the store. Each call is one transaction, so two tabs can't interleave. */ + +import type { AreaCall, StoreContext } from '../area'; +import { upsertEmail, type EmailObject } from '../mail/records'; +import { savePatch, EMAIL_TYPE } from '../outbox/email-set'; +import type { ConflictSummary } from '../outbox/items'; +import { applyPatch, destroyConflicts, stateOf, type RecordState } from '../outbox/patches'; +import type { DraftCreate } from './local-email'; +import { destroyForDraft, ghostsOf, isPlainDraft, listedDrafts, pendingForFlush, replaceLocalRow, replacesOf, resolveStandIns, versionOf, type DraftError, type DraftRow, type DraftStatus, type DraftVersion } from './pending'; + +export interface PendingDraft { + localId: string; + jmap: string; + seq: number; + subject: string; + create: DraftCreate; + messageId: string; + replaces: ReturnType; + status: DraftStatus; + firstAttemptAt: number | null; + /** What this version should look like on the server. */ + self: DraftVersion; + /** Earlier versions that may have reached the server. */ + ghosts: DraftVersion[]; +} + +export interface DraftGhost extends DraftVersion { + jmap: string; +} + +export interface DraftElsewhere { + id: number; + login: string; + jmap: string | null; + recordId: string; +} + +/** What the outbox view shows of a draft that's only on this device. */ +export interface LocalDraftSummary { + localId: string; + jmap: string; + subject: string; + status: DraftStatus; + error?: DraftError; + createdAt: number; +} + +/** A stand-in the page may still hold is resolved this long after it reached the server. */ +const MAP_KEPT_MS = 30 * 24 * 60 * 60 * 1000; + +function toPending(row: DraftRow): PendingDraft { + return { + localId: row.local_id, + jmap: row.jmap, + seq: Number(row.seq), + subject: row.subject ?? '', + create: JSON.parse(row.create_json) as DraftCreate, + messageId: row.message_id, + replaces: replacesOf(row), + status: row.status, + firstAttemptAt: row.first_attempt_at === null ? null : Number(row.first_attempt_at), + self: versionOf(row), + ghosts: ghostsOf(row), + }; +} + +function read(ctx: StoreContext, params: { before: number }): { rows: PendingDraft[]; ghosts: DraftGhost[] } { + return ctx.db.transaction(() => { + // What a closed tab left on the way may have arrived. + ctx.db.run("UPDATE draft_pending SET status = 'uncertain', sending_since = NULL WHERE status = 'sending' AND COALESCE(sending_since, 0) < ?", [params.before]); + ctx.db.run('DELETE FROM draft_id_map WHERE at < ?', [ctx.now() - MAP_KEPT_MS]); + return { + rows: pendingForFlush(ctx.db).map(toPending), + ghosts: ctx.db.all<{ jmap: string; message_id: string; first_at: number; last_at: number; subject: string; size: number }>('SELECT * FROM draft_ghost').map((row) => ({ + jmap: row.jmap, + messageId: row.message_id, + from: Number(row.first_at), + to: Number(row.last_at), + subject: row.subject, + size: Number(row.size), + })), + }; + }); +} + +function list(ctx: StoreContext): LocalDraftSummary[] { + return listedDrafts(ctx.db).map((row) => ({ + localId: row.local_id, + jmap: row.jmap, + subject: row.subject ?? '', + status: row.status, + createdAt: Number(row.created_at), + ...(row.error ? { error: JSON.parse(row.error) as DraftError } : {}), + })); +} + +/** False when it isn't there any more, or it's no longer waiting: replaced, discarded, another tab took it. */ +function claim(ctx: StoreContext, params: { localId: string }): boolean { + const now = ctx.now(); + return ctx.db.run( + `UPDATE draft_pending SET status = 'sending', sending_since = ?, first_attempt_at = COALESCE(first_attempt_at, ?), last_attempt_at = ? + WHERE local_id = ? AND status IN ('waiting', 'uncertain')`, + [now, now, now, params.localId], + ).changes === 1; +} + +/** Nothing arrived (waiting), it may have (uncertain), or the server said no (refused). */ +function failed(ctx: StoreContext, params: { localId: string; status: 'waiting' | 'uncertain' | 'refused'; error?: DraftError; neverSent?: boolean }): void { + ctx.db.run( + `UPDATE draft_pending SET status = ?, error = ?, sending_since = NULL, + first_attempt_at = CASE WHEN ? THEN NULL ELSE first_attempt_at END, + last_attempt_at = CASE WHEN ? THEN NULL ELSE last_attempt_at END + WHERE local_id = ?`, + [params.status, params.error ? JSON.stringify(params.error) : null, params.neverSent ? 1 : 0, params.neverSent ? 1 : 0, params.localId], + ); +} + +/** Try a refused draft again, once there may be room for it. */ +function retry(ctx: StoreContext, params: { localId: string }): void { + ctx.db.run("UPDATE draft_pending SET status = 'waiting', error = NULL WHERE local_id = ? AND status = 'refused'", [params.localId]); +} + +interface CreatedParams { + localId: string; + /** The row's place when it was taken: a later one means it was flagged or moved on the way. */ + seq: number; + jmap: string; + messageId: string; + serverId: string; + /** The new message as the server has it. Null when reading it back failed. */ + email: EmailObject | null; + /** What the server has of the versions this replaces, with null for one it no longer has. */ + server: Record; + /** Copies of earlier versions of this draft that still look untouched. */ + ghostCopies: Array<{ id: string; base: RecordState }>; +} + +function addConflict(ctx: StoreContext, jmap: string, recordId: string, summary: ConflictSummary): void { + ctx.db.run( + 'INSERT INTO conflict (jmap, type, record_id, summary, local, created_at) VALUES (?, ?, ?, ?, ?, ?)', + [jmap, EMAIL_TYPE, recordId, JSON.stringify(summary), JSON.stringify({ op: 'draft' }), ctx.now()], + ); +} + +/** + * The new version is on the server. Only now do the versions it replaces start + * to go, by the usual queue: a failure there leaves an old copy, never no copy. + * One changed elsewhere stays, with the user told: both are kept. + */ +function created(ctx: StoreContext, params: CreatedParams): { conflicts: string[] } { + const conflicts: string[] = []; + ctx.db.transaction(() => { + const now = ctx.now(); + const row = ctx.db.get('SELECT * FROM draft_pending WHERE local_id = ?', [params.localId]); + // Replaced or discarded while it was on its way: it left a ghost, and the + // next flush looks at this copy as it does at any other. + if (!row) return; + ctx.db.run('INSERT OR REPLACE INTO draft_id_map (local_id, jmap, server_id, at) VALUES (?, ?, ?, ?)', [row.local_id, row.jmap, params.serverId, now]); + replaceLocalRow(ctx.db, row, params.serverId, params.email); + if (Number(row.seq) !== params.seq) { + const after = JSON.parse(row.create_json) as RecordState; + const patch = JSON.stringify({ keywords: after.keywords, mailboxIds: after.mailboxIds }); + savePatch(ctx.db, { jmap: row.jmap, record_id: params.serverId, op: 'update', patch, base: null, server: null, subject: row.subject }, now); + const kept = ctx.db.get<{ json: string; gen: number }>('SELECT json, gen FROM email WHERE jmap = ? AND id = ?', [row.jmap, params.serverId]); + if (kept) upsertEmail(ctx.db, row.jmap, applyPatch(JSON.parse(kept.json) as EmailObject, JSON.parse(patch)), Number(kept.gen)); + } + ctx.db.run('DELETE FROM draft_pending WHERE local_id = ?', [row.local_id]); + ctx.db.run('UPDATE draft_elsewhere SET local_id = NULL WHERE local_id = ?', [row.local_id]); + for (const copy of params.ghostCopies) { + const state = JSON.stringify(copy.base); + savePatch(ctx.db, { jmap: row.jmap, record_id: copy.id, op: 'destroy', patch: '{}', base: state, server: state, subject: row.subject }, now); + } + for (const target of replacesOf(row)) { + const server = params.server[target.id]; + // Not read on this run (added on the way). With what it looked like known, + // the queue checks it again. Without that, it stays: a spare copy. + if (server === undefined && !target.base) continue; + // A message this device didn't keep: what the server says now is all there is to go by. + const unchanged = server === undefined || (server !== null && (target.base ? !destroyConflicts(target.base, server) : isPlainDraft(ctx.db, target.jmap, server))); + if (unchanged) { + const state = JSON.stringify(target.base ?? stateOf(server as EmailObject)); + savePatch(ctx.db, { jmap: target.jmap, record_id: target.id, op: 'destroy', patch: '{}', base: state, server: state, subject: target.subject }, now); + continue; + } + if (server) { + const gen = Number(ctx.db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM email WHERE jmap = ?', [target.jmap])?.gen ?? 0); + upsertEmail(ctx.db, target.jmap, server, gen); + } + const subject = (typeof server?.subject === 'string' ? server.subject : target.subject) ?? row.subject ?? ''; + addConflict(ctx, row.jmap, params.serverId, { subject, op: 'draft', reason: server ? 'changed' : 'gone' }); + conflicts.push(subject); + } + }); + ctx.emit({ type: 'changed', account: ctx.account, collections: ['Email', 'Mailbox', 'Thread'] }); + return { conflicts }; +} + +/** The ghosts of a discarded draft looked up: copies that still look untouched go through the usual queue. */ +function ghostsLooked(ctx: StoreContext, params: { jmap: string; messageIds: string[]; found: Array<{ id: string; base: RecordState }> }): void { + ctx.db.transaction(() => { + for (const messageId of params.messageIds) ctx.db.run('DELETE FROM draft_ghost WHERE jmap = ? AND message_id = ?', [params.jmap, messageId]); + for (const copy of params.found) { + const state = JSON.stringify(copy.base); + savePatch(ctx.db, { jmap: params.jmap, record_id: copy.id, op: 'destroy', patch: '{}', base: state, server: state, subject: null }, ctx.now()); + } + }); +} + +/** An old version in another login, to be removed through it once the new one here is up. */ +function noteElsewhere(ctx: StoreContext, params: { localId: string; login: string; jmap?: string; id: string }): void { + const waiting = ctx.db.get('SELECT 1 AS x FROM draft_pending WHERE local_id = ?', [params.localId]); + ctx.db.run('INSERT INTO draft_elsewhere (local_id, login, jmap, record_id) VALUES (?, ?, ?, ?)', [waiting ? params.localId : null, params.login, params.jmap ?? null, params.id]); +} + +function elsewhereReady(ctx: StoreContext): DraftElsewhere[] { + return ctx.db.all<{ id: number; login: string; jmap: string | null; record_id: string }>('SELECT id, login, jmap, record_id FROM draft_elsewhere WHERE local_id IS NULL') + .map((row) => ({ id: Number(row.id), login: row.login, jmap: row.jmap, recordId: row.record_id })); +} + +function elsewhereDone(ctx: StoreContext, params: { id: number }): void { + ctx.db.run('DELETE FROM draft_elsewhere WHERE id = ?', [params.id]); +} + +/** A send took care of this draft without the page holding a client's word for it. */ +function discard(ctx: StoreContext, params: { jmap: string; id: string }): void { + ctx.db.transaction(() => { + destroyForDraft(ctx, params.jmap, params.id, undefined); + }); + ctx.emit({ type: 'changed', account: ctx.account, collections: ['Email', 'Mailbox', 'Thread'] }); +} + +export const DRAFT_CALLS: Readonly> = { + 'drafts.read': read, + 'drafts.list': list, + 'drafts.claim': claim, + 'drafts.failed': failed, + 'drafts.retry': retry, + 'drafts.created': created, + 'drafts.ghostsLooked': ghostsLooked, + 'drafts.noteElsewhere': noteElsewhere, + 'drafts.elsewhereReady': elsewhereReady, + 'drafts.elsewhereDone': elsewhereDone, + 'drafts.discard': discard, + 'drafts.resolveStandIns': (ctx, params: { ids: string[] }) => resolveStandIns(ctx.db, params.ids), +}; diff --git a/lib/store/drafts/flush.ts b/lib/store/drafts/flush.ts new file mode 100644 index 000000000..2c3aaccd8 --- /dev/null +++ b/lib/store/drafts/flush.ts @@ -0,0 +1,320 @@ +/** + * Sends the drafts saved on this device, written against a small `io` so the + * page can run it through the JMAP client while the logic is tested next to the + * store (decision 15). + * + * For each draft, in order: look up whether it arrived already when an answer got + * lost, read the versions it replaces, upload its files, create it, and report. + * The store only then queues the old versions for removal, so a failure anywhere + * leaves a copy on the server or on the device, never neither. Anything that + * looks like an outage ends the run and the draft waits. A refusal stays with + * the draft, on the device, for the user to see. + * + * Message-ID lookups match on the client: `header` and `text` filters find no + * Message-ID on either Stalwart (decision 4). A Message-ID alone says little: + * another client can save again under it. So a copy only counts as a version + * made here when it still looks like one. Anything else is left alone: a spare + * copy on the server is always better than no copy. + */ + +import { JmapMethodError, responseFor, type JmapResponse, type MethodCall } from '../engine/transport'; +import { EMAIL_BODY_PROPERTIES, EMAIL_LIST_PROPERTIES, MAIL_USING, type EmailObject } from '../mail/records'; +import { CLOCK_SKEW_MARGIN_MS } from '../outbox/dedupe'; +import { isLocalBlobId } from '../outbox/items'; +import type { RecordState } from '../outbox/patches'; +import type { DraftGhost, PendingDraft } from './calls'; +import { blobPartsOf, heldBlobsOf, withUploadedBlobs, type CreatePart } from './local-email'; +import type { DraftError, DraftVersion } from './pending'; + +export interface FoundCopy { + id: string; + base: RecordState; +} + +export interface DraftFlushStore { + read(): Promise<{ rows: PendingDraft[]; ghosts: DraftGhost[] }>; + claim(localId: string): Promise; + failed(localId: string, status: 'waiting' | 'uncertain' | 'refused', error?: DraftError, neverSent?: boolean): Promise; + created(result: { + localId: string; + seq: number; + jmap: string; + messageId: string; + serverId: string; + email: EmailObject | null; + server: Record; + ghostCopies: FoundCopy[]; + }): Promise<{ conflicts: string[] }>; + ghostsLooked(jmap: string, messageIds: string[], found: FoundCopy[]): Promise; +} + +export interface DraftFlushIo { + store: DraftFlushStore; + request(calls: MethodCall[], using: string[]): Promise; + /** Puts a held file on the server. Null when it's no longer on this device. */ + upload(jmap: string, blobId: string): Promise; + /** The server refused for good: no use retrying. Anything else counts as an outage. */ + isRefusal(error: unknown): boolean; + onRefused(subject: string, error: DraftError): void; + onConflict(subject: string): void; + /** One more draft is on the server. */ + onSent(): void; +} + +const PAGE = 500; +const MAX_PAGES = 5; +/** Set errors that mean "not now" rather than "no". */ +const RETRY_LATER = new Set(['serverUnavailable', 'serverFail', 'serverPartialFail', 'rateLimit']); +/** How far a copy's receivedAt may be from the attempts, in either direction, for clocks that differ. */ +const CLOSE_TO_ATTEMPT_MS = 15 * 60 * 1000; +/** A server's size counts headers and encoding, which only add to what the store counted: base64 about a third. */ +const SIZE_SLACK_BYTES = 64 * 1024; + +function bare(id: string): string { + return id.trim().replace(/^<|>$/g, ''); +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isOutage(error: unknown, io: DraftFlushIo): boolean { + if (error instanceof JmapMethodError) return RETRY_LATER.has(error.type); + return !io.isRefusal(error); +} + +interface CopyFound { + id: string; + messageId?: string[] | null; + mailboxIds?: Record; + keywords?: Record; + receivedAt?: string; + subject?: string | null; + size?: number; +} + +interface Copies { + drafts: string | null; + byMessageId: Map; +} + +const NO_COPIES: Copies = { drafts: null, byMessageId: new Map() }; + +/** Copies in Drafts by Message-ID. More than the pages hold can only cost a spare copy. */ +async function copiesInDrafts(io: DraftFlushIo, jmap: string, since: number): Promise { + const boxes = await io.request([['Mailbox/get', { accountId: jmap, properties: ['id', 'role'] }, 'm']], MAIL_USING); + const drafts = (responseFor(boxes, 'm')[1].list as Array<{ id: string; role?: string | null }>).find((box) => box.role === 'drafts'); + if (!drafts) return NO_COPIES; + const byMessageId = new Map(); + const filter = { operator: 'AND', conditions: [{ inMailbox: drafts.id }, { after: new Date(since - CLOCK_SKEW_MARGIN_MS).toISOString() }] }; + for (let page = 0; page < MAX_PAGES; page += 1) { + const response = await io.request([ + ['Email/query', { accountId: jmap, filter, sort: [{ property: 'receivedAt', isAscending: false }], position: page * PAGE, limit: PAGE }, 'q'], + ['Email/get', { + accountId: jmap, + '#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' }, + properties: ['id', 'messageId', 'mailboxIds', 'keywords', 'receivedAt', 'subject', 'size'], + }, 'g'], + ], MAIL_USING); + const ids = responseFor(response, 'q')[1].ids as string[]; + for (const email of responseFor(response, 'g')[1].list as CopyFound[]) { + for (const messageId of email.messageId ?? []) { + const key = bare(messageId); + byMessageId.set(key, [...(byMessageId.get(key) ?? []), email]); + } + } + if (ids.length < PAGE) break; + } + return { drafts: drafts.id, byMessageId }; +} + +/** Whether a copy is that version, untouched: only in Drafts, still a draft, from the time it was sent, the same subject and size. */ +function isThatVersion(copy: CopyFound, drafts: string | null, version: DraftVersion): boolean { + const boxes = Object.keys(copy.mailboxIds ?? {}).filter((box) => copy.mailboxIds?.[box]); + if (boxes.length !== 1 || boxes[0] !== drafts || !copy.keywords?.$draft) return false; + const at = Date.parse(String(copy.receivedAt)); + if (!Number.isFinite(at) || at < version.from - CLOSE_TO_ATTEMPT_MS || at > version.to + CLOSE_TO_ATTEMPT_MS) return false; + if ((copy.subject ?? '') !== version.subject) return false; + const size = copy.size ?? 0; + return size >= version.size * 0.9 && size <= version.size * 1.5 + SIZE_SLACK_BYTES; +} + +function versionCopies(copies: Copies, version: DraftVersion): FoundCopy[] { + return (copies.byMessageId.get(bare(version.messageId)) ?? []) + .filter((copy) => isThatVersion(copy, copies.drafts, version)) + .map((copy) => ({ id: copy.id, base: { keywords: copy.keywords ?? {}, mailboxIds: copy.mailboxIds ?? {} } })); +} + +async function readReplaced(io: DraftFlushIo, row: PendingDraft): Promise> { + const out: Record = {}; + const byAccount = new Map(); + for (const target of row.replaces) byAccount.set(target.jmap, [...(byAccount.get(target.jmap) ?? []), target.id]); + for (const [jmap, ids] of byAccount) { + const response = await io.request([['Email/get', { accountId: jmap, ids, properties: EMAIL_LIST_PROPERTIES }, 'r']], MAIL_USING); + const list = responseFor(response, 'r')[1].list as EmailObject[]; + for (const id of ids) out[id] = list.find((email) => email.id === id) ?? null; + } + return out; +} + +/** Reading the new message back can't fail the flush: it's on the server. */ +async function readCreated(io: DraftFlushIo, jmap: string, id: string): Promise { + try { + const response = await io.request([['Email/get', { + accountId: jmap, + ids: [id], + properties: [...EMAIL_LIST_PROPERTIES, ...EMAIL_BODY_PROPERTIES], + fetchAllBodyValues: true, + maxBodyValueBytes: 1_000_000, + }, 'n']], MAIL_USING); + return (responseFor(response, 'n')[1].list as EmailObject[])[0] ?? null; + } catch { + return null; + } +} + +function leaves(part: CreatePart): CreatePart[] { + return part.subParts ? part.subParts.flatMap(leaves) : [part]; +} + +/** + * The part ids a create names that have died: every new version of a message + * gets new ones, and the old ones go with the old version. The same files are in + * the versions this draft replaces, under the ids they have now. + */ +async function livingParts(io: DraftFlushIo, row: PendingDraft, named: CreatePart[]): Promise> { + const fresh = new Map(); + const ids = row.replaces.filter((target) => target.jmap === row.jmap).map((target) => target.id); + if (ids.length === 0) return fresh; + const response = await io.request([['Email/get', { accountId: row.jmap, ids, properties: ['id', 'attachments', 'textBody', 'htmlBody', 'bodyStructure'] }, 'p']], MAIL_USING); + const found = (responseFor(response, 'p')[1].list as Array<{ attachments?: CreatePart[]; textBody?: CreatePart[]; htmlBody?: CreatePart[]; bodyStructure?: CreatePart }>) + .flatMap((email) => [ + ...(email.attachments ?? []), + // Inline parts Stalwart 1.0 doesn't list under attachments. The text itself isn't a file. + ...[...(email.textBody ?? []), ...(email.htmlBody ?? []), ...(email.bodyStructure ? leaves(email.bodyStructure) : [])].filter((part) => !part.type?.startsWith('text/')), + ]) + .filter((part) => part.blobId); + const free = [...new Map(found.map((part) => [part.blobId, part])).values()]; + // What's still alive keeps its part, so a file of the same name can't take it. + const dead = named.filter((part) => { + const at = free.findIndex((candidate) => candidate.blobId === part.blobId); + if (at < 0) return true; + free.splice(at, 1); + return false; + }); + for (const part of dead) { + const at = free.findIndex((candidate) => candidate.name === part.name && (candidate.type ?? '') === (part.type ?? '')); + if (at < 0) continue; + fresh.set(part.blobId as string, free[at].blobId as string); + free.splice(at, 1); + } + return fresh; +} + +type RowOutcome = 'next' | 'stop'; + +async function flushRow(io: DraftFlushIo, row: PendingDraft, copies: Copies): Promise { + if (!(await io.store.claim(row.localId))) return 'next'; + // Each step knows what a failure means: before the create nothing has left. + let serverId = versionCopies(copies, row.self)[0]?.id; + let server: Record; + const stay = async (status: 'waiting' | 'uncertain') => { + await io.store.failed(row.localId, status, undefined, status === 'waiting' && row.firstAttemptAt === null); + return 'stop' as const; + }; + const refuse = async (error: DraftError) => { + await io.store.failed(row.localId, 'refused', error); + io.onRefused(row.subject, error); + return 'next' as const; + }; + try { + server = await readReplaced(io, row); + } catch (error) { + if (isOutage(error, io)) return stay(row.firstAttemptAt === null ? 'waiting' : 'uncertain'); + return refuse({ text: errorText(error) }); + } + if (!serverId) { + const ids = new Map(); + try { + for (const blobId of new Set(heldBlobsOf(row.create))) { + const real = await io.upload(row.jmap, blobId); + if (real === null) return refuse({ code: 'file_missing' }); + ids.set(blobId, real); + } + } catch (error) { + if (isOutage(error, io)) return stay(row.firstAttemptAt === null ? 'waiting' : 'uncertain'); + return refuse({ text: errorText(error) }); + } + const creationId = 'draft'; + type SetResult = Record>; + const create = async (): Promise => { + const response = await io.request([['Email/set', { + accountId: row.jmap, + create: { [creationId]: withUploadedBlobs(row.create, ids) }, + }, 'c']], MAIL_USING); + return responseFor(response, 'c')[1] as SetResult; + }; + let result: SetResult; + try { + result = await create(); + if (result.notCreated?.[creationId]?.type === 'blobNotFound') { + const named = blobPartsOf(row.create).filter((part) => !isLocalBlobId(part.blobId)); + const fresh = await livingParts(io, row, named); + if (fresh.size > 0) { + for (const [dead, alive] of fresh) ids.set(dead, alive); + result = await create(); + } + } + } catch (error) { + // It went out: from here on it may have arrived. + if (isOutage(error, io)) return stay('uncertain'); + return refuse({ text: errorText(error) }); + } + const refusal = result.notCreated?.[creationId]; + if (refusal) { + if (RETRY_LATER.has(refusal.type ?? '')) return stay('uncertain'); + return refuse({ type: refusal.type, text: refusal.description ?? refusal.type }); + } + serverId = result.created?.[creationId]?.id as string; + if (!serverId) return stay('uncertain'); + } + const email = await readCreated(io, row.jmap, serverId); + const ghostCopies = row.ghosts.flatMap((ghost) => versionCopies(copies, ghost)).filter((copy) => copy.id !== serverId); + const { conflicts } = await io.store.created({ localId: row.localId, seq: row.seq, jmap: row.jmap, messageId: row.messageId, serverId, email, server, ghostCopies }); + for (const subject of conflicts) io.onConflict(subject); + io.onSent(); + return 'next'; +} + +/** True when all went through to an answer, false when the run stopped on an outage. */ +export async function flushDrafts(io: DraftFlushIo): Promise { + const { rows, ghosts } = await io.store.read(); + if (rows.length === 0 && ghosts.length === 0) return true; + const accounts = new Set([...rows.map((row) => row.jmap), ...ghosts.map((entry) => entry.jmap)]); + for (const jmap of accounts) { + const ours = rows.filter((row) => row.jmap === jmap); + const theirGhosts = ghosts.filter((entry) => entry.jmap === jmap); + const attempts = [ + ...ours.filter((row) => row.firstAttemptAt !== null).map((row) => row.firstAttemptAt as number), + ...ours.flatMap((row) => row.ghosts.map((ghost) => ghost.from)), + ...theirGhosts.map((entry) => entry.from), + ]; + let copies = NO_COPIES; + if (attempts.length > 0) { + try { + copies = await copiesInDrafts(io, jmap, Math.min(...attempts)); + } catch (error) { + // Can't look now: nothing may go that might go twice. Refused for good: + // there's nothing to wait for, and a spare copy is the worst that can come of it. + if (isOutage(error, io)) return false; + } + if (theirGhosts.length > 0) { + await io.store.ghostsLooked(jmap, theirGhosts.map((entry) => entry.messageId), theirGhosts.flatMap((entry) => versionCopies(copies, entry))); + } + } + for (const row of ours) { + if ((await flushRow(io, row, copies)) === 'stop') return false; + } + } + return true; +} diff --git a/lib/store/drafts/local-email.ts b/lib/store/drafts/local-email.ts new file mode 100644 index 000000000..a62273d3a --- /dev/null +++ b/lib/store/drafts/local-email.ts @@ -0,0 +1,135 @@ +/** + * What a draft saved on this device looks like to everyone who reads it: the + * Email/set create the client wrote, turned into the list properties and the + * body a server would answer with. The composer reads it back straight after a + * save and matches attachments by name and size. + */ + +import { isLocalBlobId } from '../outbox/items'; +import type { StorageAdapter } from '../storage'; + +export type DraftCreate = Record; + +export interface CreatePart { + partId?: string; + blobId?: string; + type?: string; + name?: string | null; + disposition?: string | null; + cid?: string | null; + subParts?: CreatePart[]; +} + +interface BodyPart extends CreatePart { + size: number; +} + +/** A held file's size is known. A part of a kept message's is too. Else the server knows, and the list shows nothing. */ +function blobSize(db: StorageAdapter, jmap: string, blobId: string): number { + const row = isLocalBlobId(blobId) + ? db.get<{ size: number }>('SELECT size FROM upload WHERE local_id = ?', [blobId]) + : db.get<{ size: number }>('SELECT size FROM email_part WHERE jmap = ? AND blob_id = ? ORDER BY size DESC LIMIT 1', [jmap, blobId]); + return Number(row?.size ?? 0); +} + +function leaves(part: CreatePart): CreatePart[] { + return part.subParts ? part.subParts.flatMap(leaves) : [part]; +} + +/** Every blob part a create names, wherever it put them. */ +export function blobPartsOf(create: DraftCreate): CreatePart[] { + const listed = (create.attachments ?? []) as CreatePart[]; + const tree = create.bodyStructure ? leaves(create.bodyStructure as CreatePart) : []; + return [...listed, ...tree].filter((part) => typeof part.blobId === 'string'); +} + +export function heldBlobsOf(create: DraftCreate): string[] { + return blobPartsOf(create).map((part) => part.blobId as string).filter(isLocalBlobId); +} + +/** The create with the blob ids a flush got for its held files. */ +export function withUploadedBlobs(create: DraftCreate, uploaded: ReadonlyMap): DraftCreate { + const swap = (part: CreatePart): CreatePart => ({ + ...part, + ...(part.blobId && uploaded.has(part.blobId) ? { blobId: uploaded.get(part.blobId) } : {}), + ...(part.subParts ? { subParts: part.subParts.map(swap) } : {}), + }); + const out: DraftCreate = { ...create }; + if (Array.isArray(create.attachments)) out.attachments = (create.attachments as CreatePart[]).map(swap); + if (create.bodyStructure) out.bodyStructure = swap(create.bodyStructure as CreatePart); + return out; +} + +function previewOf(text: string): string { + return text.replace(/\s+/g, ' ').trim().slice(0, 256); +} + +function plainFromHtml(html: string): string { + return html.replace(/<(style|script)\b[^<]*(?:<(?!\/\1>)[^<]*)*<\/\1>/gi, ' ').replace(/<[^<>]*>/g, ' '); +} + +function headersOf(create: DraftCreate): Array<{ name: string; value: string }> { + const out: Array<{ name: string; value: string }> = []; + for (const [key, value] of Object.entries(create)) { + const match = /^header:([^:]+):asText$/.exec(key); + if (match && typeof value === 'string') out.push({ name: match[1], value: ` ${value}` }); + } + return out; +} + +/** + * The message as it's answered from the store. `id` also serves as thread id and + * blob id: nothing else has one yet. The raw message doesn't exist until the + * server made it, so an export finds no raw copy to read. + */ +export function localEmailFrom(db: StorageAdapter, jmap: string, id: string, create: DraftCreate, messageId: string, now: number): { list: Record & { id: string }; body: Record } { + const values = (create.bodyValues ?? {}) as Record; + const valueOf = (part: CreatePart) => values[part.partId ?? '']?.value ?? ''; + const asBodyPart = (part: CreatePart, type: string): BodyPart => ({ partId: part.partId, type: part.type ?? type, size: valueOf(part).length }); + const tree = create.bodyStructure ? leaves(create.bodyStructure as CreatePart) : []; + const textParts = (tree.length ? tree.filter((part) => part.partId && part.type === 'text/plain') : (create.textBody ?? []) as CreatePart[]).map((part) => asBodyPart(part, 'text/plain')); + const htmlParts = (tree.length ? tree.filter((part) => part.partId && part.type === 'text/html') : (create.htmlBody ?? []) as CreatePart[]).map((part) => asBodyPart(part, 'text/html')); + const attachments: BodyPart[] = blobPartsOf(create).map((part) => ({ + partId: part.blobId, + blobId: part.blobId, + type: part.type ?? 'application/octet-stream', + name: part.name ?? null, + size: blobSize(db, jmap, part.blobId as string), + disposition: part.disposition ?? 'attachment', + cid: part.cid ?? null, + })); + const text = textParts.map(valueOf).join('\n') || plainFromHtml(htmlParts.map(valueOf).join('\n')); + const size = [...textParts, ...htmlParts, ...attachments].reduce((sum, part) => sum + part.size, 0); + const at = new Date(now).toISOString(); + const list = { + id, + threadId: id, + blobId: id, + mailboxIds: create.mailboxIds, + keywords: create.keywords, + size, + receivedAt: at, + sentAt: at, + from: create.from ?? [], + to: create.to ?? [], + cc: create.cc ?? [], + subject: typeof create.subject === 'string' ? create.subject : '', + preview: previewOf(text), + hasAttachment: attachments.some((part) => part.disposition !== 'inline'), + } as Record & { id: string }; + const body: Record = { + bcc: create.bcc ?? [], + replyTo: create.replyTo ?? null, + messageId: [messageId], + inReplyTo: create.inReplyTo ?? null, + references: create.references ?? null, + headers: headersOf(create), + textBody: textParts.length ? textParts : htmlParts, + // A plain text draft lists its text part as the HTML body too, as a server does (RFC 8621 4.1.4). + htmlBody: htmlParts.length ? htmlParts : textParts, + attachments, + bodyStructure: create.bodyStructure ?? { type: 'multipart/mixed', subParts: [...textParts, ...htmlParts, ...attachments] }, + bodyValues: Object.fromEntries(Object.entries(values).map(([partId, value]) => [partId, { value: value.value ?? '', isEncodingProblem: false, isTruncated: false }])), + }; + return { list, body }; +} diff --git a/lib/store/drafts/pending.ts b/lib/store/drafts/pending.ts new file mode 100644 index 000000000..da2ff8072 --- /dev/null +++ b/lib/store/drafts/pending.ts @@ -0,0 +1,312 @@ +/** + * Drafts saved on this device. createDraft on the client saves by creating a new + * version and then destroying the one before, and that's how it reaches this + * file: a create hands out a stand-in id, and the destroy that follows names the + * new version in `replacedBy`. Each draft therefore has one pending row however + * often it's saved. A version on the server stays there, hidden here, until the + * new one has arrived (flush.ts). + * + * A version that may already have reached the server (its Message-ID was handed + * over, and the answer got lost) leaves a ghost behind when it's replaced. + */ + +import { isStandInId, newStandInId } from '@/lib/jmap/stand-in-ids'; +import type { MethodContext, RecordOverlay, StoreContext } from '../area'; +import { listObjects } from '../engine/core-schema'; +import { deleteEmail, storeBody, upsertEmail, type EmailObject } from '../mail/records'; +import { localEmail, nextSeq, pendingRow, queueDestroy, queueUpdate, savePatch, EMAIL_TYPE } from '../outbox/email-set'; +import { applyPatch, normalizePatch, patchProblem, stateOf, type Patch, type RecordState } from '../outbox/patches'; +import type { StorageAdapter } from '../storage'; +import { localEmailFrom, type DraftCreate } from './local-email'; + +export interface ReplacedDraft { + jmap: string; + id: string; + base: RecordState | null; + subject: string | null; +} + +/** What a version sent to the server should look like there, to tell it from anything else under its Message-ID. */ +export interface DraftVersion { + messageId: string; + /** Its first and last attempt. */ + from: number; + to: number; + subject: string; + size: number; +} + +export interface DraftRow { + local_id: string; + jmap: string; + seq: number; + subject: string | null; + create_json: string; + message_id: string; + replaces: string; + status: DraftStatus; + sending_since: number | null; + first_attempt_at: number | null; + last_attempt_at: number | null; + size: number; + ghosts: string; + error: string | null; + created_at: number; +} + +/** + * - waiting: goes up by itself when the server answers; + * - sending: on its way (a tab took it); + * - uncertain: the connection broke after it may have arrived. Looked up first; + * - refused: the server said no. Stays here until it's edited or tried again. + */ +export type DraftStatus = 'waiting' | 'sending' | 'uncertain' | 'refused'; + +export interface DraftError { + code?: 'file_missing'; + type?: string; + text?: string; +} + +interface SetError { type: string; description?: string } + +const notADraft = (): SetError => ({ type: 'serverUnavailable', description: 'This needs the server' }); + +function rowOf(db: StorageAdapter, localId: string): DraftRow | undefined { + return db.get('SELECT * FROM draft_pending WHERE local_id = ?', [localId]); +} + +export function replacesOf(row: Pick): ReplacedDraft[] { + return JSON.parse(row.replaces) as ReplacedDraft[]; +} + +/** A message of the Drafts folder with the draft keyword, in a request that doesn't also send it. */ +function isDraftCreate(ctx: MethodContext, jmap: string, create: DraftCreate): boolean { + if (ctx.calls.some(([name]) => name === 'EmailSubmission/set')) return false; + if (!(create.keywords as Record | undefined)?.$draft) return false; + const boxes = Object.keys((create.mailboxIds ?? {}) as object); + if (boxes.length === 0) return false; + const drafts = listObjects<{ id: string; role?: string | null }>(ctx.db, jmap, 'Mailbox').filter((box) => box.role === 'drafts').map((box) => box.id); + return boxes.every((box) => drafts.includes(box)); +} + +export function ghostsOf(row: Pick): DraftVersion[] { + return JSON.parse(row.ghosts) as DraftVersion[]; +} + +/** This version as a later one sees it, if it may have reached the server. */ +export function versionOf(row: DraftRow): DraftVersion { + const from = Number(row.first_attempt_at ?? 0); + return { messageId: row.message_id, from, to: Number(row.last_attempt_at ?? from), subject: row.subject ?? '', size: Number(row.size) }; +} + +/** Attempts in a long outage are few, but nothing should grow without end. */ +const MOST_GHOSTS = 50; + +function currentGen(db: StorageAdapter, jmap: string): number { + return Number(db.get<{ gen: number | null }>('SELECT MAX(gen) AS gen FROM email WHERE jmap = ?', [jmap])?.gen ?? 0); +} + +/** Writes the row lists and search read. A held file isn't something sync could fetch, and there's no raw message yet. */ +function writeLocalRow(db: StorageAdapter, jmap: string, id: string, create: DraftCreate, messageId: string, now: number): { size: number } { + const { list, body } = localEmailFrom(db, jmap, id, create, messageId, now); + const rowid = upsertEmail(db, jmap, list, currentGen(db, jmap)); + storeBody(db, rowid, JSON.stringify(list), body); + db.run("DELETE FROM email_part WHERE email_rowid = ? AND (raw = 1 OR blob_id LIKE 'bulwark-local-%')", [rowid]); + return { size: Number(list.size) }; +} + +export function createLocalDraft(ctx: MethodContext, jmap: string, create: DraftCreate, networkTried: boolean): Record | SetError & { refused: true } { + if (!isDraftCreate(ctx, jmap, create)) return { ...notADraft(), refused: true }; + const id = newStandInId(); + const now = ctx.now(); + const messageId = (create.messageId as string[] | undefined)?.[0] ?? `${id}@bulwark.invalid`; + const stored: DraftCreate = { ...create, messageId: [messageId] }; + const { size } = writeLocalRow(ctx.db, jmap, id, stored, messageId, now); + ctx.db.run( + `INSERT INTO draft_pending (local_id, jmap, seq, subject, create_json, message_id, replaces, status, first_attempt_at, last_attempt_at, size, created_at) + VALUES (?, ?, ?, ?, ?, ?, '[]', ?, ?, ?, ?, ?)`, + [id, jmap, nextSeq(ctx.db), typeof create.subject === 'string' ? create.subject : '', JSON.stringify(stored), messageId, networkTried ? 'uncertain' : 'waiting', networkTried ? now : null, networkTried ? now : null, size, now], + ); + return { id, blobId: id, threadId: id, size }; +} + +/** A flag or a move on a draft that only exists here: it goes into the create. */ +export function updateLocalDraft(ctx: Pick, jmap: string, id: string, rawPatch: Patch): SetError | null { + const row = rowOf(ctx.db, id); + if (!row) { + const real = mappedId(ctx.db, id); + return real ? queueUpdate(ctx.db, jmap, real, rawPatch, ctx.now()) : { type: 'notFound' }; + } + const problem = patchProblem(rawPatch); + if (problem) return problem; + const local = localEmail(ctx.db, row.jmap, id); + const create = JSON.parse(row.create_json) as DraftCreate; + const after = applyPatch(create, normalizePatch(rawPatch)); + if (Object.keys(after.mailboxIds ?? {}).length === 0) return { type: 'invalidProperties', description: 'A message has to stay in a folder' }; + ctx.db.run('UPDATE draft_pending SET create_json = ?, seq = ? WHERE local_id = ?', [JSON.stringify(after), nextSeq(ctx.db), id]); + if (local) upsertEmail(ctx.db, row.jmap, { ...local.email, keywords: after.keywords, mailboxIds: after.mailboxIds }, local.gen); + return null; +} + +/** What `id` is now on the server, for a stand-in that made it there. */ +function mappedId(db: StorageAdapter, id: string): string | null { + return db.get<{ server_id: string }>('SELECT server_id FROM draft_id_map WHERE local_id = ?', [id])?.server_id ?? null; +} + +export function resolveStandIns(db: StorageAdapter, ids: readonly string[]): Record { + return Object.fromEntries(ids.map((id) => [id, mappedId(db, id)])); +} + +function queueRemoval(db: StorageAdapter, target: ReplacedDraft, now: number): void { + const state = target.base ? JSON.stringify(target.base) : null; + savePatch(db, { jmap: target.jmap, record_id: target.id, op: 'destroy', patch: '{}', base: state, server: state, subject: target.subject }, now); +} + +/** + * Takes a pending row away: `into` carries on what it replaced, else the user + * discarded or sent the draft and what it replaced goes too. + */ +function dropPending(ctx: Pick, row: DraftRow, into: DraftRow | null): void { + const { db } = ctx; + const replaced = replacesOf(row); + const ghosts = [...ghostsOf(row), ...(row.first_attempt_at !== null ? [versionOf(row)] : [])]; + if (into) { + // Only once the new version is up: until then these may be the only copy on the server. + db.run('UPDATE draft_pending SET replaces = ?, ghosts = ? WHERE local_id = ?', [ + JSON.stringify([...replacesOf(into), ...replaced]), + JSON.stringify([...ghostsOf(into), ...ghosts].slice(-MOST_GHOSTS)), + into.local_id, + ]); + db.run('UPDATE draft_elsewhere SET local_id = ? WHERE local_id = ?', [into.local_id, row.local_id]); + } else { + for (const target of replaced) queueRemoval(db, target, ctx.now()); + for (const version of ghosts) { + db.run( + 'INSERT OR REPLACE INTO draft_ghost (jmap, message_id, first_at, last_at, subject, size) VALUES (?, ?, ?, ?, ?, ?)', + [row.jmap, version.messageId, version.from, version.to, version.subject, version.size], + ); + } + db.run('UPDATE draft_elsewhere SET local_id = NULL WHERE local_id = ?', [row.local_id]); + } + db.run('DELETE FROM draft_pending WHERE local_id = ?', [row.local_id]); + deleteEmail(db, row.jmap, row.local_id); +} + +/** + * Email/set destroy of a stand-in, or of any message when a new version on the + * device replaces it. False when it's an ordinary delete for the outbox to queue. + */ +export function destroyForDraft(ctx: Pick, jmap: string, id: string, replacedBy: string | undefined): boolean { + const { db } = ctx; + const into = replacedBy ? rowOf(db, replacedBy) ?? null : null; + if (isStandInId(id)) { + const row = rowOf(db, id); + if (row) { + dropPending(ctx, row, into); + return true; + } + const real = mappedId(db, id); + if (!real) return true; // Gone already: that's the same result. + if (into) return destroyForDraft(ctx, jmap, real, replacedBy); + queueDestroy(db, jmap, real, ctx.now()); + return true; + } + if (!into) return false; + // The version on the server stays there until the new one has arrived. + const local = localEmail(db, jmap, id); + const waiting = pendingRow(db, jmap, id); + const base = local ? stateOf(local.email) : waiting?.base ? JSON.parse(waiting.base) as RecordState : null; + const entry: ReplacedDraft = { jmap, id, base, subject: typeof local?.email.subject === 'string' ? local.email.subject : waiting?.subject ?? null }; + db.run('UPDATE draft_pending SET replaces = ? WHERE local_id = ?', [JSON.stringify([...replacesOf(into), entry]), into.local_id]); + db.run('DELETE FROM outbox_patch WHERE jmap = ? AND type = ? AND record_id = ?', [jmap, EMAIL_TYPE, id]); + deleteEmail(db, jmap, id); + return true; +} + +/** A plain draft: only in the Drafts folder, with the keyword. Anything else someone did something with. */ +export function isPlainDraft(db: StorageAdapter, jmap: string, email: EmailObject): boolean { + const boxes = Object.entries((email.mailboxIds ?? {}) as Record).filter(([, on]) => on).map(([box]) => box); + if (boxes.length !== 1 || !(email.keywords as Record | undefined)?.$draft) return false; + return listObjects<{ id: string; role?: string | null }>(db, jmap, 'Mailbox').some((box) => box.id === boxes[0] && box.role === 'drafts'); +} + +/** A send naming a draft that's still on the device: the send takes care of it. */ +function draftsWithQueuedSend(db: StorageAdapter): Set { + const rows = db.all<{ payload: string }>("SELECT payload FROM outbox WHERE status IN ('waiting', 'sending', 'uncertain', 'held') AND kind = 'email'"); + const out = new Set(); + for (const row of rows) { + const draft = (JSON.parse(row.payload) as { item: { args: unknown[] } }).item.args[7]; + if (typeof draft === 'string' && isStandInId(draft)) out.add(draft); + } + return out; +} + +export function allPending(db: StorageAdapter): DraftRow[] { + return db.all('SELECT * FROM draft_pending ORDER BY seq ASC'); +} + +/** The drafts that are only here and nothing else speaks for. One a send names is that message, in the outbox. */ +export function listedDrafts(db: StorageAdapter): DraftRow[] { + const held = draftsWithQueuedSend(db); + return allPending(db).filter((row) => !held.has(row.local_id)); +} + +export function pendingForFlush(db: StorageAdapter): DraftRow[] { + const held = draftsWithQueuedSend(db); + return allPending(db).filter((row) => row.status !== 'refused' && !held.has(row.local_id)); +} + +/** + * Sync must not bring back a version that a draft here replaces, and the folder + * counts stay the server's with what waits here added when they're read. + */ +export const draftsOverlay: RecordOverlay = (ctx, jmap, type, records) => { + if (type !== 'Email' && type !== 'Thread' && type !== 'Mailbox') return records; + const rows = ctx.db.all>('SELECT replaces FROM draft_pending'); + if (rows.length === 0) return records; + const replaced = rows.flatMap(replacesOf).filter((target) => target.jmap === jmap); + if (type === 'Email') { + const hidden = new Set(replaced.map((target) => target.id)); + return records.filter((record) => !hidden.has(record.id)); + } + if (type === 'Thread') { + const hidden = new Set(replaced.map((target) => target.id)); + return (records as Array<{ id: string; emailIds: string[] }>).map((thread) => ({ ...thread, emailIds: thread.emailIds.filter((id) => !hidden.has(id)) })); + } + const deltas = new Map(); + const bump = (box: string, total: number, unread: number) => { + const delta = deltas.get(box) ?? { total: 0, unread: 0 }; + deltas.set(box, { total: delta.total + total, unread: delta.unread + unread }); + }; + for (const row of allPending(ctx.db)) { + if (row.jmap !== jmap) continue; + const create = JSON.parse(row.create_json) as RecordState; + for (const box of Object.keys(create.mailboxIds ?? {})) bump(box, 1, create.keywords?.$seen ? 0 : 1); + } + for (const target of replaced) { + for (const box of Object.keys(target.base?.mailboxIds ?? {})) bump(box, -1, target.base?.keywords?.$seen ? 0 : -1); + } + if (deltas.size === 0) return records; + return (records as Array<{ id: string; totalEmails?: unknown; unreadEmails?: unknown }>).map((box) => { + const delta = deltas.get(box.id); + if (!delta) return box; + const count = (value: unknown, by: number) => (typeof value === 'number' ? Math.max(0, value + by) : value); + return { ...box, totalEmails: count(box.totalEmails, delta.total), unreadEmails: count(box.unreadEmails, delta.unread) }; + }); +}; + +/** After a create went through: the real message takes the stand-in's place. */ +export function replaceLocalRow(db: StorageAdapter, row: DraftRow, serverId: string, email: EmailObject | null): void { + const local = localEmail(db, row.jmap, row.local_id); + deleteEmail(db, row.jmap, row.local_id); + const gen = currentGen(db, row.jmap); + if (email) { + const { bodyValues, textBody, htmlBody, attachments, bodyStructure, headers, bcc, replyTo, messageId, inReplyTo, references, sender, ...list } = email; + const rowid = upsertEmail(db, row.jmap, list as EmailObject, gen); + storeBody(db, rowid, JSON.stringify(list), { bodyValues, textBody, htmlBody, attachments, bodyStructure, headers, bcc, replyTo, messageId, inReplyTo, references, sender }); + return; + } + // It's there, but reading it back failed. Sync brings the text when it can. + if (local) upsertEmail(db, row.jmap, { ...local.email, id: serverId, threadId: serverId, blobId: serverId }, gen); +} diff --git a/lib/store/drafts/schema.ts b/lib/store/drafts/schema.ts new file mode 100644 index 000000000..ce8218a42 --- /dev/null +++ b/lib/store/drafts/schema.ts @@ -0,0 +1,77 @@ +import type { Migration } from '../storage'; + +export const DRAFT_MIGRATIONS: readonly Migration[] = [ + { + id: 400, + name: 'drafts', + sql: ` + -- A draft saved on this device and not on the server yet. One row per + -- draft: a later save takes over from the row before it. + CREATE TABLE draft_pending ( + local_id TEXT PRIMARY KEY, + jmap TEXT NOT NULL, + seq INTEGER NOT NULL, + subject TEXT, + -- The create as the client wrote it, with its fixed Message-ID. + create_json TEXT NOT NULL, + message_id TEXT NOT NULL, + -- Versions on the server this one replaces: destroyed after it's there. + replaces TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'waiting', + sending_since INTEGER, + -- Set once it may have reached the server: then it's looked up first. + first_attempt_at INTEGER, + last_attempt_at INTEGER, + -- What this version should look like on the server. + size INTEGER NOT NULL DEFAULT 0, + -- Earlier versions that may have reached the server. Only removed once + -- this one is there, and only if they still look like those versions. + ghosts TEXT NOT NULL DEFAULT '[]', + error TEXT, + created_at INTEGER NOT NULL + ) WITHOUT ROWID; + -- What a stand-in id became. The page may hold one for a long time. + CREATE TABLE draft_id_map ( + local_id TEXT PRIMARY KEY, + jmap TEXT NOT NULL, + server_id TEXT NOT NULL, + at INTEGER NOT NULL + ) WITHOUT ROWID; + -- A version that may have reached the server, of a draft discarded here + -- since. Looked up and removed when the server is back. + CREATE TABLE draft_ghost ( + jmap TEXT NOT NULL, + message_id TEXT NOT NULL, + first_at INTEGER NOT NULL, + last_at INTEGER NOT NULL, + subject TEXT NOT NULL, + size INTEGER NOT NULL, + PRIMARY KEY (jmap, message_id) + ) WITHOUT ROWID; + -- An old version in another login: this store can't queue it. Removed + -- through that login once local_id is NULL: the new version is up, or the + -- draft was discarded or sent. + CREATE TABLE draft_elsewhere ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + local_id TEXT, + login TEXT NOT NULL, + jmap TEXT, + record_id TEXT NOT NULL + ); + `, + }, + { + id: 401, + name: 'held files in chunks', + sql: ` + ALTER TABLE upload ADD COLUMN complete INTEGER NOT NULL DEFAULT 1; + -- Binding a whole file grows the WASM heap by its size, and that never shrinks. + CREATE TABLE upload_chunk ( + local_id TEXT NOT NULL, + seq INTEGER NOT NULL, + data BLOB NOT NULL, + PRIMARY KEY (local_id, seq) + ); + `, + }, +]; diff --git a/lib/store/engine/account-engine.ts b/lib/store/engine/account-engine.ts index f4fe1220f..146c54f57 100644 --- a/lib/store/engine/account-engine.ts +++ b/lib/store/engine/account-engine.ts @@ -44,8 +44,8 @@ export class AccountEngine { private session: StoreSession; private scope: StoreScope; private readonly transport: JmapTransport; - private readonly areas: readonly StoreArea[]; private readonly syncers: readonly Syncer[]; + private readonly areas: readonly StoreArea[]; private readonly methods: ReadonlyMap; private readonly calls = new Map(); private readonly overlays: readonly RecordOverlay[]; @@ -67,9 +67,10 @@ export class AccountEngine { this.account = options.account; this.db = options.db; this.session = options.session; - this.areas = options.areas; this.transport = options.transport; - this.syncers = options.areas.flatMap((area) => area.syncers); + this.areas = options.areas; + const syncers = options.areas.flatMap((area) => area.syncers); + this.syncers = [...syncers.filter((syncer) => !syncer.low), ...syncers.filter((syncer) => syncer.low)]; this.methods = buildMethodTable(options.areas); this.overlays = options.areas.flatMap((area) => (area.overlay ? [area.overlay] : [])); for (const area of options.areas) { @@ -90,6 +91,12 @@ export class AccountEngine { // An offline start has no way to probe the server, so the last answer stays. this.invertedKeywordSort = options.invertedKeywordSort ?? metaGet(this.db, 'inverted_keyword_sort') === '1'; if (options.invertedKeywordSort !== undefined) metaSet(this.db, 'inverted_keyword_sort', options.invertedKeywordSort ? '1' : '0'); + this.runScopeHooks(); + } + + private runScopeHooks(): void { + const ctx = this.storeContext(); + for (const area of this.areas) area.scopeChanged?.(ctx); } /** The session kept from the last time, for a start without a network. */ @@ -113,6 +120,7 @@ export class AccountEngine { // The user may just have made room by keeping less. this.storageBlockedUntil = 0; metaSet(this.db, SCOPE_KEY, json); + this.runScopeHooks(); return true; } @@ -295,20 +303,27 @@ export class AccountEngine { } private syncContext(): SyncContext { + const scope = () => this.scope; return { account: this.account, db: this.db, session: this.session, - scope: this.scope, + // A pass can take long, and a tighter policy applies to the rest of it. + get scope() { + return scope(); + }, transport: this.transport, now: this.now, emit: this.emitter, stopped: () => this.closed || !this.online || this.scopeChanged, requestSync: () => this.requestSync(), + syncWanted: () => this.again, + pause: this.sleep, syncState: (jmap, type) => this.syncState(jmap, type), setSyncState: (jmap, type, row) => this.setSyncState(jmap, type, row), overlay: (jmap, type, records) => this.overlay(jmap, type, records), keptByOthers: (area) => this.keptByOthers(area), + sparedRecords: (jmap, type) => this.areas.flatMap((area) => area.spares?.({ db: this.db }, jmap, type) ?? []), }; } @@ -322,6 +337,7 @@ export class AccountEngine { emit: this.emitter, overlay: (jmap, type, records) => this.overlay(jmap, type, records), keptByOthers: (area) => this.keptByOthers(area), + sparedRecords: (jmap, type) => this.areas.flatMap((area) => area.spares?.({ db: this.db }, jmap, type) ?? []), requestSync: () => this.requestSync(), }; } @@ -361,6 +377,8 @@ export class AccountEngine { /** Stops syncing and waits for the current batch. The database stays open for the caller to close. */ async close(): Promise { this.closed = true; + // A large download could otherwise hold up removing the account. + this.transport.abort?.(); await this.running?.catch(() => {}); } } diff --git a/lib/store/engine/responder.ts b/lib/store/engine/responder.ts index be03dc5db..c01b2d932 100644 --- a/lib/store/engine/responder.ts +++ b/lib/store/engine/responder.ts @@ -56,12 +56,12 @@ export function buildMethodTable(areas: readonly StoreArea[]): Map, + ctx: Omit, methods: ReadonlyMap, calls: readonly MethodCall[], ): Promise { const done: MethodResponse[] = []; - const full: MethodContext = { ...ctx, createdIds: new Map() }; + const full: MethodContext = { ...ctx, createdIds: new Map(), calls }; let answered = 0; for (const call of calls) { const [name, rawArgs, callId] = call; diff --git a/lib/store/engine/transport.ts b/lib/store/engine/transport.ts index cc55ff300..ec2a6288c 100644 --- a/lib/store/engine/transport.ts +++ b/lib/store/engine/transport.ts @@ -34,6 +34,8 @@ export class TransportError extends Error { /** The `type` of an RFC 7807 problem body, when there was one. */ readonly problem?: string, readonly retryAfterMs?: number, + /** The connection worked and this transfer broke or stalled: it says little about the next one. */ + readonly interrupted?: boolean, ) { super(message); this.name = 'TransportError'; @@ -43,7 +45,14 @@ export class TransportError extends Error { export interface JmapTransport { request(calls: MethodCall[], using: string[]): Promise; download(jmap: string, blobId: string, type: string, name: string): Promise; + /** + * The same, piece by piece, so a large file isn't held in memory. An error + * thrown by `sink` ends the download and comes out as it is. + */ + downloadStream?(jmap: string, blobId: string, type: string, name: string, sink: (part: Uint8Array) => void): Promise; upload(jmap: string, data: Uint8Array, type: string): Promise<{ blobId: string; type: string; size: number }>; + /** Ends what's running, now and for good. The engine calls this when it closes. */ + abort?(): void; } export interface FetchTransportOptions { @@ -56,23 +65,72 @@ export interface FetchTransportOptions { } const REQUEST_TIMEOUT_MS = 120_000; +/** A download may take as long as it takes, as long as bytes keep coming. */ +const DOWNLOAD_IDLE_MS = 60_000; function fillTemplate(template: string, values: Record): string { return template.replace(/\{(\w+)\}/g, (whole, key: string) => (key in values ? encodeURIComponent(values[key]) : whole)); } +/** One transfer's way to be stopped: by its time limit, or by the transport closing. */ +interface Transfer { + signal: AbortSignal; + /** Bytes came: for a download, the time limit starts over. */ + alive(): void; + /** Whether the time limit ended it. */ + timedOut(): boolean; + done(): void; +} + +function messageOf(error: unknown, fallback: string): string { + return error instanceof Error && error.message ? error.message : fallback; +} + export class FetchTransport implements JmapTransport { + private readonly running = new Set(); + private aborted = false; + constructor(private readonly options: FetchTransportOptions) {} - private async fetchWithAuth(url: string, init: RequestInit): Promise { + abort(): void { + this.aborted = true; + for (const controller of this.running) controller.abort(); + } + + private transfer(ms: number, idle: boolean): Transfer { + const controller = new AbortController(); + this.running.add(controller); + let timedOut = false; + const arm = () => setTimeout(() => { + timedOut = true; + controller.abort(); + }, ms); + let timer = arm(); + if (this.aborted) controller.abort(); + return { + signal: controller.signal, + alive: () => { + if (!idle) return; + clearTimeout(timer); + timer = arm(); + }, + timedOut: () => timedOut, + done: () => { + clearTimeout(timer); + this.running.delete(controller); + }, + }; + } + + private async fetchWithAuth(url: string, init: RequestInit, transfer: Transfer): Promise { const doFetch = this.options.fetch ?? fetch; const send = async (header: string | null): Promise => { if (!header) throw new TransportError('auth', 'No credentials yet'); - const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + if (this.aborted) throw new TransportError('network', 'The store is closing'); try { - return await doFetch(url, { ...init, signal, headers: { ...init.headers, Authorization: header } }); + return await doFetch(url, { ...init, signal: transfer.signal, headers: { ...init.headers, Authorization: header } }); } catch (error) { - throw new TransportError('network', error instanceof Error ? error.message : 'Network failure'); + throw new TransportError('network', messageOf(error, 'Network failure'), undefined, undefined, undefined, transfer.timedOut()); } }; let response = await send(this.options.auth()); @@ -87,40 +145,97 @@ export class FetchTransport implements JmapTransport { return response; } + /** A JSON body. A connection dropping halfway is a network failure, not a bad answer. */ + private async readJson(response: Response): Promise { + try { + return await response.json() as T; + } catch (error) { + if (error instanceof SyntaxError) throw new TransportError('http', 'The server sent something that is not JSON', response.status); + throw new TransportError('network', messageOf(error, 'The response broke off'), undefined, undefined, undefined, true); + } + } + async request(calls: MethodCall[], using: string[]): Promise { - const response = await this.fetchWithAuth(this.options.session().apiUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ using, methodCalls: calls }), - }); - if (!response.ok) { - let problem: string | undefined; + const transfer = this.transfer(REQUEST_TIMEOUT_MS, false); + try { + const response = await this.fetchWithAuth(this.options.session().apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ using, methodCalls: calls }), + }, transfer); + if (!response.ok) { + let problem: string | undefined; + try { + problem = (await response.json() as { type?: string }).type; + } catch { + // Not a problem document. + } + throw new TransportError('http', `JMAP request failed: ${response.status}`, response.status, problem); + } + return await this.readJson(response); + } finally { + transfer.done(); + } + } + + async downloadStream(jmap: string, blobId: string, type: string, name: string, sink: (part: Uint8Array) => void): Promise { + const url = fillTemplate(this.options.session().downloadUrl, { accountId: jmap, blobId, type, name }); + const transfer = this.transfer(DOWNLOAD_IDLE_MS, true); + try { + const response = await this.fetchWithAuth(url, { method: 'GET' }, transfer); + if (!response.ok) throw new TransportError('http', `Download failed: ${response.status}`, response.status); + if (!response.body) return; + const reader = response.body.getReader(); try { - problem = (await response.json() as { type?: string }).type; - } catch { - // Not a problem document. + for (;;) { + let read: Awaited>; + try { + read = await reader.read(); + } catch (error) { + throw new TransportError('network', messageOf(error, 'The download broke off'), undefined, undefined, undefined, !this.aborted); + } + if (read.done) return; + transfer.alive(); + sink(read.value); + } + } finally { + void reader.cancel().catch(() => {}); } - throw new TransportError('http', `JMAP request failed: ${response.status}`, response.status, problem); + } finally { + transfer.done(); } - return await response.json() as JmapResponse; } async download(jmap: string, blobId: string, type: string, name: string): Promise { - const url = fillTemplate(this.options.session().downloadUrl, { accountId: jmap, blobId, type, name }); - const response = await this.fetchWithAuth(url, { method: 'GET' }); - if (!response.ok) throw new TransportError('http', `Download failed: ${response.status}`, response.status); - return new Uint8Array(await response.arrayBuffer()); + const parts: Uint8Array[] = []; + let size = 0; + await this.downloadStream(jmap, blobId, type, name, (part) => { + parts.push(part); + size += part.length; + }); + const data = new Uint8Array(size); + let at = 0; + for (const part of parts) { + data.set(part, at); + at += part.length; + } + return data; } async upload(jmap: string, data: Uint8Array, type: string): Promise<{ blobId: string; type: string; size: number }> { const url = fillTemplate(this.options.session().uploadUrl, { accountId: jmap }); - const response = await this.fetchWithAuth(url, { - method: 'POST', - headers: { 'Content-Type': type || 'application/octet-stream' }, - body: data as BodyInit, - }); - if (!response.ok) throw new TransportError('http', `Upload failed: ${response.status}`, response.status); - return await response.json() as { blobId: string; type: string; size: number }; + const transfer = this.transfer(REQUEST_TIMEOUT_MS, false); + try { + const response = await this.fetchWithAuth(url, { + method: 'POST', + headers: { 'Content-Type': type || 'application/octet-stream' }, + body: data as BodyInit, + }, transfer); + if (!response.ok) throw new TransportError('http', `Upload failed: ${response.status}`, response.status); + return await this.readJson<{ blobId: string; type: string; size: number }>(response); + } finally { + transfer.done(); + } } } diff --git a/lib/store/mail/attachments.ts b/lib/store/mail/attachments.ts new file mode 100644 index 000000000..12feba84a --- /dev/null +++ b/lib/store/mail/attachments.ts @@ -0,0 +1,333 @@ +/** + * The files of kept mail: attachments, inline images (so a message offline shows + * them) and the raw message an export reads (decision in + * local-data/desktop-offline-decisions.md). Fetched last in a pass, newest mail + * first, one at a time and paced, and a new pass that's asked for goes first. + * + * What's kept is bounded by the scope: nothing with `attachments` off, nothing + * over `maxAttachmentBytes`, and `maxTotalBytes` in all. At the total a newer + * message's file pushes out raw messages first, then files of older messages, + * oldest first. Never the files of a pinned message. Raw messages come after + * every file and only take room that's free. Files held for a draft or a send + * are in `upload`, pinned files in the files area, and neither is touched here. + * + * A file comes in as a stream where the transport can, written in 1 MB chunks + * that each commit on their own, and is held nowhere whole. Its row in `blob` + * comes last, so a broken download leaves nothing readable. + */ + +import type { StoreContext, SyncContext, Syncer } from '../area'; +import { TransportError } from '../engine/transport'; +import type { StorageAdapter } from '../storage'; +import { BLOB_CHUNK_BYTES, dropBlob, putBlob } from './records'; + +/** Keeps well under the server's 1000 requests a minute, which the user's own clicks count against too. */ +const PACE_MS = 200; +/** There's one raw message per message, and nothing waits for them. */ +const RAW_PACE_MS = 1000; +const PAGE = 20; +/** + * Parts in a row that don't fit before a pass stops looking. Each check reads the + * kept list, and older mail only has less to push out. + */ +const MOST_PASSED_IN_A_ROW = 50; +const PINNED = '$pinned'; +/** What says a file won't come. Anything else, like a 500, is tried at the next sync. */ +const PERMANENT_DOWNLOAD_STATUSES = new Set([403, 404, 410]); +const RAW_TYPE = 'message/rfc822'; + +class DownloadStopped extends Error {} + +class DownloadTooLarge extends Error {} + +/** Someone else kept the same file while this one was coming in. */ +class AlreadyKept extends Error {} + +interface Part { + blob_id: string; + size: number; + type: string | null; + name: string | null; + received_at: number; + raw: number; +} + +type KeptContext = Pick & Partial>; + +/** The total is for everything an account keeps, so what the Files area holds takes its part. */ +function mailCap(ctx: KeptContext): number { + return Math.max(0, ctx.scope.maxTotalBytes - (ctx.keptByOthers?.('mail') ?? 0)); +} + +function usedBytes(db: StorageAdapter): number { + return Number(db.get<{ n: number | null }>('SELECT SUM(size) AS n FROM blob')?.n ?? 0); +} + +const NOT_PINNED = `NOT EXISTS ( + SELECT 1 FROM email_part p JOIN email_keyword k ON k.email_rowid = p.email_rowid + WHERE p.jmap = b.jmap AND p.blob_id = b.blob_id AND k.keyword = '${PINNED}' +)`; + +const IS_RAW = `EXISTS (SELECT 1 FROM email_part r WHERE r.jmap = b.jmap AND r.blob_id = b.blob_id AND r.raw = 1)`; + +/** + * Whether `size` more can fit, counting what could be pushed out for mail + * received at `at`. A raw message pushes out nothing: it only takes what's free. + */ +function couldFit(db: StorageAdapter, cap: number, size: number, at: number, raw: boolean): boolean { + const used = usedBytes(db); + if (used + size <= cap) return true; + if (raw) return false; + const yielding = Number(db.get<{ n: number | null }>(`SELECT SUM(b.size) AS n FROM blob b WHERE (${IS_RAW} OR b.at < ?) AND ${NOT_PINNED}`, [at])?.n ?? 0); + return used - yielding + size <= cap; +} + +/** Pushes out raw messages, then files of mail older than `at`, oldest first, until `size` more fits or nothing is left to push out. */ +function pushOut(db: StorageAdapter, cap: number, size: number, at: number): void { + let used = usedBytes(db); + while (used + size > cap) { + const oldest = db.all<{ jmap: string; blob_id: string; size: number }>(`SELECT b.jmap, b.blob_id, b.size FROM blob b WHERE (${IS_RAW} OR b.at < ?) AND ${NOT_PINNED} ORDER BY ${IS_RAW} DESC, b.at ASC LIMIT 50`, [at]); + if (oldest.length === 0) return; + for (const row of oldest) { + if (used + size <= cap) break; + dropBlob(db, row.jmap, row.blob_id); + used -= Number(row.size); + } + } +} + +/** Makes room for `size` more. False, with nothing pushed out, when it can't. */ +function makeRoom(db: StorageAdapter, cap: number, size: number, at: number, raw: boolean): boolean { + if (!couldFit(db, cap, size, at, raw)) return false; + pushOut(db, cap, size, at); + return true; +} + +/** A tighter scope applies at once, not at the next sync. */ +export function applyAttachmentScope(ctx: KeptContext): void { + const { db, scope } = ctx; + db.transaction(() => { + if (!scope.attachments) { + db.run('DELETE FROM blob_chunk'); + db.run('DELETE FROM blob'); + return; + } + for (const row of db.all<{ jmap: string; blob_id: string }>('SELECT jmap, blob_id FROM blob WHERE size > ?', [scope.maxAttachmentBytes])) { + dropBlob(db, row.jmap, row.blob_id); + } + pushOut(db, mailCap(ctx), 0, Number.MAX_SAFE_INTEGER); + }); +} + +/** A kept file, piece by piece into a Blob, which the browser can keep out of this worker's memory. */ +export function keptBlob(ctx: Pick, jmap: string, blobId: string): { type: string | null; size: number; blob: Blob } | null { + if (!ctx.scope.attachments) return null; + const row = ctx.db.get<{ type: string | null; size: number; data: Uint8Array }>('SELECT type, size, data FROM blob WHERE jmap = ? AND blob_id = ?', [jmap, blobId]); + if (!row) return null; + const type = row.type ?? ''; + // A file from before chunks has its bytes in the row; a new one has none there. + let blob = new Blob(row.data?.byteLength ? [row.data as BlobPart] : [], { type }); + for (let seq = 0; ; seq += 1) { + const chunk = ctx.db.get<{ data: Uint8Array }>('SELECT data FROM blob_chunk WHERE jmap = ? AND blob_id = ? AND seq = ?', [jmap, blobId, seq]); + if (!chunk) break; + // Each step only refers to the last Blob, so one chunk at a time is in memory here. + blob = new Blob([blob, chunk.data as BlobPart], { type }); + } + // Better not there, and fetched again, than a file that reads as empty or cut short. + if (blob.size !== Number(row.size)) { + ctx.db.transaction(() => dropBlob(ctx.db, jmap, blobId)); + return null; + } + return { type: row.type, size: Number(row.size), blob }; +} + +/** A file the user opened online: kept if it belongs to a kept message and the scope allows it. */ +export function recordKeptBlob(ctx: KeptContext, jmap: string, blobId: string, type: string | null, data: Uint8Array): boolean { + const { db, scope } = ctx; + const owner = db.get<{ received_at: number; raw: number }>('SELECT received_at, raw FROM email_part WHERE jmap = ? AND blob_id = ?', [jmap, blobId]); + if (!owner || !scope.attachments || data.byteLength > scope.maxAttachmentBytes) return false; + const at = Number(owner.received_at); + return db.transaction(() => { + if (!makeRoom(db, mailCap(ctx), data.byteLength, at, Boolean(owner.raw))) return false; + putBlob(db, jmap, blobId, type, data, at); + return true; + }); +} + +export type RawRefusal = 'attachments_off' | 'not_kept'; + +/** + * A whole message as the server has it, by `Email.blobId`, for an offline + * export. It stays a refusal rather than something rebuilt: a rebuilt message + * would lose its signatures and exact bytes. + */ +export function rawBlob(ctx: Pick, jmap: string, blobId: string): { ok: true; type: string; size: number; blob: Blob } | { ok: false; reason: RawRefusal } { + if (!ctx.scope.attachments) return { ok: false, reason: 'attachments_off' }; + const isRaw = ctx.db.get('SELECT 1 AS x FROM email_part WHERE jmap = ? AND blob_id = ? AND raw = 1', [jmap, blobId]); + const kept = isRaw ? keptBlob(ctx, jmap, blobId) : null; + if (!kept) return { ok: false, reason: 'not_kept' }; + return { ok: true, type: RAW_TYPE, size: kept.size, blob: new Blob([kept.blob], { type: RAW_TYPE }) }; +} + +/** 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 = ?' : ''; + const params = mailboxId ? [mailboxId, jmap] : [jmap]; + const total = Number(ctx.db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM email e ${inFolder} WHERE e.jmap = ?`, params)?.n ?? 0); + if (!ctx.scope.attachments) return { total, kept: 0 }; + const kept = Number(ctx.db.get<{ n: number }>( + `SELECT COUNT(DISTINCT e.rowid) AS n FROM email e ${inFolder} + JOIN email_part p ON p.email_rowid = e.rowid AND p.raw = 1 + JOIN blob b ON b.jmap = p.jmap AND b.blob_id = p.blob_id + WHERE e.jmap = ?`, + params, + )?.n ?? 0); + return { total, kept }; +} + +function isKept(db: StorageAdapter, jmap: string, blobId: string): boolean { + return Boolean(db.get('SELECT 1 AS x FROM blob WHERE jmap = ? AND blob_id = ?', [jmap, blobId])); +} + +/** What a download left. Never the pieces of a file that's kept: its row says they're whole. */ +function clearUnfinished(db: StorageAdapter, jmap: string, blobId: string): void { + if (!isKept(db, jmap, blobId)) db.run('DELETE FROM blob_chunk WHERE jmap = ? AND blob_id = ?', [jmap, blobId]); +} + +/** Takes a body piece by piece and writes it in chunks, each committed on its own. */ +function chunkWriter(ctx: SyncContext, jmap: string, blobId: string) { + const buffer = new Uint8Array(BLOB_CHUNK_BYTES); + let filled = 0; + let seq = 0; + let total = 0; + const write = () => { + if (filled === 0) return; + if (isKept(ctx.db, jmap, blobId)) throw new AlreadyKept(); + ctx.db.run('INSERT INTO blob_chunk (jmap, blob_id, seq, data) VALUES (?, ?, ?, ?)', [jmap, blobId, seq, buffer.subarray(0, filled)]); + seq += 1; + filled = 0; + }; + return { + push(part: Uint8Array): void { + total += part.length; + if (total > ctx.scope.maxAttachmentBytes) throw new DownloadTooLarge(); + for (let at = 0; at < part.length;) { + const take = Math.min(part.length - at, BLOB_CHUNK_BYTES - filled); + buffer.set(part.subarray(at, at + take), filled); + filled += take; + at += take; + if (filled === BLOB_CHUNK_BYTES) { + if (ctx.stopped() || !ctx.scope.attachments) throw new DownloadStopped(); + write(); + } + } + }, + /** The last piece goes in the transaction that files it, so a small file costs one commit. */ + writeLast: write, + get total(): number { + return total; + }, + }; +} + +function refuse(db: StorageAdapter, jmap: string, blobId: string): void { + db.run('UPDATE email_part SET refused = 1 WHERE jmap = ? AND blob_id = ?', [jmap, blobId]); +} + +type Fetched = 'kept' | 'skipped' | 'stopped'; + +async function fetchOne(ctx: SyncContext, jmap: string, part: Part): Promise { + const { db, transport } = ctx; + const blobId = String(part.blob_id); + const type = part.type ?? 'application/octet-stream'; + const name = part.name ?? 'attachment'; + if (isKept(db, jmap, blobId)) return 'kept'; + clearUnfinished(db, jmap, blobId); + const writer = chunkWriter(ctx, jmap, blobId); + try { + if (transport.downloadStream) await transport.downloadStream(jmap, blobId, type, name, (piece) => writer.push(piece)); + else writer.push(await transport.download(jmap, blobId, type, name)); + } catch (error) { + clearUnfinished(db, jmap, blobId); + if (error instanceof AlreadyKept) return 'kept'; + if (error instanceof DownloadStopped) return 'stopped'; + if (error instanceof DownloadTooLarge) { + refuse(db, jmap, blobId); + return 'skipped'; + } + if (error instanceof TransportError && error.kind === 'http') { + if (!PERMANENT_DOWNLOAD_STATUSES.has(error.status ?? 0)) return 'stopped'; + refuse(db, jmap, blobId); + return 'skipped'; + } + // The connection works and this transfer broke: the next one may be fine. + if (error instanceof TransportError && error.interrupted) return 'skipped'; + throw error; + } + const at = Number(part.received_at); + const size = writer.total; + return db.transaction((): Fetched => { + // The user opened it meanwhile, and that copy is whole. + if (isKept(db, jmap, blobId)) return 'kept'; + // Its message may have gone while this was on its way, or the rules changed. + const wanted = ctx.scope.attachments && db.get('SELECT 1 AS x FROM email_part WHERE jmap = ? AND blob_id = ?', [jmap, blobId]); + if (!wanted || !makeRoom(db, mailCap(ctx), size, at, Boolean(part.raw))) { + clearUnfinished(db, jmap, blobId); + return 'skipped'; + } + writer.writeLast(); + db.run( + `INSERT INTO blob (jmap, blob_id, type, size, at, data) VALUES (?, ?, ?, ?, ?, x'') + ON CONFLICT (jmap, blob_id) DO UPDATE SET type = excluded.type, size = excluded.size, at = excluded.at, data = excluded.data`, + [jmap, blobId, part.type, size, at], + ); + return 'kept'; + }); +} + +async function fetchKept(ctx: SyncContext, jmap: string): Promise { + const { db } = ctx; + // What a closed app or a stopped pass left unfinished. + db.run('DELETE FROM blob_chunk WHERE jmap = ? AND NOT EXISTS (SELECT 1 FROM blob b WHERE b.jmap = blob_chunk.jmap AND b.blob_id = blob_chunk.blob_id)', [jmap]); + applyAttachmentScope(ctx); + if (!ctx.scope.attachments) return; + // Rows passed over in this pass stay in the list, so the next page starts past them. + let passed = 0; + let passedInARow = 0; + let done = 0; + for (;;) { + const parts = db.all( + `SELECT p.blob_id, p.size, p.type, p.name, p.received_at, p.raw FROM email_part p + WHERE p.jmap = ? AND p.refused = 0 AND p.size <= ? + AND NOT EXISTS (SELECT 1 FROM blob b WHERE b.jmap = p.jmap AND b.blob_id = p.blob_id) + ORDER BY p.raw ASC, p.received_at DESC, p.blob_id ASC LIMIT ? OFFSET ?`, + [jmap, ctx.scope.maxAttachmentBytes, PAGE, passed], + ); + if (parts.length === 0) return; + for (const part of parts) { + if (ctx.stopped() || ctx.syncWanted()) return; + // The same file a second time in this page: kept by now. + if (db.get('SELECT 1 AS x FROM blob WHERE jmap = ? AND blob_id = ?', [jmap, String(part.blob_id)])) continue; + if (!couldFit(db, mailCap(ctx), Number(part.size), Number(part.received_at), Boolean(part.raw))) { + passed += 1; + passedInARow += 1; + if (passedInARow >= MOST_PASSED_IN_A_ROW) return; + continue; + } + passedInARow = 0; + const outcome = await fetchOne(ctx, jmap, part); + if (outcome === 'stopped') return; + if (outcome === 'skipped') passed += 1; + done += 1; + ctx.emit({ type: 'progress', account: ctx.account, area: 'mail', phase: 'attachments', done, total: null }); + await ctx.pause(part.raw ? RAW_PACE_MS : PACE_MS); + } + } +} + +export const attachmentSyncer: Syncer = { + name: 'attachments', + capability: 'urn:ietf:params:jmap:mail', + low: true, + run: fetchKept, +}; diff --git a/lib/store/mail/calls.ts b/lib/store/mail/calls.ts index bec374a14..b8202c86e 100644 --- a/lib/store/mail/calls.ts +++ b/lib/store/mail/calls.ts @@ -1,7 +1,8 @@ /** What the page asks the mail part of the store outside JMAP. */ import type { AreaCall, StoreContext } from '../area'; -import { putBlob, storeBody, upsertEmail, type EmailObject } from './records'; +import { keptBlob, rawBlob, rawStatus, recordKeptBlob } from './attachments'; +import { storeBody, upsertEmail, type EmailObject } from './records'; /** * A message the user opened online: kept with its text if the store doesn't @@ -34,18 +35,11 @@ function recordOpened(ctx: StoreContext, params: { jmap: string; emails: EmailOb /** A file of a kept message the user opened. Nothing else is kept: not an export, not a stray file. */ function recordBlob(ctx: StoreContext, params: { jmap: string; blobId: string; type: string | null; data: Uint8Array }): boolean { - if (!ctx.scope.attachments || params.data.byteLength > ctx.scope.maxAttachmentBytes) return false; - const used = Number(ctx.db.get<{ n: number | null }>('SELECT SUM(size) AS n FROM blob')?.n ?? 0); - if (used + ctx.keptByOthers('mail') + params.data.byteLength > ctx.scope.maxTotalBytes) return false; - const owner = ctx.db.get<{ received_at: number }>('SELECT received_at FROM email_part WHERE jmap = ? AND blob_id = ?', [params.jmap, params.blobId]); - if (!owner) return false; - putBlob(ctx.db, params.jmap, params.blobId, params.type, params.data, Number(owner.received_at)); - return true; + return recordKeptBlob(ctx, params.jmap, params.blobId, params.type, params.data); } -function getBlob(ctx: StoreContext, params: { jmap: string; blobId: string }): { type: string | null; data: Uint8Array } | null { - const row = ctx.db.get<{ type: string | null; data: Uint8Array }>('SELECT type, data FROM blob WHERE jmap = ? AND blob_id = ?', [params.jmap, params.blobId]); - return row ? { type: row.type, data: row.data } : null; +function getBlob(ctx: StoreContext, params: { jmap: string; blobId: string }): { type: string | null; size: number; blob: Blob } | null { + return keptBlob(ctx, params.jmap, params.blobId); } /** How much of the disk this account's file takes. */ @@ -59,6 +53,10 @@ export const MAIL_CALLS: Readonly> = { 'mail.recordOpened': recordOpened, 'mail.recordBlob': recordBlob, '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), + /** 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), '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/mail/index.ts b/lib/store/mail/index.ts index 452a199fc..bcec48da7 100644 --- a/lib/store/mail/index.ts +++ b/lib/store/mail/index.ts @@ -1,4 +1,5 @@ import type { StoreArea } from '../area'; +import { applyAttachmentScope, attachmentSyncer } from './attachments'; import { MAIL_CALLS } from './calls'; import { MAIL_METHODS } from './methods'; import { MAIL_MIGRATIONS } from './schema'; @@ -10,9 +11,10 @@ export const mailArea: StoreArea = { name: 'mail', migrations: [...MAIL_MIGRATIONS, ...SEARCH_MIGRATIONS], methods: MAIL_METHODS, - syncers: [mailSyncer, identitySyncer, quotaSyncer], + syncers: [mailSyncer, identitySyncer, quotaSyncer, attachmentSyncer], calls: MAIL_CALLS, applyScope: applyMailScope, usage: mailUsage, keptBytes: (ctx) => Number(ctx.db.get<{ n: number | null }>('SELECT SUM(size) AS n FROM blob')?.n ?? 0), + scopeChanged: applyAttachmentScope, }; diff --git a/lib/store/mail/records.ts b/lib/store/mail/records.ts index e9d575c44..d8afda291 100644 --- a/lib/store/mail/records.ts +++ b/lib/store/mail/records.ts @@ -97,12 +97,21 @@ export function upsertEmail(db: StorageAdapter, jmap: string, email: EmailObject return rowid; } +/** Kept files are stored in pieces of this size. */ +export const BLOB_CHUNK_BYTES = 1024 * 1024; + +export function dropBlob(db: StorageAdapter, jmap: string, blobId: string): void { + db.run('DELETE FROM blob_chunk WHERE jmap = ? AND blob_id = ?', [jmap, blobId]); + db.run('DELETE FROM blob WHERE jmap = ? AND blob_id = ?', [jmap, blobId]); +} + +/** A file that's here as a whole already. */ export function putBlob(db: StorageAdapter, jmap: string, blobId: string, type: string | null, data: Uint8Array, at: number): void { - db.run( - `INSERT INTO blob (jmap, blob_id, type, size, at, data) VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT (jmap, blob_id) DO UPDATE SET type = excluded.type, size = excluded.size, at = excluded.at, data = excluded.data`, - [jmap, blobId, type, data.byteLength, at, data], - ); + dropBlob(db, jmap, blobId); + for (let from = 0, seq = 0; from < data.byteLength; from += BLOB_CHUNK_BYTES, seq += 1) { + db.run('INSERT INTO blob_chunk (jmap, blob_id, seq, data) VALUES (?, ?, ?, ?)', [jmap, blobId, seq, data.subarray(from, from + BLOB_CHUNK_BYTES)]); + } + db.run("INSERT INTO blob (jmap, blob_id, type, size, at, data) VALUES (?, ?, ?, ?, ?, x'')", [jmap, blobId, type, data.byteLength, at]); } export function deleteEmail(db: StorageAdapter, jmap: string, id: string): void { @@ -237,24 +246,48 @@ function forgetParts(db: StorageAdapter, rowid: number): void { const parts = db.all<{ jmap: string; blob_id: string }>('SELECT jmap, blob_id FROM email_part WHERE email_rowid = ?', [rowid]); db.run('DELETE FROM email_part WHERE email_rowid = ?', [rowid]); for (const part of parts) { - db.run( - 'DELETE FROM blob WHERE jmap = ? AND blob_id = ? AND NOT EXISTS (SELECT 1 FROM email_part p WHERE p.jmap = ? AND p.blob_id = ?)', - [part.jmap, part.blob_id, part.jmap, part.blob_id], - ); + if (db.get('SELECT 1 AS x FROM email_part WHERE jmap = ? AND blob_id = ?', [part.jmap, part.blob_id])) continue; + dropBlob(db, part.jmap, part.blob_id); } } -function rememberParts(db: StorageAdapter, rowid: number, attachments: unknown): void { +interface MimePart { + blobId?: unknown; + size?: unknown; + type?: unknown; + name?: unknown; + disposition?: unknown; + subParts?: MimePart[]; +} + +function leafParts(part: MimePart): MimePart[] { + return part.subParts ? part.subParts.flatMap(leafParts) : [part]; +} + +/** + * The parts a message needs as files: its attachments, and whatever else the + * body shows. Stalwart 1.0 doesn't list inline images under `attachments`, so + * those come from the body lists and the MIME tree. Text parts are in bodyValues. + */ +function fileParts(body: Record): MimePart[] { + const listed = (key: string) => (Array.isArray(body[key]) ? body[key] as MimePart[] : []); + const shown = [...listed('textBody'), ...listed('htmlBody'), ...(body.bodyStructure ? leafParts(body.bodyStructure as MimePart) : [])] + .filter((part) => !(typeof part.type === 'string' && part.type.startsWith('text/')) || part.disposition === 'attachment'); + return [...listed('attachments'), ...shown]; +} + +function rememberParts(db: StorageAdapter, rowid: number, list: Record, body: Record): void { const row = db.get<{ jmap: string; received_at: number }>('SELECT jmap, received_at FROM email WHERE rowid = ?', [rowid]); - if (!row || !Array.isArray(attachments)) return; - for (const part of attachments as Array<{ blobId?: unknown; size?: unknown; type?: unknown; name?: unknown }>) { - if (typeof part.blobId !== 'string') continue; - db.run( - `INSERT INTO email_part (jmap, blob_id, email_rowid, received_at, size, type, name) VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (jmap, blob_id, email_rowid) DO NOTHING`, - [row.jmap, part.blobId, rowid, row.received_at, typeof part.size === 'number' ? part.size : 0, typeof part.type === 'string' ? part.type : null, typeof part.name === 'string' ? part.name : null], - ); + if (!row) return; + const remember = (blobId: string, size: unknown, type: unknown, name: unknown, raw: number) => db.run( + `INSERT INTO email_part (jmap, blob_id, email_rowid, received_at, size, type, name, raw) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (jmap, blob_id, email_rowid) DO NOTHING`, + [row.jmap, blobId, rowid, row.received_at, typeof size === 'number' ? size : 0, typeof type === 'string' ? type : null, typeof name === 'string' ? name : null, raw], + ); + for (const part of fileParts(body)) { + if (typeof part.blobId === 'string') remember(part.blobId, part.size, part.type, part.name, 0); } + if (typeof list.blobId === 'string') remember(list.blobId, list.size, 'message/rfc822', 'message.eml', 1); } export function storeBody(db: StorageAdapter, rowid: number, listJson: string, full: Record): void { @@ -262,7 +295,7 @@ export function storeBody(db: StorageAdapter, rowid: number, listJson: string, f const body: Record = {}; for (const [key, value] of Object.entries(full)) if (!(key in list)) body[key] = value; const hadBody = Number(db.get<{ body_state: number }>('SELECT body_state FROM email WHERE rowid = ?', [rowid])?.body_state) === 1; - rememberParts(db, rowid, body.attachments); + rememberParts(db, rowid, list, body); db.run('INSERT INTO email_body (email_rowid, json) VALUES (?, ?) ON CONFLICT (email_rowid) DO UPDATE SET json = excluded.json', [rowid, JSON.stringify(body)]); db.run('UPDATE email SET body_state = 1 WHERE rowid = ?', [rowid]); // A first body is one insert. Only a body stored again has an old row to replace. diff --git a/lib/store/mail/schema.ts b/lib/store/mail/schema.ts index 64def1243..13b7cfc1c 100644 --- a/lib/store/mail/schema.ts +++ b/lib/store/mail/schema.ts @@ -92,4 +92,22 @@ export const MAIL_MIGRATIONS: readonly Migration[] = [ ) WITHOUT ROWID; `, }, + // In the drafts range: this came with drafts and attachments. + { + id: 410, + name: 'kept files in chunks, raw messages', + sql: ` + -- Binding a whole file grows the WASM heap by its size, and that never shrinks. + -- A file counts as kept once its row in blob exists. + CREATE TABLE blob_chunk ( + jmap TEXT NOT NULL, + blob_id TEXT NOT NULL, + seq INTEGER NOT NULL, + data BLOB NOT NULL, + PRIMARY KEY (jmap, blob_id, seq) + ); + -- 1 for the whole message (Email.blobId), for an export. + ALTER TABLE email_part ADD COLUMN raw INTEGER NOT NULL DEFAULT 0; + `, + }, ]; diff --git a/lib/store/mail/scope.ts b/lib/store/mail/scope.ts index d2cc201e5..352c3830c 100644 --- a/lib/store/mail/scope.ts +++ b/lib/store/mail/scope.ts @@ -8,7 +8,6 @@ import type { ScopeChange, StoreUsagePart, SyncContext } from '../area'; import { deleteEmailRow } from './records'; const DELETE_BATCH = 200; -const BLOB_BATCH = 20; /** Other calls and syncs get a turn between batches. */ const breathe = () => new Promise((resolve) => setTimeout(resolve, 0)); @@ -26,10 +25,13 @@ async function pruneWindow(ctx: SyncContext, days: number): Promise { const before = cutoff(ctx, days); let removed = false; for (const { jmap } of ctx.db.all<{ jmap: string }>('SELECT DISTINCT jmap FROM email')) { + // A draft only on this device has no other copy. A change waiting for any + // other message goes out by its id, whether it's kept or not. + const spared = ctx.sparedRecords(jmap, 'Email'); + const notSpared = spared.length > 0 ? `AND id NOT IN (${spared.map(() => '?').join(', ')})` : ''; for (;;) { if (ctx.stopped()) return; - // A change waiting for one of these goes out by its id, whether it's kept or not. - const rows = ctx.db.all<{ rowid: number }>('SELECT rowid FROM email WHERE jmap = ? AND received_at < ? LIMIT ?', [jmap, before, DELETE_BATCH]); + const rows = ctx.db.all<{ rowid: number }>(`SELECT rowid FROM email WHERE jmap = ? AND received_at < ? ${notSpared} LIMIT ?`, [jmap, before, ...spared, DELETE_BATCH]); if (rows.length === 0) break; ctx.db.transaction(() => { for (const row of rows) deleteEmailRow(ctx.db, Number(row.rowid)); @@ -41,42 +43,6 @@ async function pruneWindow(ctx: SyncContext, days: number): Promise { if (removed) ctx.emit({ type: 'changed', account: ctx.account, collections: ['Email'] }); } -/** The rows stay in email_part, so a loosened scope can fetch them again. */ -async function dropBlobs(ctx: SyncContext, select: () => Array<{ jmap: string; blob_id: string }>): Promise { - for (;;) { - if (ctx.stopped()) return; - const rows = select(); - if (rows.length === 0) return; - ctx.db.transaction(() => { - for (const row of rows) ctx.db.run('DELETE FROM blob WHERE jmap = ? AND blob_id = ?', [row.jmap, row.blob_id]); - }); - await breathe(); - } -} - -export async function pruneAttachments(ctx: SyncContext): Promise { - const { scope } = ctx; - if (!scope.attachments) { - await dropBlobs(ctx, () => ctx.db.all('SELECT jmap, blob_id FROM blob LIMIT ?', [BLOB_BATCH])); - return; - } - await dropBlobs(ctx, () => ctx.db.all('SELECT jmap, blob_id FROM blob WHERE size > ? LIMIT ?', [scope.maxAttachmentBytes, BLOB_BATCH])); - // The total is for all kept files of the account, so what the Files area holds takes its part. - const allowed = scope.maxTotalBytes - ctx.keptByOthers('mail'); - await dropBlobs(ctx, () => { - const used = Number(ctx.db.get<{ n: number | null }>('SELECT SUM(size) AS n FROM blob')?.n ?? 0); - if (used <= allowed) return []; - const out: Array<{ jmap: string; blob_id: string }> = []; - let over = used - allowed; - for (const row of ctx.db.all<{ jmap: string; blob_id: string; size: number }>('SELECT jmap, blob_id, size FROM blob ORDER BY at ASC LIMIT ?', [BLOB_BATCH])) { - if (over <= 0) break; - out.push({ jmap: row.jmap, blob_id: row.blob_id }); - over -= Number(row.size); - } - return out; - }); -} - export async function applyMailScope(ctx: SyncContext, { previous, next }: ScopeChange): Promise { // First, in case this gets interrupted: a pass under way pages by the old // window, so it starts over, tighter or wider. @@ -88,7 +54,6 @@ export async function applyMailScope(ctx: SyncContext, { previous, next }: Scope }); } if (next.days !== null) await pruneWindow(ctx, next.days); - await pruneAttachments(ctx); } /** Message text and the search index aren't counted here: they are what's left of the file. */ diff --git a/lib/store/mail/sync.ts b/lib/store/mail/sync.ts index f22265bee..c636d6f0a 100644 --- a/lib/store/mail/sync.ts +++ b/lib/store/mail/sync.ts @@ -10,15 +10,13 @@ import type { SyncContext, Syncer } from '../area'; import { deleteObject, putObject } from '../engine/core-schema'; import { runChangesLoop } from '../engine/changes'; -import { JmapMethodError, responseFor, TransportError, type MethodCall } from '../engine/transport'; -import { pruneAttachments } from './scope'; +import { JmapMethodError, responseFor, type MethodCall } from '../engine/transport'; import { deleteEmail, deleteEmailRow, EMAIL_BODY_PROPERTIES, EMAIL_LIST_PROPERTIES, MAIL_USING, - putBlob, storeBody, upsertEmail, type EmailObject, @@ -114,7 +112,8 @@ async function startFullList(ctx: SyncContext, jmap: string): Promise { for (;;) { if (ctx.stopped()) return false; - const unseen = ctx.db.all<{ rowid: number; id: string }>('SELECT rowid, id FROM email WHERE jmap = ? AND gen < ? LIMIT ?', [jmap, gen, PAGE]); + // A draft saved on this device isn't on the server yet: it has no say in this. + const unseen = ctx.db.all<{ rowid: number; id: string }>("SELECT rowid, id FROM email WHERE jmap = ? AND gen < ? AND id NOT LIKE 'local-%' LIMIT ?", [jmap, gen, PAGE]); if (unseen.length === 0) return true; const response = await ctx.transport.request([['Email/get', { accountId: jmap, @@ -350,52 +349,6 @@ async function backfillBodies(ctx: SyncContext, jmap: string): Promise { } } -// ---- Attachments ---- - -/** - * The files of kept messages, newest first, within what the user chose: one - * at a time, each in its own transaction, so a reload loses at most one. - */ -async function fetchAttachments(ctx: SyncContext, jmap: string): Promise { - if (!ctx.scope.attachments) return; - // The Files area may have taken its part of the total since the last pass. - const others = ctx.keptByOthers('mail'); - await pruneAttachments(ctx); - let used = others + Number(ctx.db.get<{ n: number | null }>('SELECT SUM(size) AS n FROM blob')?.n ?? 0); - for (;;) { - if (ctx.stopped()) return; - const parts = ctx.db.all<{ blob_id: string; size: number; type: string | null; name: string | null; received_at: number }>( - `SELECT p.blob_id, p.size, p.type, p.name, p.received_at FROM email_part p - WHERE p.jmap = ? AND p.refused = 0 AND p.size <= ? - AND NOT EXISTS (SELECT 1 FROM blob b WHERE b.jmap = p.jmap AND b.blob_id = p.blob_id) - ORDER BY p.received_at DESC LIMIT 20`, - [jmap, ctx.scope.maxAttachmentBytes], - ); - if (parts.length === 0) return; - for (const part of parts) { - if (ctx.stopped()) return; - const blobId = String(part.blob_id); - if (used + Number(part.size) > ctx.scope.maxTotalBytes) return; - let data: Uint8Array; - try { - data = await ctx.transport.download(jmap, blobId, part.type ?? 'application/octet-stream', part.name ?? 'attachment'); - } catch (error) { - // Only what says this file won't come. Anything else, like a 500 or a 429, is tried at the next sync. - if (error instanceof TransportError && error.kind === 'http' && PERMANENT_DOWNLOAD_STATUSES.has(error.status ?? 0)) { - ctx.db.run('UPDATE email_part SET refused = 1 WHERE jmap = ? AND blob_id = ?', [jmap, blobId]); - continue; - } - if (error instanceof TransportError && error.kind === 'http') return; - throw error; - } - putBlob(ctx.db, jmap, blobId, part.type, data, Number(part.received_at)); - used += data.byteLength; - } - } -} - -const PERMANENT_DOWNLOAD_STATUSES = new Set([403, 404, 410]); - // ---- Identities and quotas ---- const IDENTITY_USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:submission']; @@ -485,7 +438,5 @@ export const mailSyncer: Syncer = { await syncThreads(ctx, jmap); if (ctx.stopped()) return; await backfillBodies(ctx, jmap); - if (ctx.stopped()) return; - await fetchAttachments(ctx, jmap); }, }; diff --git a/lib/store/outbox/calls.ts b/lib/store/outbox/calls.ts index 33b1f7228..eff14fc62 100644 --- a/lib/store/outbox/calls.ts +++ b/lib/store/outbox/calls.ts @@ -1,6 +1,7 @@ /** What the page asks the outbox. Each call is one SQLite transaction, so two tabs can't interleave. */ import type { AreaCall, StoreContext } from '../area'; +import { heldBlobsOf, type DraftCreate } from '../drafts/local-email'; import { metaGet, metaSet } from '../engine/core-schema'; import { deleteEmail, upsertEmail, type EmailObject } from '../mail/records'; import { nextSeq, queueUpdate, type PatchRow } from './email-set'; @@ -136,48 +137,104 @@ function settleStale(ctx: StoreContext, params: { before: number }): number { ).changes; } -/** Removes an entry and the files it held. */ +/** Every held file a waiting message or a draft on this device still needs. */ +function neededFiles(ctx: StoreContext): Set { + const forSends = ctx.db.all('SELECT * FROM outbox').flatMap((row) => heldBlobIds(toEntry(row).item)); + const forDrafts = ctx.db.all<{ create_json: string }>('SELECT create_json FROM draft_pending').flatMap((row) => heldBlobsOf(JSON.parse(row.create_json) as DraftCreate)); + return new Set([...forSends, ...forDrafts]); +} + +function dropHeld(ctx: StoreContext, blobId: string): void { + ctx.db.run('DELETE FROM upload_chunk WHERE local_id = ?', [blobId]); + ctx.db.run('DELETE FROM upload WHERE local_id = ?', [blobId]); +} + +/** Removes an entry and the files only it held. A draft may share one with the message it became. */ function remove(ctx: StoreContext, params: { id: number }): void { ctx.db.transaction(() => { const row = ctx.db.get('SELECT * FROM outbox WHERE id = ?', [params.id]); if (!row) return; - for (const blobId of heldBlobIds(toEntry(row).item)) ctx.db.run('DELETE FROM upload WHERE local_id = ?', [blobId]); ctx.db.run('DELETE FROM outbox WHERE id = ?', [params.id]); + const needed = neededFiles(ctx); + for (const blobId of heldBlobIds(toEntry(row).item)) if (!needed.has(blobId)) dropHeld(ctx, blobId); }); } // ---- Files attached offline ---- -function holdFile(ctx: StoreContext, params: { jmap?: string; name: string; type: string; data: Uint8Array; localId?: string }): { blobId: string; size: number; type: string } { - const blobId = params.localId ?? `${LOCAL_BLOB_PREFIX}${crypto.randomUUID()}`; +/** The same size the page cuts a file into on its way here. */ +const HOLD_CHUNK_BYTES = 1024 * 1024; + +/** A file that comes in pieces: nothing can read it until the last one is in. */ +function holdStart(ctx: StoreContext, params: { jmap?: string; name: string; type: string; size: number }): { blobId: string } { + const blobId = `${LOCAL_BLOB_PREFIX}${crypto.randomUUID()}`; ctx.db.run( - 'INSERT OR REPLACE INTO upload (local_id, jmap, type, name, size, data, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', - [blobId, params.jmap ?? null, params.type, params.name, params.data.byteLength, params.data, ctx.now()], + "INSERT INTO upload (local_id, jmap, type, name, size, data, created_at, complete) VALUES (?, ?, ?, ?, ?, x'', ?, 0)", + [blobId, params.jmap ?? null, params.type, params.name, params.size, ctx.now()], ); - return { blobId, size: params.data.byteLength, type: params.type || 'application/octet-stream' }; + return { blobId }; } -function heldFile(ctx: StoreContext, params: { blobId: string }): { name: string; type: string; data: Uint8Array } | null { - const row = ctx.db.get<{ name: string | null; type: string | null; data: Uint8Array }>('SELECT name, type, data FROM upload WHERE local_id = ?', [params.blobId]); - return row ? { name: row.name ?? 'attachment', type: row.type ?? 'application/octet-stream', data: row.data } : null; +function holdChunk(ctx: StoreContext, params: { blobId: string; seq: number; data: Uint8Array }): void { + ctx.db.run('INSERT OR REPLACE INTO upload_chunk (local_id, seq, data) VALUES (?, ?, ?)', [params.blobId, params.seq, params.data]); } -const heldFileSources: Array<(ctx: StoreContext) => Iterable> = []; - -/** Another area's records that hold files by their local id, which dropOrphans must leave alone. */ -export function addHeldFileSource(source: (ctx: StoreContext) => Iterable): void { - heldFileSources.push(source); +function holdEnd(ctx: StoreContext, params: { blobId: string }): { blobId: string; size: number; type: string } { + ctx.db.run('UPDATE upload SET complete = 1 WHERE local_id = ?', [params.blobId]); + const row = ctx.db.get<{ size: number; type: string | null }>('SELECT size, type FROM upload WHERE local_id = ?', [params.blobId]); + return { blobId: params.blobId, size: Number(row?.size ?? 0), type: row?.type || 'application/octet-stream' }; } -/** Files held for no entry (attached offline to a message that was then discarded). SEAM: the drafts agent keeps its drafts' files here too. */ +function holdAbort(ctx: StoreContext, params: { blobId: string }): void { + ctx.db.transaction(() => dropHeld(ctx, params.blobId)); +} + +/** A file that's here as a whole already: the old database's, on its move here. */ +function holdFile(ctx: StoreContext, params: { jmap?: string; name: string; type: string; data: Uint8Array; localId?: string }): { blobId: string; size: number; type: string } { + return ctx.db.transaction(() => { + const blobId = params.localId ?? `${LOCAL_BLOB_PREFIX}${crypto.randomUUID()}`; + dropHeld(ctx, blobId); + ctx.db.run( + "INSERT INTO upload (local_id, jmap, type, name, size, data, created_at) VALUES (?, ?, ?, ?, ?, x'', ?)", + [blobId, params.jmap ?? null, params.type, params.name, params.data.byteLength, ctx.now()], + ); + for (let at = 0, seq = 0; at < params.data.byteLength; at += HOLD_CHUNK_BYTES, seq += 1) { + ctx.db.run('INSERT INTO upload_chunk (local_id, seq, data) VALUES (?, ?, ?)', [blobId, seq, params.data.subarray(at, at + HOLD_CHUNK_BYTES)]); + } + return { blobId, size: params.data.byteLength, type: params.type || 'application/octet-stream' }; + }); +} + +/** Piece by piece into a Blob, which the browser can keep out of this worker's memory. */ +function heldFile(ctx: StoreContext, params: { blobId: string }): { name: string; type: string; size: number; blob: Blob } | null { + const row = ctx.db.get<{ name: string | null; type: string | null; size: number; data: Uint8Array }>('SELECT name, type, size, data FROM upload WHERE local_id = ? AND complete = 1', [params.blobId]); + if (!row) return null; + const type = row.type || 'application/octet-stream'; + // A file from before chunks has its bytes in the row; a new one has none there. + let blob = new Blob(row.data?.byteLength ? [row.data as BlobPart] : [], { type }); + for (let seq = 0; ; seq += 1) { + const chunk = ctx.db.get<{ data: Uint8Array }>('SELECT data FROM upload_chunk WHERE local_id = ? AND seq = ?', [params.blobId, seq]); + if (!chunk) break; + // Each step only refers to the last Blob, so one chunk at a time is in memory here. + blob = new Blob([blob, chunk.data as BlobPart], { type }); + } + // Pieces missing: as gone, so it's said so, rather than a file that uploads as empty. + if (blob.size !== Number(row.size)) return null; + return { name: row.name ?? 'attachment', type, size: Number(row.size), blob }; +} + +/** + * Files nothing needs any more: attached to a message that was then discarded, + * or sent or saved long enough ago. Not at once, because an open composer may + * still show one. + */ function dropOrphans(ctx: StoreContext, params: { olderThan: number }): number { return ctx.db.transaction(() => { - const needed = new Set(ctx.db.all('SELECT * FROM outbox').flatMap((row) => heldBlobIds(toEntry(row).item))); - for (const source of heldFileSources) for (const id of source(ctx)) needed.add(id); + const needed = neededFiles(ctx); let dropped = 0; for (const row of ctx.db.all<{ local_id: string }>('SELECT local_id FROM upload WHERE created_at < ?', [params.olderThan])) { if (needed.has(String(row.local_id))) continue; - ctx.db.run('DELETE FROM upload WHERE local_id = ?', [row.local_id]); + dropHeld(ctx, String(row.local_id)); dropped += 1; } return dropped; @@ -288,6 +345,10 @@ export const OUTBOX_CALLS: Readonly> = { 'outbox.settleStale': settleStale, 'outbox.remove': remove, 'outbox.holdFile': holdFile, + 'outbox.holdStart': holdStart, + 'outbox.holdChunk': holdChunk, + 'outbox.holdEnd': holdEnd, + 'outbox.holdAbort': holdAbort, 'outbox.heldFile': heldFile, 'outbox.dropOrphans': dropOrphans, 'outbox.pendingPatches': pendingPatches, diff --git a/lib/store/outbox/email-set.ts b/lib/store/outbox/email-set.ts index 5451e1c21..51e2bae25 100644 --- a/lib/store/outbox/email-set.ts +++ b/lib/store/outbox/email-set.ts @@ -4,7 +4,10 @@ * same way. Answers carry no state: a made-up one would reach the server later. */ +import { isStandInId } from '@/lib/jmap/stand-in-ids'; import { MethodError, type MethodContext, type MethodHandler, type RecordOverlay } from '../area'; +import { createLocalDraft, destroyForDraft, updateLocalDraft } from '../drafts/pending'; +import type { DraftCreate } from '../drafts/local-email'; import { metaGet, metaSet } from '../engine/core-schema'; import { deleteEmail, upsertEmail, type EmailObject } from '../mail/records'; import type { StorageAdapter } from '../storage'; @@ -24,6 +27,7 @@ export interface PatchRow { } const EMAIL = 'Email'; +export const EMAIL_TYPE = EMAIL; /** The next place in the order changes and sends were made. */ export function nextSeq(db: StorageAdapter): number { @@ -32,11 +36,11 @@ export function nextSeq(db: StorageAdapter): number { return next; } -function pendingRow(db: StorageAdapter, jmap: string, id: string): PatchRow | undefined { +export function pendingRow(db: StorageAdapter, jmap: string, id: string): PatchRow | undefined { return db.get('SELECT * FROM outbox_patch WHERE jmap = ? AND type = ? AND record_id = ?', [jmap, EMAIL, id]); } -function savePatch(db: StorageAdapter, row: Omit, now: number): void { +export function savePatch(db: StorageAdapter, row: Omit, now: number): void { db.run( `INSERT INTO outbox_patch (jmap, type, record_id, op, patch, base, server, subject, seq, queued_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) @@ -45,7 +49,7 @@ function savePatch(db: StorageAdapter, row: Omit('SELECT json, gen FROM email WHERE jmap = ? AND id = ?', [jmap, id]); return row ? { email: JSON.parse(row.json) as EmailObject, gen: Number(row.gen) } : null; } @@ -90,13 +94,16 @@ export function queueDestroy(db: StorageAdapter, jmap: string, id: string, now: if (local) deleteEmail(db, jmap, id); } -/** - * SEAM for the drafts agent: Email/set create is theirs. Until it lands every - * create is refused per message, so createDraft throws and the composer shows - * its "draft not saved" state, as it does today. - */ -function createEmails(_ctx: MethodContext, _jmap: string, create: Record): Record { - return Object.fromEntries(Object.keys(create).map((id) => [id, { type: 'serverUnavailable', description: 'Saving a draft needs the server' }])); +/** A draft stays on this device until it can go up (lib/store/drafts). Any other new message needs the server. */ +function createEmails(ctx: MethodContext, jmap: string, create: Record, networkTried: boolean): { created: Record; notCreated: Record } { + const created: Record = {}; + const notCreated: Record = {}; + for (const [creationId, email] of Object.entries(create)) { + const { refused, ...answer } = createLocalDraft(ctx, jmap, email as DraftCreate, networkTried); + if (refused) notCreated[creationId] = answer; + else created[creationId] = answer; + } + return { created, notCreated }; } export const emailSet: MethodHandler = (ctx, args) => { @@ -107,14 +114,15 @@ export const emailSet: MethodHandler = (ctx, args) => { const result: Record = { accountId: jmap }; ctx.db.transaction(() => { if (args.create && Object.keys(args.create as object).length > 0) { - result.created = {}; - result.notCreated = createEmails(ctx, jmap, args.create as Record); + const { created, notCreated } = createEmails(ctx, jmap, args.create as Record, args.networkTried === true); + result.created = created; + if (Object.keys(notCreated).length > 0) result.notCreated = notCreated; } if (args.update) { const updated: Record = {}; const notUpdated: Record = {}; for (const [id, patch] of Object.entries(args.update as Record)) { - const problem = queueUpdate(ctx.db, jmap, id, patch, ctx.now()); + const problem = isStandInId(id) ? updateLocalDraft(ctx, jmap, id, patch) : queueUpdate(ctx.db, jmap, id, patch, ctx.now()); if (problem) notUpdated[id] = problem; else updated[id] = null; } @@ -122,7 +130,10 @@ export const emailSet: MethodHandler = (ctx, args) => { if (Object.keys(notUpdated).length > 0) result.notUpdated = notUpdated; } if (Array.isArray(args.destroy)) { - for (const id of args.destroy as string[]) queueDestroy(ctx.db, jmap, id, ctx.now()); + const replacedBy = typeof args.replacedBy === 'string' ? args.replacedBy : undefined; + for (const id of args.destroy as string[]) { + if (!destroyForDraft(ctx, jmap, id, replacedBy)) queueDestroy(ctx.db, jmap, id, ctx.now()); + } result.destroyed = args.destroy; } }); diff --git a/lib/store/outbox/index.ts b/lib/store/outbox/index.ts index 4d65885cb..5dc28dd01 100644 --- a/lib/store/outbox/index.ts +++ b/lib/store/outbox/index.ts @@ -1,13 +1,23 @@ -import type { StoreArea } from '../area'; +import type { RecordOverlay, StoreArea } from '../area'; +import { DRAFT_CALLS } from '../drafts/calls'; +import { draftsOverlay } from '../drafts/pending'; +import { DRAFT_MIGRATIONS } from '../drafts/schema'; import { OUTBOX_CALLS } from './calls'; import { emailSet, outboxOverlay } from './email-set'; import { OUTBOX_MIGRATIONS } from './schema'; +const overlay: RecordOverlay = (ctx, jmap, type, records) => draftsOverlay(ctx, jmap, type, outboxOverlay(ctx, jmap, type, records)); + +/** Drafts answer the same Email/set and share the queue's order, so they plug in here. */ export const outboxArea: StoreArea = { name: 'outbox', - migrations: OUTBOX_MIGRATIONS, + migrations: [...OUTBOX_MIGRATIONS, ...DRAFT_MIGRATIONS], methods: { 'Email/set': emailSet }, syncers: [], - calls: OUTBOX_CALLS, - overlay: outboxOverlay, + calls: { ...OUTBOX_CALLS, ...DRAFT_CALLS }, + overlay, + // A draft only on this device has no other copy, however old it is. + spares: (ctx, jmap, type) => (type === 'Email' + ? ctx.db.all<{ local_id: string }>('SELECT local_id FROM draft_pending WHERE jmap = ?', [jmap]).map((row) => row.local_id) + : []), }; diff --git a/lib/store/outbox/items.ts b/lib/store/outbox/items.ts index e802090ee..01cc1c4f6 100644 --- a/lib/store/outbox/items.ts +++ b/lib/store/outbox/items.ts @@ -91,7 +91,8 @@ export type ConflictReason = 'changed' | 'gone' | 'refused'; export interface ConflictSummary { subject: string; - op: 'update' | 'destroy'; + /** `draft`: a draft saved here met a version changed elsewhere. Both are kept. */ + op: 'update' | 'destroy' | 'draft'; reason: ConflictReason; /** The server's word when it refused. */ detail?: string; diff --git a/locales/ar/common.json b/locales/ar/common.json index 01aa69392..10fde651f 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5818,6 +5818,26 @@ "unpin": "إيقاف الاحتفاظ دون اتصال", "unpin_label": "إيقاف الاحتفاظ بـ {name} دون اتصال" }, + "offlineDrafts": { + "title": "المسودات على هذا الجهاز", + "description": "هذه المسودات محفوظة على هذا الجهاز. ستنتقل إلى الخادم تلقائيًا فور إمكان الوصول إليه.", + "saved_on_device": "محفوظة على هذا الجهاز", + "status_waiting": "في انتظار الخادم", + "status_sending": "جارٍ الحفظ على الخادم", + "status_uncertain": "جارٍ التحقق من وصولها إلى الخادم", + "status_refused": "رفض الخادم هذه المسودة: {error}", + "file_missing": "لم يعد ملف مرفق موجودًا على هذا الجهاز", + "open": "فتح", + "open_label": "فتح {subject}", + "retry": "إعادة المحاولة", + "retry_label": "إعادة محاولة {subject}", + "toast_refused": "تعذر حفظ المسودة {subject} على الخادم. ستبقى على هذا الجهاز.", + "toast_conflict": "تم تغيير المسودة {subject} على جهاز آخر. يتم الاحتفاظ بالنسختين.", + "conflict_changed": "تم التغيير على جهاز آخر. النسختان في المسودات.", + "conflict_gone": "تم التغيير أو الإزالة على جهاز آخر. نسختك في المسودات.", + "banner_refused": "المسودات التي رفضها الخادم: {count}", + "banner_waiting": "مسودات في انتظار الخادم: {count}" + }, "deep_link": { "message_not_found": "لم تعد هذه الرسالة متاحة.", "thread_not_found": "لم تعد هذه المحادثة متاحة.", @@ -5934,6 +5954,10 @@ "title": "وصول كامل إلى هذا التطبيق", "body": "تعمل بالصلاحيات نفسها التي يملكها تطبيق البريد. يمكنها قراءة كل بريدك ومفاتيحك المخزّنة والتصرف نيابةً عنك. لا تسمح بذلك إلا للإضافات التي تثق بها تمامًا." }, + "keystore": { + "title": "استخدام الشهادات الموجودة على هذا الكمبيوتر", + "body": "طلب توقيع الرسائل وفك تشفيرها باستخدام الشهادات الموجودة على هذا الكمبيوتر أو على بطاقة ذكية. يسألك التطبيق أولاً. لا يحصل المكوّن الإضافي على المفتاح الخاص أبدًا، ولكن طالما سمحت بذلك يمكنه التوقيع باسمك وقراءة ما يفك تشفيره." + }, "email_raw_send": { "title": "إرسال رسائل أنشأتها بنفسها", "body": "إرسال رسائل كاملة من حسابك، مثل الرسائل الموقّعة أو المشفّرة." @@ -6584,5 +6608,13 @@ "server_needed": "أدخل عنوان الخادم أولًا.", "server_declined": "لم تسمح بهذا الخادم، لذا لم يُرسل إليه أي شيء.", "server_invalid": "عنوان الخادم هذا غير صالح." + }, + "desktop_keystore": { + "title": "الشهادات الموجودة على هذا الكمبيوتر", + "description": "يمكن لهذا الخادم أن يطلب استخدام شهاداتك وبطاقاتك الذكية. سيُطلب إذنك مع ذلك قبل استخدام كل شهادة.", + "label": "وصول هذا الخادم", + "label_description": "اسحب إذنك. سيتعين على الخادم أن يطلبه مرة أخرى.", + "revoke": "سحب الإذن", + "revoked": "تم سحب الإذن" } } diff --git a/locales/ca/common.json b/locales/ca/common.json index fa74fe5ad..090d5ed0d 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5818,6 +5818,26 @@ "unpin": "Deixa de desar sense connexió", "unpin_label": "Deixa de desar {name} sense connexió" }, + "offlineDrafts": { + "title": "Esborranys en aquest dispositiu", + "description": "Aquests esborranys estan desats en aquest dispositiu. Aniran al servidor sols tan bon punt s'hi pugui connectar.", + "saved_on_device": "Desat en aquest dispositiu", + "status_waiting": "Esperant el servidor", + "status_sending": "Desant al servidor", + "status_uncertain": "Comprovant si ha arribat al servidor", + "status_refused": "El servidor ha rebutjat aquest esborrany: {error}", + "file_missing": "Un fitxer adjunt ja no és en aquest dispositiu", + "open": "Obre", + "open_label": "Obre {subject}", + "retry": "Torna-ho a provar", + "retry_label": "Torna a provar {subject}", + "toast_refused": "L'esborrany {subject} no s'ha pogut desar al servidor. Es queda en aquest dispositiu.", + "toast_conflict": "L'esborrany {subject} s'ha canviat en un altre dispositiu. Es conserven les dues versions.", + "conflict_changed": "Canviat en un altre dispositiu. Les dues versions són a Esborranys.", + "conflict_gone": "Canviat o eliminat en un altre dispositiu. La vostra versió és a Esborranys.", + "banner_refused": "Esborranys rebutjats pel servidor: {count}", + "banner_waiting": "Esborranys que esperen el servidor: {count}" + }, "deep_link": { "message_not_found": "Aquest missatge ja no està disponible.", "thread_not_found": "Aquesta conversa ja no està disponible.", @@ -5934,6 +5954,10 @@ "title": "Accés complet a aquesta aplicació", "body": "S'executa amb els mateixos drets que el mateix webmail. Pot llegir tot el vostre correu i les claus desades i actuar en nom vostre. Permeteu-ho només als connectors en què confieu plenament." }, + "keystore": { + "title": "Utilitzar els certificats d'aquest ordinador", + "body": "Demana signar i desxifrar missatges amb certificats d'aquest ordinador o d'una targeta intel·ligent. L'aplicació us ho pregunta abans. El connector no rep mai la clau privada, però mentre ho permeteu pot signar en nom vostre i llegir el que desxifra." + }, "email_raw_send": { "title": "Enviar missatges que ha creat ell mateix", "body": "Enviar missatges complets, per exemple signats o xifrats, des del vostre compte." @@ -6584,5 +6608,13 @@ "server_needed": "Primer escriu l'adreça del servidor.", "server_declined": "No has permès aquest servidor, així que no s'hi ha enviat res.", "server_invalid": "Aquesta adreça de servidor no és vàlida." + }, + "desktop_keystore": { + "title": "Certificats d'aquest ordinador", + "description": "Aquest servidor pot demanar utilitzar els vostres certificats i targetes intel·ligents. Tot i així, se us preguntarà abans d'utilitzar cada certificat.", + "label": "Accés d'aquest servidor", + "label_description": "Retira el permís. El servidor ho haurà de tornar a demanar.", + "revoke": "Retira", + "revoked": "Permís retirat" } } diff --git a/locales/cs/common.json b/locales/cs/common.json index 69ffed51b..f5bf15a75 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5818,6 +5818,26 @@ "unpin": "Přestat uchovávat offline", "unpin_label": "Přestat uchovávat {name} offline" }, + "offlineDrafts": { + "title": "Koncepty v tomto zařízení", + "description": "Tyto koncepty jsou uložené v tomto zařízení. Na server se odešlou samy, jakmile bude dostupný.", + "saved_on_device": "Uloženo v tomto zařízení", + "status_waiting": "Čeká na server", + "status_sending": "Ukládá se na server", + "status_uncertain": "Ověřuje se, zda dorazil na server", + "status_refused": "Server tento koncept odmítl: {error}", + "file_missing": "Přiložený soubor už v tomto zařízení není", + "open": "Otevřít", + "open_label": "Otevřít {subject}", + "retry": "Zkusit znovu", + "retry_label": "Zkusit znovu: {subject}", + "toast_refused": "Koncept {subject} se nepodařilo uložit na server. Zůstává v tomto zařízení.", + "toast_conflict": "Koncept {subject} byl změněn v jiném zařízení. Obě verze zůstávají zachovány.", + "conflict_changed": "Změněno v jiném zařízení. Obě verze jsou v Konceptech.", + "conflict_gone": "Změněno nebo odstraněno v jiném zařízení. Vaše verze je v Konceptech.", + "banner_refused": "Koncepty odmítnuté serverem: {count}", + "banner_waiting": "Koncepty čekající na server: {count}" + }, "deep_link": { "message_not_found": "Tato zpráva už není dostupná.", "thread_not_found": "Tato konverzace už není dostupná.", @@ -5934,6 +5954,10 @@ "title": "Plný přístup k této aplikaci", "body": "Běží se stejnými právy jako samotný webmail. Může číst veškerou vaši poštu a uložené klíče a jednat vaším jménem. Povolte to jen pluginům, kterým plně důvěřujete." }, + "keystore": { + "title": "Používat certifikáty v tomto počítači", + "body": "Žádá o podepisování a dešifrování zpráv pomocí certifikátů v tomto počítači nebo na čipové kartě. Aplikace se vás nejprve zeptá. Plugin nikdy nedostane soukromý klíč, ale dokud to povolíte, může podepisovat vaším jménem a číst, co dešifruje." + }, "email_raw_send": { "title": "Odesílat zprávy, které sám vytvořil", "body": "Odesílá z vašeho účtu kompletní zprávy, například podepsané nebo šifrované." @@ -6584,5 +6608,13 @@ "server_needed": "Nejdřív zadejte adresu serveru.", "server_declined": "Tento server jste nepovolili, proto se na něj nic neodeslalo.", "server_invalid": "Tato adresa serveru není platná." + }, + "desktop_keystore": { + "title": "Certifikáty v tomto počítači", + "description": "Tento server může žádat o použití vašich certifikátů a čipových karet. Před použitím každého certifikátu se vás přesto zeptáme.", + "label": "Přístup tohoto serveru", + "label_description": "Odvolejte své povolení. Server se bude muset zeptat znovu.", + "revoke": "Odvolat", + "revoked": "Povolení bylo odvoláno" } } diff --git a/locales/da/common.json b/locales/da/common.json index 490f7ff2b..c70eff586 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5818,6 +5818,26 @@ "unpin": "Behold ikke længere offline", "unpin_label": "Behold ikke {name} offline længere" }, + "offlineDrafts": { + "title": "Kladder på denne enhed", + "description": "Disse kladder er gemt på denne enhed. De sendes selv til serveren, så snart den kan nås.", + "saved_on_device": "Gemt på denne enhed", + "status_waiting": "Venter på serveren", + "status_sending": "Gemmer på serveren", + "status_uncertain": "Tjekker, om den nåede serveren", + "status_refused": "Serveren afviste denne kladde: {error}", + "file_missing": "En vedhæftet fil er ikke længere på denne enhed", + "open": "Åbn", + "open_label": "Åbn {subject}", + "retry": "Prøv igen", + "retry_label": "Prøv {subject} igen", + "toast_refused": "Kladden {subject} kunne ikke gemmes på serveren. Den bliver på denne enhed.", + "toast_conflict": "Kladden {subject} blev ændret på en anden enhed. Begge versioner beholdes.", + "conflict_changed": "Ændret på en anden enhed. Begge versioner ligger i Kladder.", + "conflict_gone": "Ændret eller fjernet på en anden enhed. Din version ligger i Kladder.", + "banner_refused": "Kladder afvist af serveren: {count}", + "banner_waiting": "Kladder, der venter på serveren: {count}" + }, "deep_link": { "message_not_found": "Denne besked er ikke længere tilgængelig.", "thread_not_found": "Denne samtale er ikke længere tilgængelig.", @@ -5934,6 +5954,10 @@ "title": "Fuld adgang til denne app", "body": "Kører med de samme rettigheder som selve webmailen. Det kan læse alle dine e-mails og gemte nøgler og handle på dine vegne. Tillad det kun for plugins, du stoler fuldt ud på." }, + "keystore": { + "title": "Brug certifikater på denne computer", + "body": "Beder om at signere og dekryptere beskeder med certifikater på denne computer eller et chipkort. Appen spørger dig først. Pluginnet får aldrig den private nøgle, men så længe du tillader det, kan det signere i dit navn og læse det, det dekrypterer." + }, "email_raw_send": { "title": "Sende beskeder, det selv har lavet", "body": "Sende færdige beskeder, f.eks. signerede eller krypterede, fra din konto." @@ -6584,5 +6608,13 @@ "server_needed": "Angiv først serverens adresse.", "server_declined": "Du tillod ikke denne server, så der blev ikke sendt noget til den.", "server_invalid": "Denne serveradresse er ikke gyldig." + }, + "desktop_keystore": { + "title": "Certifikater på denne computer", + "description": "Denne server kan bede om at bruge dine certifikater og chipkort. Du bliver stadig spurgt, før hvert certifikat bruges.", + "label": "Adgang for denne server", + "label_description": "Træk din tilladelse tilbage. Serveren skal så spørge igen.", + "revoke": "Træk tilbage", + "revoked": "Tilladelsen er trukket tilbage" } } diff --git a/locales/de/common.json b/locales/de/common.json index 0e20e1b8b..af3b0b033 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5818,6 +5818,26 @@ "unpin": "Nicht mehr offline behalten", "unpin_label": "{name} nicht mehr offline behalten" }, + "offlineDrafts": { + "title": "Entwürfe auf diesem Gerät", + "description": "Diese Entwürfe sind auf diesem Gerät gespeichert. Sie gehen von selbst an den Server, sobald er erreichbar ist.", + "saved_on_device": "Auf diesem Gerät gespeichert", + "status_waiting": "Wartet auf den Server", + "status_sending": "Wird auf dem Server gespeichert", + "status_uncertain": "Es wird geprüft, ob er den Server erreicht hat", + "status_refused": "Der Server hat diesen Entwurf abgelehnt: {error}", + "file_missing": "Eine angehängte Datei ist nicht mehr auf diesem Gerät", + "open": "Öffnen", + "open_label": "{subject} öffnen", + "retry": "Erneut versuchen", + "retry_label": "{subject} erneut versuchen", + "toast_refused": "Der Entwurf {subject} konnte nicht auf dem Server gespeichert werden. Er bleibt auf diesem Gerät.", + "toast_conflict": "Der Entwurf {subject} wurde auf einem anderen Gerät geändert. Beide Fassungen bleiben erhalten.", + "conflict_changed": "Auf einem anderen Gerät geändert. Beide Fassungen liegen in den Entwürfen.", + "conflict_gone": "Auf einem anderen Gerät geändert oder entfernt. Ihre Fassung liegt in den Entwürfen.", + "banner_refused": "Vom Server abgelehnte Entwürfe: {count}", + "banner_waiting": "Entwürfe warten auf den Server: {count}" + }, "deep_link": { "message_not_found": "Diese Nachricht ist nicht mehr verfügbar.", "thread_not_found": "Diese Unterhaltung ist nicht mehr verfügbar.", @@ -5934,6 +5954,10 @@ "title": "Vollzugriff auf diese App", "body": "Läuft mit denselben Rechten wie das Webmail selbst. Es kann alle Ihre E-Mails und gespeicherten Schlüssel lesen und in Ihrem Namen handeln. Erlauben Sie das nur Plugins, denen Sie voll vertrauen." }, + "keystore": { + "title": "Zertifikate auf diesem Computer verwenden", + "body": "Bittet darum, Nachrichten mit Zertifikaten auf diesem Computer oder einer Smartcard zu signieren und zu entschlüsseln. Die App fragt Sie vorher. Das Plugin erhält nie den privaten Schlüssel, kann aber, solange Sie es zulassen, in Ihrem Namen signieren und lesen, was es entschlüsselt." + }, "email_raw_send": { "title": "Selbst erstellte Nachrichten senden", "body": "Vollständige Nachrichten, zum Beispiel signierte oder verschlüsselte, von Ihrem Konto senden." @@ -6584,5 +6608,13 @@ "server_needed": "Geben Sie zuerst die Adresse Ihres Servers ein.", "server_declined": "Sie haben diesen Server nicht zugelassen, daher wurde nichts an ihn gesendet.", "server_invalid": "Diese Serveradresse ist ungültig." + }, + "desktop_keystore": { + "title": "Zertifikate auf diesem Computer", + "description": "Dieser Server darf darum bitten, Ihre Zertifikate und Smartcards zu verwenden. Vor der Verwendung eines Zertifikats werden Sie trotzdem gefragt.", + "label": "Zugriff für diesen Server", + "label_description": "Nehmen Sie Ihre Erlaubnis zurück. Der Server muss dann erneut fragen.", + "revoke": "Zurücknehmen", + "revoked": "Erlaubnis zurückgenommen" } } diff --git a/locales/en/common.json b/locales/en/common.json index 46350c150..6f84023f7 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5818,6 +5818,26 @@ "unpin": "Stop keeping offline", "unpin_label": "Stop keeping {name} offline" }, + "offlineDrafts": { + "title": "Drafts on this device", + "description": "These drafts are saved on this device. They go to the server by themselves once it can be reached.", + "saved_on_device": "Saved on this device", + "status_waiting": "Waiting for the server", + "status_sending": "Saving to the server", + "status_uncertain": "Checking whether it reached the server", + "status_refused": "The server refused this draft: {error}", + "file_missing": "A file attached to it is no longer on this device", + "open": "Open", + "open_label": "Open {subject}", + "retry": "Try again", + "retry_label": "Try {subject} again", + "toast_refused": "The draft {subject} couldn't be saved on the server. It stays on this device.", + "toast_conflict": "The draft {subject} was changed on another device. Both versions are kept.", + "conflict_changed": "Changed on another device. Both versions are in Drafts.", + "conflict_gone": "Changed or removed on another device. Your version is in Drafts.", + "banner_refused": "Drafts the server refused: {count}", + "banner_waiting": "Drafts waiting for the server: {count}" + }, "deep_link": { "message_not_found": "This message is no longer available.", "thread_not_found": "This conversation is no longer available.", @@ -5934,6 +5954,10 @@ "title": "Full access to this app", "body": "Runs with the same rights as the webmail itself. It can read all your mail and stored keys and act for you. Only allow this for plugins you fully trust." }, + "keystore": { + "title": "Use certificates on this computer", + "body": "Ask to sign and decrypt messages with certificates on this computer or a smart card. The app asks you first. The plugin never gets the private key, but while you allow it, it can sign in your name and read what it decrypts." + }, "email_raw_send": { "title": "Send messages it built itself", "body": "Send complete messages, for example signed or encrypted ones, from your account." @@ -6584,5 +6608,13 @@ "server_needed": "Enter your server's address first.", "server_declined": "You didn't allow this server, so nothing was sent to it.", "server_invalid": "This server address isn't valid." + }, + "desktop_keystore": { + "title": "Certificates on this computer", + "description": "This server may ask to use your certificates and smart cards. You are still asked before each certificate is used.", + "label": "Access for this server", + "label_description": "Take back your permission. The server has to ask again.", + "revoke": "Take back", + "revoked": "Permission taken back" } } diff --git a/locales/es/common.json b/locales/es/common.json index 10aad8120..c0c9ed0af 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5818,6 +5818,26 @@ "unpin": "Dejar de guardar sin conexión", "unpin_label": "Dejar de guardar {name} sin conexión" }, + "offlineDrafts": { + "title": "Borradores en este dispositivo", + "description": "Estos borradores están guardados en este dispositivo. Irán al servidor por sí solos en cuanto se pueda conectar con él.", + "saved_on_device": "Guardado en este dispositivo", + "status_waiting": "Esperando al servidor", + "status_sending": "Guardando en el servidor", + "status_uncertain": "Comprobando si llegó al servidor", + "status_refused": "El servidor rechazó este borrador: {error}", + "file_missing": "Un archivo adjunto ya no está en este dispositivo", + "open": "Abrir", + "open_label": "Abrir {subject}", + "retry": "Reintentar", + "retry_label": "Reintentar {subject}", + "toast_refused": "El borrador {subject} no se pudo guardar en el servidor. Se queda en este dispositivo.", + "toast_conflict": "El borrador {subject} se cambió en otro dispositivo. Se conservan las dos versiones.", + "conflict_changed": "Cambiado en otro dispositivo. Las dos versiones están en Borradores.", + "conflict_gone": "Cambiado o eliminado en otro dispositivo. Su versión está en Borradores.", + "banner_refused": "Borradores rechazados por el servidor: {count}", + "banner_waiting": "Borradores esperando al servidor: {count}" + }, "deep_link": { "message_not_found": "Este mensaje ya no está disponible.", "thread_not_found": "Esta conversación ya no está disponible.", @@ -5934,6 +5954,10 @@ "title": "Acceso total a esta aplicación", "body": "Se ejecuta con los mismos derechos que el propio webmail. Puede leer todo tu correo y tus claves guardadas y actuar en tu nombre. Permítelo solo en complementos en los que confíes plenamente." }, + "keystore": { + "title": "Usar certificados de este ordenador", + "body": "Pide firmar y descifrar mensajes con certificados de este ordenador o de una tarjeta inteligente. La aplicación le pregunta antes. El complemento nunca recibe la clave privada, pero mientras lo permita puede firmar en su nombre y leer lo que descifra." + }, "email_raw_send": { "title": "Enviar mensajes que creó él mismo", "body": "Enviar mensajes completos, por ejemplo firmados o cifrados, desde tu cuenta." @@ -6584,5 +6608,13 @@ "server_needed": "Primero escribe la dirección del servidor.", "server_declined": "No permitiste este servidor, así que no se le envió nada.", "server_invalid": "Esta dirección de servidor no es válida." + }, + "desktop_keystore": { + "title": "Certificados de este ordenador", + "description": "Este servidor puede pedir usar sus certificados y tarjetas inteligentes. Aun así, se le preguntará antes de usar cada certificado.", + "label": "Acceso de este servidor", + "label_description": "Retire su permiso. El servidor tendrá que volver a pedirlo.", + "revoke": "Retirar", + "revoked": "Permiso retirado" } } diff --git a/locales/et/common.json b/locales/et/common.json index 6ec985068..9f477d450 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5818,6 +5818,26 @@ "unpin": "Ära hoia enam võrguta", "unpin_label": "Ära hoia faili {name} enam võrguta" }, + "offlineDrafts": { + "title": "Mustandid selles seadmes", + "description": "Need mustandid on salvestatud selles seadmes. Need lähevad serverisse ise, kui server on kättesaadav.", + "saved_on_device": "Salvestatud selles seadmes", + "status_waiting": "Ootab serverit", + "status_sending": "Salvestamine serverisse", + "status_uncertain": "Kontrollimine, kas see jõudis serverisse", + "status_refused": "Server keeldus sellest mustandist: {error}", + "file_missing": "Manustatud faili pole enam selles seadmes", + "open": "Ava", + "open_label": "Ava {subject}", + "retry": "Proovi uuesti", + "retry_label": "Proovi uuesti: {subject}", + "toast_refused": "Mustandit {subject} ei saanud serverisse salvestada. See jääb sellesse seadmesse.", + "toast_conflict": "Mustandit {subject} muudeti teises seadmes. Mõlemad versioonid jäävad alles.", + "conflict_changed": "Muudetud teises seadmes. Mõlemad versioonid on kaustas Mustandid.", + "conflict_gone": "Muudetud või eemaldatud teises seadmes. Teie versioon on kaustas Mustandid.", + "banner_refused": "Serveri tagasi lükatud mustandid: {count}", + "banner_waiting": "Serverit ootavad mustandid: {count}" + }, "deep_link": { "message_not_found": "See sõnum ei ole enam saadaval.", "thread_not_found": "See vestlus ei ole enam saadaval.", @@ -5934,6 +5954,10 @@ "title": "Täielik juurdepääs sellele rakendusele", "body": "Töötab samade õigustega kui veebimeil ise. See saab lugeda kogu sinu posti ja salvestatud võtmeid ning tegutseda sinu nimel. Luba see ainult pistikprogrammidele, mida täielikult usaldad." }, + "keystore": { + "title": "Kasuta selle arvuti sertifikaate", + "body": "Palub sõnumeid allkirjastada ja dekrüpteerida selle arvuti või kiipkaardi sertifikaatidega. Rakendus küsib teilt enne. Pistikprogramm ei saa kunagi privaatvõtit, kuid seni, kuni seda lubate, saab see teie nimel allkirjastada ja lugeda seda, mida dekrüpteerib." + }, "email_raw_send": { "title": "Saada ise koostatud kirju", "body": "Saata sinu kontolt täielikke kirju, näiteks allkirjastatud või krüpteeritud." @@ -6584,5 +6608,13 @@ "server_needed": "Sisesta kõigepealt serveri aadress.", "server_declined": "Sa ei lubanud seda serverit, nii et sellele ei saadetud midagi.", "server_invalid": "See serveri aadress ei kehti." + }, + "desktop_keystore": { + "title": "Selle arvuti sertifikaadid", + "description": "See server võib paluda teie sertifikaate ja kiipkaarte kasutada. Enne iga sertifikaadi kasutamist küsitakse teilt siiski luba.", + "label": "Selle serveri juurdepääs", + "label_description": "Võtke oma luba tagasi. Server peab siis uuesti küsima.", + "revoke": "Võta tagasi", + "revoked": "Luba võeti tagasi" } } diff --git a/locales/fa/common.json b/locales/fa/common.json index 69bcba248..cd75ac0f5 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5818,6 +5818,26 @@ "unpin": "دیگر آفلاین نگه داشته نشود", "unpin_label": "{name} دیگر آفلاین نگه داشته نشود" }, + "offlineDrafts": { + "title": "پیش‌نویس‌های این دستگاه", + "description": "این پیش‌نویس‌ها روی این دستگاه ذخیره شده‌اند. به‌محض دسترسی به سرور، خودکار به آن می‌روند.", + "saved_on_device": "روی این دستگاه ذخیره شد", + "status_waiting": "در انتظار سرور", + "status_sending": "در حال ذخیره در سرور", + "status_uncertain": "در حال بررسی رسیدن آن به سرور", + "status_refused": "سرور این پیش‌نویس را رد کرد: {error}", + "file_missing": "یک فایل پیوست دیگر روی این دستگاه نیست", + "open": "باز کردن", + "open_label": "باز کردن {subject}", + "retry": "تلاش دوباره", + "retry_label": "تلاش دوباره برای {subject}", + "toast_refused": "پیش‌نویس {subject} در سرور ذخیره نشد. روی این دستگاه می‌ماند.", + "toast_conflict": "پیش‌نویس {subject} در دستگاه دیگری تغییر کرد. هر دو نسخه نگه داشته می‌شوند.", + "conflict_changed": "در دستگاه دیگری تغییر کرد. هر دو نسخه در پیش‌نویس‌ها هستند.", + "conflict_gone": "در دستگاه دیگری تغییر کرد یا حذف شد. نسخه شما در پیش‌نویس‌ها است.", + "banner_refused": "پیش‌نویس‌های ردشده توسط سرور: {count}", + "banner_waiting": "پیش‌نویس‌های در انتظار سرور: {count}" + }, "deep_link": { "message_not_found": "این پیام دیگر در دسترس نیست.", "thread_not_found": "این گفتگو دیگر در دسترس نیست.", @@ -5934,6 +5954,10 @@ "title": "دسترسی کامل به این برنامه", "body": "با همان دسترسی‌های خود ایمیل تحت وب اجرا می‌شود. می‌تواند همه ایمیل‌ها و کلیدهای ذخیره‌شده شما را بخواند و از طرف شما عمل کند. فقط برای افزونه‌هایی که کاملاً به آن‌ها اعتماد دارید اجازه دهید." }, + "keystore": { + "title": "استفاده از گواهی‌های این رایانه", + "body": "درخواست امضا و رمزگشایی پیام‌ها با گواهی‌های این رایانه یا کارت هوشمند. برنامه ابتدا از شما می‌پرسد. افزونه هرگز کلید خصوصی را دریافت نمی‌کند، اما تا زمانی که اجازه دهید می‌تواند به نام شما امضا کند و آنچه را رمزگشایی می‌کند بخواند." + }, "email_raw_send": { "title": "ارسال پیام‌هایی که خودش ساخته است", "body": "ارسال پیام‌های کامل، مثلاً امضاشده یا رمزنگاری‌شده، از حساب شما." @@ -6584,5 +6608,13 @@ "server_needed": "ابتدا نشانی سرور را وارد کنید.", "server_declined": "شما این سرور را مجاز نکردید، پس چیزی به آن فرستاده نشد.", "server_invalid": "این نشانی سرور معتبر نیست." + }, + "desktop_keystore": { + "title": "گواهی‌های این رایانه", + "description": "این سرور می‌تواند برای استفاده از گواهی‌ها و کارت‌های هوشمند شما درخواست دهد. با این حال پیش از استفاده از هر گواهی از شما پرسیده می‌شود.", + "label": "دسترسی این سرور", + "label_description": "اجازهٔ خود را پس بگیرید. سرور باید دوباره درخواست کند.", + "revoke": "پس گرفتن", + "revoked": "اجازه پس گرفته شد" } } diff --git a/locales/fr/common.json b/locales/fr/common.json index 4fa2ab5f0..b7e97abce 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5818,6 +5818,26 @@ "unpin": "Ne plus garder hors ligne", "unpin_label": "Ne plus garder {name} hors ligne" }, + "offlineDrafts": { + "title": "Brouillons sur cet appareil", + "description": "Ces brouillons sont enregistrés sur cet appareil. Ils partent seuls vers le serveur dès qu'il est joignable.", + "saved_on_device": "Enregistré sur cet appareil", + "status_waiting": "En attente du serveur", + "status_sending": "Enregistrement sur le serveur", + "status_uncertain": "Vérification de son arrivée sur le serveur", + "status_refused": "Le serveur a refusé ce brouillon : {error}", + "file_missing": "Un fichier joint n'est plus sur cet appareil", + "open": "Ouvrir", + "open_label": "Ouvrir {subject}", + "retry": "Réessayer", + "retry_label": "Réessayer {subject}", + "toast_refused": "Le brouillon {subject} n'a pas pu être enregistré sur le serveur. Il reste sur cet appareil.", + "toast_conflict": "Le brouillon {subject} a été modifié sur un autre appareil. Les deux versions sont conservées.", + "conflict_changed": "Modifié sur un autre appareil. Les deux versions sont dans Brouillons.", + "conflict_gone": "Modifié ou supprimé sur un autre appareil. Votre version est dans Brouillons.", + "banner_refused": "Brouillons refusés par le serveur : {count}", + "banner_waiting": "Brouillons en attente du serveur : {count}" + }, "deep_link": { "message_not_found": "Ce message n'est plus disponible.", "thread_not_found": "Cette conversation n'est plus disponible.", @@ -5934,6 +5954,10 @@ "title": "Accès complet à cette application", "body": "S'exécute avec les mêmes droits que le webmail lui-même. Il peut lire tous vos e-mails et vos clés enregistrées, et agir en votre nom. N'autorisez ceci que pour des plugins en qui vous avez entièrement confiance." }, + "keystore": { + "title": "Utiliser les certificats de cet ordinateur", + "body": "Demande à signer et déchiffrer des messages avec les certificats de cet ordinateur ou d'une carte à puce. L'application vous pose d'abord la question. Le plugin ne reçoit jamais la clé privée, mais tant que vous l'autorisez, il peut signer en votre nom et lire ce qu'il déchiffre." + }, "email_raw_send": { "title": "Envoyer des messages qu'il a lui-même créés", "body": "Envoyer des messages complets, par exemple signés ou chiffrés, depuis votre compte." @@ -6584,5 +6608,13 @@ "server_needed": "Saisissez d'abord l'adresse du serveur.", "server_declined": "Vous n'avez pas autorisé ce serveur, donc rien ne lui a été envoyé.", "server_invalid": "Cette adresse de serveur n'est pas valide." + }, + "desktop_keystore": { + "title": "Certificats de cet ordinateur", + "description": "Ce serveur peut demander à utiliser vos certificats et cartes à puce. La question vous est quand même posée avant l'utilisation de chaque certificat.", + "label": "Accès de ce serveur", + "label_description": "Retirez votre autorisation. Le serveur devra la redemander.", + "revoke": "Retirer", + "revoked": "Autorisation retirée" } } diff --git a/locales/he/common.json b/locales/he/common.json index 9a404b7ec..d64d300fa 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5819,6 +5819,26 @@ "unpin": "הפסקת שמירה במצב לא מקוון", "unpin_label": "הפסקת שמירה של {name} במצב לא מקוון" }, + "offlineDrafts": { + "title": "טיוטות במכשיר זה", + "description": "הטיוטות האלה שמורות במכשיר זה. הן יעברו לשרת מעצמן ברגע שאפשר יהיה להגיע אליו.", + "saved_on_device": "נשמר במכשיר זה", + "status_waiting": "ממתין לשרת", + "status_sending": "שומר בשרת", + "status_uncertain": "בודק אם הגיע לשרת", + "status_refused": "השרת דחה את הטיוטה: {error}", + "file_missing": "קובץ מצורף כבר לא נמצא במכשיר זה", + "open": "פתיחה", + "open_label": "פתיחת {subject}", + "retry": "ניסיון חוזר", + "retry_label": "ניסיון חוזר עבור {subject}", + "toast_refused": "לא ניתן היה לשמור את הטיוטה {subject} בשרת. היא נשארת במכשיר זה.", + "toast_conflict": "הטיוטה {subject} שונתה במכשיר אחר. שתי הגרסאות נשמרות.", + "conflict_changed": "שונה במכשיר אחר. שתי הגרסאות נמצאות בטיוטות.", + "conflict_gone": "שונה או הוסר במכשיר אחר. הגרסה שלך נמצאת בטיוטות.", + "banner_refused": "טיוטות שהשרת דחה: {count}", + "banner_waiting": "טיוטות שממתינות לשרת: {count}" + }, "deep_link": { "message_not_found": "הודעה זו אינה זמינה עוד.", "thread_not_found": "שיחה זו אינה זמינה עוד.", @@ -5935,6 +5955,10 @@ "title": "גישה מלאה לאפליקציה הזו", "body": "פועל עם אותן הרשאות כמו ה-Webmail עצמו. הוא יכול לקרוא את כל הדואר והמפתחות השמורים שלכם ולפעול בשמכם. אפשרו זאת רק לתוספים שאתם סומכים עליהם לגמרי." }, + "keystore": { + "title": "שימוש בתעודות במחשב זה", + "body": "מבקש לחתום על הודעות ולפענח אותן באמצעות תעודות במחשב זה או בכרטיס חכם. האפליקציה שואלת אותך קודם. התוסף אף פעם לא מקבל את המפתח הפרטי, אבל כל עוד אתה מאפשר זאת הוא יכול לחתום בשמך ולקרוא את מה שהוא מפענח." + }, "email_raw_send": { "title": "שליחת הודעות שהוא בנה בעצמו", "body": "שליחת הודעות שלמות, למשל חתומות או מוצפנות, מהחשבון שלכם." @@ -6585,5 +6609,13 @@ "server_needed": "קודם צריך להזין את כתובת השרת.", "server_declined": "לא אישרת את השרת הזה, לכן לא נשלח אליו דבר.", "server_invalid": "כתובת השרת הזו לא תקינה." + }, + "desktop_keystore": { + "title": "תעודות במחשב זה", + "description": "שרת זה יכול לבקש להשתמש בתעודות ובכרטיסים החכמים שלך. עדיין תישאל לפני השימוש בכל תעודה.", + "label": "גישה לשרת זה", + "label_description": "בטל את ההרשאה שלך. השרת יצטרך לבקש שוב.", + "revoke": "ביטול", + "revoked": "ההרשאה בוטלה" } } diff --git a/locales/hu/common.json b/locales/hu/common.json index 799f05ae3..5d4a9dd70 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5818,6 +5818,26 @@ "unpin": "Megtartás offline megszüntetése", "unpin_label": "{name} offline megtartásának megszüntetése" }, + "offlineDrafts": { + "title": "Piszkozatok ezen az eszközön", + "description": "Ezek a piszkozatok ezen az eszközön vannak mentve. Maguktól a szerverre kerülnek, amint az elérhető.", + "saved_on_device": "Mentve ezen az eszközön", + "status_waiting": "Várakozás a szerverre", + "status_sending": "Mentés a szerverre", + "status_uncertain": "Annak ellenőrzése, hogy elérte-e a szervert", + "status_refused": "A szerver elutasította ezt a piszkozatot: {error}", + "file_missing": "Egy csatolt fájl már nincs ezen az eszközön", + "open": "Megnyitás", + "open_label": "{subject} megnyitása", + "retry": "Újra", + "retry_label": "{subject} újrapróbálása", + "toast_refused": "A(z) {subject} piszkozatot nem sikerült a szerverre menteni. Ezen az eszközön marad.", + "toast_conflict": "A(z) {subject} piszkozat egy másik eszközön módosult. Mindkét változat megmarad.", + "conflict_changed": "Másik eszközön módosult. Mindkét változat a Piszkozatok között van.", + "conflict_gone": "Másik eszközön módosult vagy törlődött. Az Ön változata a Piszkozatok között van.", + "banner_refused": "A szerver által elutasított piszkozatok: {count}", + "banner_waiting": "A szerverre váró piszkozatok: {count}" + }, "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.", @@ -5934,6 +5954,10 @@ "title": "Teljes hozzáférés az alkalmazáshoz", "body": "Ugyanazokkal a jogokkal fut, mint maga a webmail. Olvashatja az összes leveledet és tárolt kulcsodat, és a nevedben járhat el. Csak olyan bővítménynek engedélyezd, amelyben teljesen megbízol." }, + "keystore": { + "title": "A számítógépen lévő tanúsítványok használata", + "body": "Kéri, hogy üzeneteket írhasson alá és fejthessen vissza a számítógépen vagy intelligens kártyán lévő tanúsítványokkal. Az alkalmazás előbb megkérdezi Önt. A bővítmény soha nem kapja meg a titkos kulcsot, de amíg engedélyezi, az Ön nevében írhat alá, és elolvashatja, amit visszafejt." + }, "email_raw_send": { "title": "Saját maga által összeállított üzenetek küldése", "body": "Teljes üzeneteket küld a fiókodból, például aláírt vagy titkosított üzeneteket." @@ -6584,5 +6608,13 @@ "server_needed": "Először adja meg a szerver címét.", "server_declined": "Nem engedélyezte ezt a szervert, ezért semmit sem küldtünk neki.", "server_invalid": "Ez a szervercím nem érvényes." + }, + "desktop_keystore": { + "title": "A számítógépen lévő tanúsítványok", + "description": "Ez a kiszolgáló kérheti a tanúsítványai és intelligens kártyái használatát. Minden tanúsítvány használata előtt így is megkérdezzük.", + "label": "A kiszolgáló hozzáférése", + "label_description": "Vonja vissza az engedélyét. A kiszolgálónak újra kérnie kell.", + "revoke": "Visszavonás", + "revoked": "Engedély visszavonva" } } diff --git a/locales/it/common.json b/locales/it/common.json index 509b08082..58ba03188 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5818,6 +5818,26 @@ "unpin": "Non conservare più offline", "unpin_label": "Non conservare più {name} offline" }, + "offlineDrafts": { + "title": "Bozze su questo dispositivo", + "description": "Queste bozze sono salvate su questo dispositivo. Passano da sole al server appena è raggiungibile.", + "saved_on_device": "Salvata su questo dispositivo", + "status_waiting": "In attesa del server", + "status_sending": "Salvataggio sul server", + "status_uncertain": "Verifica dell'arrivo sul server", + "status_refused": "Il server ha rifiutato questa bozza: {error}", + "file_missing": "Un file allegato non è più su questo dispositivo", + "open": "Apri", + "open_label": "Apri {subject}", + "retry": "Riprova", + "retry_label": "Riprova {subject}", + "toast_refused": "Non è stato possibile salvare la bozza {subject} sul server. Resta su questo dispositivo.", + "toast_conflict": "La bozza {subject} è stata modificata su un altro dispositivo. Vengono conservate entrambe le versioni.", + "conflict_changed": "Modificata su un altro dispositivo. Entrambe le versioni sono in Bozze.", + "conflict_gone": "Modificata o rimossa su un altro dispositivo. La tua versione è in Bozze.", + "banner_refused": "Bozze rifiutate dal server: {count}", + "banner_waiting": "Bozze in attesa del server: {count}" + }, "deep_link": { "message_not_found": "Questo messaggio non è più disponibile.", "thread_not_found": "Questa conversazione non è più disponibile.", @@ -5934,6 +5954,10 @@ "title": "Accesso completo a questa app", "body": "Funziona con gli stessi diritti della webmail stessa. Può leggere tutta la tua posta e le chiavi salvate e agire per conto tuo. Consentilo solo ai plugin di cui ti fidi completamente." }, + "keystore": { + "title": "Usare i certificati di questo computer", + "body": "Chiede di firmare e decifrare i messaggi con i certificati di questo computer o di una smart card. L'app te lo chiede prima. Il plugin non riceve mai la chiave privata, ma finché lo consenti può firmare a tuo nome e leggere ciò che decifra." + }, "email_raw_send": { "title": "Inviare messaggi creati da sé", "body": "Inviare messaggi completi, ad esempio firmati o cifrati, dal tuo account." @@ -6584,5 +6608,13 @@ "server_needed": "Inserisci prima l'indirizzo del server.", "server_declined": "Non hai autorizzato questo server, quindi non gli è stato inviato nulla.", "server_invalid": "Questo indirizzo del server non è valido." + }, + "desktop_keystore": { + "title": "Certificati di questo computer", + "description": "Questo server può chiedere di usare i tuoi certificati e le tue smart card. Ti verrà comunque chiesto prima di usare ogni certificato.", + "label": "Accesso di questo server", + "label_description": "Ritira il tuo permesso. Il server dovrà chiederlo di nuovo.", + "revoke": "Ritira", + "revoked": "Permesso ritirato" } } diff --git a/locales/ja/common.json b/locales/ja/common.json index 69a3faa56..3532ea448 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5818,6 +5818,26 @@ "unpin": "オフライン保存をやめる", "unpin_label": "{name} のオフライン保存をやめる" }, + "offlineDrafts": { + "title": "このデバイスの下書き", + "description": "これらの下書きはこのデバイスに保存されています。サーバーに接続できるようになると自動で送られます。", + "saved_on_device": "このデバイスに保存しました", + "status_waiting": "サーバーを待っています", + "status_sending": "サーバーに保存しています", + "status_uncertain": "サーバーに届いたか確認しています", + "status_refused": "サーバーがこの下書きを拒否しました: {error}", + "file_missing": "添付ファイルがこのデバイスにありません", + "open": "開く", + "open_label": "{subject} を開く", + "retry": "再試行", + "retry_label": "{subject} を再試行", + "toast_refused": "下書き {subject} をサーバーに保存できませんでした。このデバイスに残ります。", + "toast_conflict": "下書き {subject} は別のデバイスで変更されました。両方のバージョンを残します。", + "conflict_changed": "別のデバイスで変更されました。両方のバージョンが下書きにあります。", + "conflict_gone": "別のデバイスで変更または削除されました。あなたのバージョンは下書きにあります。", + "banner_refused": "サーバーが拒否した下書き: {count}", + "banner_waiting": "サーバーを待っている下書き: {count}" + }, "deep_link": { "message_not_found": "このメッセージは利用できなくなりました。", "thread_not_found": "この会話は利用できなくなりました。", @@ -5934,6 +5954,10 @@ "title": "このアプリへのフルアクセス", "body": "ウェブメール本体と同じ権限で動作します。すべてのメールと保存された鍵を読み、あなたに代わって操作できます。完全に信頼できるプラグインにのみ許可してください。" }, + "keystore": { + "title": "このコンピューターの証明書を使用", + "body": "このコンピューターまたはスマートカードの証明書でメッセージの署名と復号を行うよう求めます。アプリが先に確認します。プラグインが秘密鍵を受け取ることはありませんが、許可している間はあなたの名前で署名し、復号した内容を読むことができます。" + }, "email_raw_send": { "title": "自分で作成したメッセージを送信", "body": "署名済みや暗号化済みのメッセージなど、完成したメッセージをあなたのアカウントから送信できます。" @@ -6584,5 +6608,13 @@ "server_needed": "先にサーバーのアドレスを入力してください。", "server_declined": "このサーバーを許可しなかったため、何も送信していません。", "server_invalid": "このサーバーアドレスは正しくありません。" + }, + "desktop_keystore": { + "title": "このコンピューターの証明書", + "description": "このサーバーは、証明書とスマートカードの使用を求めることができます。各証明書を使用する前には、引き続き確認されます。", + "label": "このサーバーのアクセス", + "label_description": "許可を取り消します。サーバーは再度確認する必要があります。", + "revoke": "取り消す", + "revoked": "許可を取り消しました" } } diff --git a/locales/ko/common.json b/locales/ko/common.json index 130320758..4b48fc595 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5818,6 +5818,26 @@ "unpin": "오프라인 보관 중지", "unpin_label": "{name} 오프라인 보관 중지" }, + "offlineDrafts": { + "title": "이 기기의 임시 보관함", + "description": "이 임시 보관 메일은 이 기기에 저장되어 있습니다. 서버에 연결되면 자동으로 올라갑니다.", + "saved_on_device": "이 기기에 저장됨", + "status_waiting": "서버 기다리는 중", + "status_sending": "서버에 저장하는 중", + "status_uncertain": "서버에 도착했는지 확인하는 중", + "status_refused": "서버가 이 임시 보관 메일을 거부했습니다: {error}", + "file_missing": "첨부 파일이 더 이상 이 기기에 없습니다", + "open": "열기", + "open_label": "{subject} 열기", + "retry": "다시 시도", + "retry_label": "{subject} 다시 시도", + "toast_refused": "임시 보관 메일 {subject}을(를) 서버에 저장하지 못했습니다. 이 기기에 남아 있습니다.", + "toast_conflict": "임시 보관 메일 {subject}이(가) 다른 기기에서 변경되었습니다. 두 버전을 모두 보관합니다.", + "conflict_changed": "다른 기기에서 변경되었습니다. 두 버전 모두 임시 보관함에 있습니다.", + "conflict_gone": "다른 기기에서 변경되었거나 삭제되었습니다. 내 버전은 임시 보관함에 있습니다.", + "banner_refused": "서버가 거부한 임시 보관 메일: {count}", + "banner_waiting": "서버를 기다리는 임시 보관 메일: {count}" + }, "deep_link": { "message_not_found": "이 메시지를 더 이상 사용할 수 없습니다.", "thread_not_found": "이 대화를 더 이상 사용할 수 없습니다.", @@ -5934,6 +5954,10 @@ "title": "이 앱에 대한 전체 접근 권한", "body": "웹메일과 같은 권한으로 실행돼요. 모든 메일과 저장된 키를 읽고 사용자를 대신해 작업할 수 있어요. 완전히 신뢰하는 플러그인에만 허용하세요." }, + "keystore": { + "title": "이 컴퓨터의 인증서 사용", + "body": "이 컴퓨터나 스마트 카드의 인증서로 메시지 서명과 복호화를 요청합니다. 앱이 먼저 사용자에게 묻습니다. 플러그인은 개인 키를 받지 않지만, 허용하는 동안에는 사용자 이름으로 서명하고 복호화한 내용을 읽을 수 있습니다." + }, "email_raw_send": { "title": "직접 만든 메시지 보내기", "body": "서명되거나 암호화된 메시지 같은 완성된 메시지를 내 계정에서 보낼 수 있어요." @@ -6584,5 +6608,13 @@ "server_needed": "먼저 서버 주소를 입력하세요.", "server_declined": "이 서버를 허용하지 않아 아무것도 보내지 않았습니다.", "server_invalid": "이 서버 주소는 올바르지 않습니다." + }, + "desktop_keystore": { + "title": "이 컴퓨터의 인증서", + "description": "이 서버는 인증서와 스마트 카드 사용을 요청할 수 있습니다. 각 인증서를 사용하기 전에는 여전히 사용자에게 묻습니다.", + "label": "이 서버의 액세스", + "label_description": "허용을 취소합니다. 서버는 다시 요청해야 합니다.", + "revoke": "취소", + "revoked": "허용을 취소했습니다" } } diff --git a/locales/lv/common.json b/locales/lv/common.json index d19629112..d9aa9961a 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5818,6 +5818,26 @@ "unpin": "Pārtraukt glabāt bezsaistē", "unpin_label": "Pārtraukt glabāt {name} bezsaistē" }, + "offlineDrafts": { + "title": "Melnraksti šajā ierīcē", + "description": "Šie melnraksti ir saglabāti šajā ierīcē. Tie paši nonāks serverī, tiklīdz tas būs sasniedzams.", + "saved_on_device": "Saglabāts šajā ierīcē", + "status_waiting": "Gaida serveri", + "status_sending": "Saglabā serverī", + "status_uncertain": "Pārbauda, vai tas sasniedza serveri", + "status_refused": "Serveris noraidīja šo melnrakstu: {error}", + "file_missing": "Pievienotais fails vairs nav šajā ierīcē", + "open": "Atvērt", + "open_label": "Atvērt {subject}", + "retry": "Mēģināt vēlreiz", + "retry_label": "Mēģināt vēlreiz: {subject}", + "toast_refused": "Melnrakstu {subject} neizdevās saglabāt serverī. Tas paliek šajā ierīcē.", + "toast_conflict": "Melnraksts {subject} tika mainīts citā ierīcē. Abas versijas tiek saglabātas.", + "conflict_changed": "Mainīts citā ierīcē. Abas versijas ir mapē Melnraksti.", + "conflict_gone": "Mainīts vai noņemts citā ierīcē. Jūsu versija ir mapē Melnraksti.", + "banner_refused": "Servera noraidītie melnraksti: {count}", + "banner_waiting": "Melnraksti, kas gaida serveri: {count}" + }, "deep_link": { "message_not_found": "Šī ziņa vairs nav pieejama.", "thread_not_found": "Šī sarakste vairs nav pieejama.", @@ -5934,6 +5954,10 @@ "title": "Pilna piekļuve lietotnei", "body": "Darbojas ar tādām pašām tiesībām kā pats webmail. Var lasīt visu jūsu pastu un saglabātās atslēgas un rīkoties jūsu vārdā. Atļaujiet to tikai spraudņiem, kuriem pilnībā uzticaties." }, + "keystore": { + "title": "Izmantot šī datora sertifikātus", + "body": "Lūdz parakstīt un atšifrēt ziņojumus ar šī datora vai viedkartes sertifikātiem. Lietotne vispirms jautā jums. Spraudnis nekad nesaņem privāto atslēgu, taču, kamēr to atļaujat, tas var parakstīt jūsu vārdā un lasīt to, ko atšifrē." + }, "email_raw_send": { "title": "Sūtīt paša izveidotas vēstules", "body": "Sūtīt no jūsu konta pilnīgas vēstules, piemēram, parakstītas vai šifrētas." @@ -6584,5 +6608,13 @@ "server_needed": "Vispirms ievadiet servera adresi.", "server_declined": "Jūs neatļāvāt šo serveri, tāpēc tam nekas netika nosūtīts.", "server_invalid": "Šī servera adrese nav derīga." + }, + "desktop_keystore": { + "title": "Šī datora sertifikāti", + "description": "Šis serveris var lūgt izmantot jūsu sertifikātus un viedkartes. Pirms katra sertifikāta izmantošanas jums tomēr tiks jautāts.", + "label": "Šī servera piekļuve", + "label_description": "Atsauciet savu atļauju. Serverim būs jālūdz vēlreiz.", + "revoke": "Atsaukt", + "revoked": "Atļauja atsaukta" } } diff --git a/locales/mn/common.json b/locales/mn/common.json index 4ae828b62..b0e0847f8 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5818,6 +5818,26 @@ "unpin": "Офлайн хадгалахаа болих", "unpin_label": "{name}-г офлайн хадгалахаа болих" }, + "offlineDrafts": { + "title": "Энэ төхөөрөмж дээрх ноорог", + "description": "Эдгээр ноорог энэ төхөөрөмж дээр хадгалагдсан. Сервертэй холбогдмогц өөрсдөө илгээгдэнэ.", + "saved_on_device": "Энэ төхөөрөмж дээр хадгалагдсан", + "status_waiting": "Серверийг хүлээж байна", + "status_sending": "Сервер дээр хадгалж байна", + "status_uncertain": "Сервертэй хүрсэн эсэхийг шалгаж байна", + "status_refused": "Сервер энэ ноорогийг татгалзлаа: {error}", + "file_missing": "Хавсаргасан файл энэ төхөөрөмж дээр байхгүй болсон", + "open": "Нээх", + "open_label": "{subject}-г нээх", + "retry": "Дахин оролдох", + "retry_label": "{subject}-г дахин оролдох", + "toast_refused": "{subject} ноорогийг сервер дээр хадгалж чадсангүй. Энэ төхөөрөмж дээр үлдэнэ.", + "toast_conflict": "{subject} ноорог өөр төхөөрөмж дээр өөрчлөгдсөн. Хоёр хувилбарыг хоёуланг нь хадгална.", + "conflict_changed": "Өөр төхөөрөмж дээр өөрчлөгдсөн. Хоёр хувилбар хоёулаа Ноорог хавтсанд байна.", + "conflict_gone": "Өөр төхөөрөмж дээр өөрчлөгдсөн эсвэл устгагдсан. Таны хувилбар Ноорог хавтсанд байна.", + "banner_refused": "Серверийн татгалзсан ноорог: {count}", + "banner_waiting": "Серверийг хүлээж буй ноорог: {count}" + }, "deep_link": { "message_not_found": "Энэ зурвас дахин ашиглах боломжгүй болсон.", "thread_not_found": "Энэ харилцан яриа дахин ашиглах боломжгүй болсон.", @@ -5934,6 +5954,10 @@ "title": "Аппад бүрэн хандах", "body": "Webmail-тэй адил эрхээр ажиллана. Таны бүх имэйл болон хадгалсан түлхүүрүүдийг уншиж, таны нэрийн өмнөөс үйлдэл хийж чадна. Зөвхөн бүрэн итгэдэг залгаасдаа үүнийг зөвшөөрнө үү." }, + "keystore": { + "title": "Энэ компьютер дээрх гэрчилгээг ашиглах", + "body": "Энэ компьютер эсвэл ухаалаг картан дээрх гэрчилгээгээр мессежид гарын үсэг зурах, тайлахыг хүснэ. Апп эхлээд танаас асууна. Өргөтгөл хувийн түлхүүрийг хэзээ ч авахгүй, гэхдээ таныг зөвшөөрч байх хугацаанд таны нэрээр гарын үсэг зурж, тайлсан зүйлээ уншиж чадна." + }, "email_raw_send": { "title": "Өөрөө бэлтгэсэн имэйл илгээх", "body": "Таны бүртгэлээс бүрэн бэлэн имэйл, жишээ нь гарын үсэг зурсан эсвэл шифрлэсэн имэйл илгээх." @@ -6584,5 +6608,13 @@ "server_needed": "Эхлээд серверийн хаягаа оруулна уу.", "server_declined": "Та энэ серверийг зөвшөөрөөгүй тул юу ч илгээгээгүй.", "server_invalid": "Энэ серверийн хаяг буруу байна." + }, + "desktop_keystore": { + "title": "Энэ компьютер дээрх гэрчилгээ", + "description": "Энэ сервер таны гэрчилгээ, ухаалаг картыг ашиглахыг хүсэж болно. Гэрчилгээ бүрийг ашиглахын өмнө танаас асуусаар байх болно.", + "label": "Энэ серверийн хандалт", + "label_description": "Зөвшөөрлөө буцааж авна уу. Сервер дахин асуух шаардлагатай.", + "revoke": "Буцааж авах", + "revoked": "Зөвшөөрлийг буцааж авлаа" } } diff --git a/locales/nb/common.json b/locales/nb/common.json index 540dc8bd6..2c62bd288 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5818,6 +5818,26 @@ "unpin": "Slutt å beholde frakoblet", "unpin_label": "Slutt å beholde {name} frakoblet" }, + "offlineDrafts": { + "title": "Utkast på denne enheten", + "description": "Disse utkastene er lagret på denne enheten. De går til serveren av seg selv så snart den kan nås.", + "saved_on_device": "Lagret på denne enheten", + "status_waiting": "Venter på serveren", + "status_sending": "Lagrer på serveren", + "status_uncertain": "Sjekker om det nådde serveren", + "status_refused": "Serveren avviste dette utkastet: {error}", + "file_missing": "En vedlagt fil er ikke lenger på denne enheten", + "open": "Åpne", + "open_label": "Åpne {subject}", + "retry": "Prøv igjen", + "retry_label": "Prøv {subject} igjen", + "toast_refused": "Utkastet {subject} kunne ikke lagres på serveren. Det blir på denne enheten.", + "toast_conflict": "Utkastet {subject} ble endret på en annen enhet. Begge versjonene beholdes.", + "conflict_changed": "Endret på en annen enhet. Begge versjonene ligger i Utkast.", + "conflict_gone": "Endret eller fjernet på en annen enhet. Din versjon ligger i Utkast.", + "banner_refused": "Utkast avvist av serveren: {count}", + "banner_waiting": "Utkast som venter på serveren: {count}" + }, "deep_link": { "message_not_found": "Denne meldingen er ikke lenger tilgjengelig.", "thread_not_found": "Denne samtalen er ikke lenger tilgjengelig.", @@ -5934,6 +5954,10 @@ "title": "Full tilgang til denne appen", "body": "Kjører med de samme rettighetene som selve webmailen. Det kan lese all e-posten din og lagrede nøkler og handle på dine vegne. Tillat dette bare for tillegg du stoler fullt på." }, + "keystore": { + "title": "Bruk sertifikater på denne datamaskinen", + "body": "Ber om å signere og dekryptere meldinger med sertifikater på denne datamaskinen eller et smartkort. Appen spør deg først. Tillegget får aldri den private nøkkelen, men så lenge du tillater det, kan det signere i ditt navn og lese det det dekrypterer." + }, "email_raw_send": { "title": "Sende meldinger det har laget selv", "body": "Sende ferdige meldinger, for eksempel signerte eller krypterte, fra kontoen din." @@ -6584,5 +6608,13 @@ "server_needed": "Skriv inn serveradressen først.", "server_declined": "Du tillot ikke denne serveren, så ingenting ble sendt til den.", "server_invalid": "Denne serveradressen er ikke gyldig." + }, + "desktop_keystore": { + "title": "Sertifikater på denne datamaskinen", + "description": "Denne serveren kan be om å bruke sertifikatene og smartkortene dine. Du blir likevel spurt før hvert sertifikat brukes.", + "label": "Tilgang for denne serveren", + "label_description": "Trekk tilbake tillatelsen din. Serveren må da spørre på nytt.", + "revoke": "Trekk tilbake", + "revoked": "Tillatelsen er trukket tilbake" } } diff --git a/locales/nl/common.json b/locales/nl/common.json index 375563100..7cb5b803e 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5818,6 +5818,26 @@ "unpin": "Niet meer offline bewaren", "unpin_label": "{name} niet meer offline bewaren" }, + "offlineDrafts": { + "title": "Concepten op dit apparaat", + "description": "Deze concepten zijn op dit apparaat opgeslagen. Ze gaan vanzelf naar de server zodra die bereikbaar is.", + "saved_on_device": "Opgeslagen op dit apparaat", + "status_waiting": "Wacht op de server", + "status_sending": "Wordt op de server opgeslagen", + "status_uncertain": "Controleren of het de server heeft bereikt", + "status_refused": "De server heeft dit concept geweigerd: {error}", + "file_missing": "Een bijgevoegd bestand staat niet meer op dit apparaat", + "open": "Openen", + "open_label": "{subject} openen", + "retry": "Opnieuw proberen", + "retry_label": "{subject} opnieuw proberen", + "toast_refused": "Het concept {subject} kon niet op de server worden opgeslagen. Het blijft op dit apparaat.", + "toast_conflict": "Het concept {subject} is op een ander apparaat gewijzigd. Beide versies blijven bewaard.", + "conflict_changed": "Gewijzigd op een ander apparaat. Beide versies staan in Concepten.", + "conflict_gone": "Gewijzigd of verwijderd op een ander apparaat. Uw versie staat in Concepten.", + "banner_refused": "Door de server geweigerde concepten: {count}", + "banner_waiting": "Concepten die op de server wachten: {count}" + }, "deep_link": { "message_not_found": "Dit bericht is niet meer beschikbaar.", "thread_not_found": "Dit gesprek is niet meer beschikbaar.", @@ -5934,6 +5954,10 @@ "title": "Volledige toegang tot deze app", "body": "Draait met dezelfde rechten als de webmail zelf. Hij kan al je e-mail en opgeslagen sleutels lezen en namens jou handelen. Sta dit alleen toe voor plug-ins die je volledig vertrouwt." }, + "keystore": { + "title": "Certificaten op deze computer gebruiken", + "body": "Vraagt om berichten te ondertekenen en te ontsleutelen met certificaten op deze computer of een smartcard. De app vraagt het u eerst. De plug-in krijgt nooit de privésleutel, maar zolang u het toestaat, kan hij in uw naam ondertekenen en lezen wat hij ontsleutelt." + }, "email_raw_send": { "title": "Zelf samengestelde berichten verzenden", "body": "Volledige berichten, bijvoorbeeld ondertekende of versleutelde, vanaf je account verzenden." @@ -6584,5 +6608,13 @@ "server_needed": "Vul eerst het adres van de server in.", "server_declined": "Je hebt deze server niet toegestaan, dus er is niets naartoe gestuurd.", "server_invalid": "Dit serveradres is niet geldig." + }, + "desktop_keystore": { + "title": "Certificaten op deze computer", + "description": "Deze server kan vragen om uw certificaten en smartcards te gebruiken. U krijgt nog steeds een vraag voordat een certificaat wordt gebruikt.", + "label": "Toegang voor deze server", + "label_description": "Trek uw toestemming in. De server moet dan opnieuw vragen.", + "revoke": "Intrekken", + "revoked": "Toestemming ingetrokken" } } diff --git a/locales/pl/common.json b/locales/pl/common.json index 1210cbbeb..a9a82b95c 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5818,6 +5818,26 @@ "unpin": "Nie przechowuj już offline", "unpin_label": "Nie przechowuj już {name} offline" }, + "offlineDrafts": { + "title": "Wersje robocze na tym urządzeniu", + "description": "Te wersje robocze są zapisane na tym urządzeniu. Trafią na serwer same, gdy tylko będzie dostępny.", + "saved_on_device": "Zapisano na tym urządzeniu", + "status_waiting": "Czeka na serwer", + "status_sending": "Zapisywanie na serwerze", + "status_uncertain": "Sprawdzanie, czy dotarła na serwer", + "status_refused": "Serwer odrzucił tę wersję roboczą: {error}", + "file_missing": "Załączonego pliku nie ma już na tym urządzeniu", + "open": "Otwórz", + "open_label": "Otwórz {subject}", + "retry": "Spróbuj ponownie", + "retry_label": "Spróbuj ponownie: {subject}", + "toast_refused": "Nie udało się zapisać wersji roboczej {subject} na serwerze. Zostaje na tym urządzeniu.", + "toast_conflict": "Wersja robocza {subject} została zmieniona na innym urządzeniu. Obie wersje zostają zachowane.", + "conflict_changed": "Zmieniono na innym urządzeniu. Obie wersje są w Wersjach roboczych.", + "conflict_gone": "Zmieniono lub usunięto na innym urządzeniu. Twoja wersja jest w Wersjach roboczych.", + "banner_refused": "Wersje robocze odrzucone przez serwer: {count}", + "banner_waiting": "Wersje robocze czekające na serwer: {count}" + }, "deep_link": { "message_not_found": "Ta wiadomość nie jest już dostępna.", "thread_not_found": "Ta rozmowa nie jest już dostępna.", @@ -5934,6 +5954,10 @@ "title": "Pełny dostęp do aplikacji", "body": "Działa z tymi samymi uprawnieniami co sam webmail. Może czytać całą Twoją pocztę i zapisane klucze oraz działać w Twoim imieniu. Zezwalaj na to tylko wtyczkom, którym w pełni ufasz." }, + "keystore": { + "title": "Używanie certyfikatów z tego komputera", + "body": "Prosi o podpisywanie i odszyfrowywanie wiadomości certyfikatami z tego komputera lub karty inteligentnej. Aplikacja najpierw zapyta. Wtyczka nigdy nie dostaje klucza prywatnego, ale dopóki na to pozwalasz, może podpisywać w Twoim imieniu i czytać to, co odszyfruje." + }, "email_raw_send": { "title": "Wysyłanie wiadomości utworzonych przez wtyczkę", "body": "Wysyła z Twojego konta kompletne wiadomości, na przykład podpisane lub zaszyfrowane." @@ -6584,5 +6608,13 @@ "server_needed": "Najpierw wpisz adres serwera.", "server_declined": "Ten serwer nie został dozwolony, więc nic do niego nie wysłano.", "server_invalid": "Ten adres serwera jest nieprawidłowy." + }, + "desktop_keystore": { + "title": "Certyfikaty na tym komputerze", + "description": "Ten serwer może prosić o użycie Twoich certyfikatów i kart inteligentnych. Przed użyciem każdego certyfikatu nadal zostaniesz zapytany.", + "label": "Dostęp tego serwera", + "label_description": "Cofnij swoją zgodę. Serwer będzie musiał zapytać ponownie.", + "revoke": "Cofnij", + "revoked": "Zgoda została cofnięta" } } diff --git a/locales/pt/common.json b/locales/pt/common.json index a1a62277d..8eaa09aba 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5818,6 +5818,26 @@ "unpin": "Deixar de manter offline", "unpin_label": "Deixar de manter {name} offline" }, + "offlineDrafts": { + "title": "Rascunhos neste dispositivo", + "description": "Estes rascunhos estão guardados neste dispositivo. Vão para o servidor por si assim que for possível contactá-lo.", + "saved_on_device": "Guardado neste dispositivo", + "status_waiting": "A aguardar o servidor", + "status_sending": "A guardar no servidor", + "status_uncertain": "A verificar se chegou ao servidor", + "status_refused": "O servidor recusou este rascunho: {error}", + "file_missing": "Um ficheiro anexado já não está neste dispositivo", + "open": "Abrir", + "open_label": "Abrir {subject}", + "retry": "Tentar novamente", + "retry_label": "Tentar {subject} novamente", + "toast_refused": "Não foi possível guardar o rascunho {subject} no servidor. Fica neste dispositivo.", + "toast_conflict": "O rascunho {subject} foi alterado noutro dispositivo. As duas versões são mantidas.", + "conflict_changed": "Alterado noutro dispositivo. As duas versões estão em Rascunhos.", + "conflict_gone": "Alterado ou removido noutro dispositivo. A sua versão está em Rascunhos.", + "banner_refused": "Rascunhos recusados pelo servidor: {count}", + "banner_waiting": "Rascunhos a aguardar o servidor: {count}" + }, "deep_link": { "message_not_found": "Esta mensagem não está mais disponível.", "thread_not_found": "Esta conversa não está mais disponível.", @@ -5934,6 +5954,10 @@ "title": "Acesso total a este aplicativo", "body": "É executado com os mesmos direitos do próprio webmail. Pode ler todos os seus e-mails e chaves armazenadas e agir em seu nome. Só permita isto para plugins em que você confia totalmente." }, + "keystore": { + "title": "Usar certificados deste computador", + "body": "Pede para assinar e desencriptar mensagens com certificados deste computador ou de um cartão inteligente. A aplicação pergunta-lhe primeiro. O plugin nunca recebe a chave privada, mas enquanto o permitir pode assinar em seu nome e ler o que desencripta." + }, "email_raw_send": { "title": "Enviar mensagens criadas por ele mesmo", "body": "Enviar mensagens completas, por exemplo assinadas ou criptografadas, a partir da sua conta." @@ -6584,5 +6608,13 @@ "server_needed": "Digite primeiro o endereço do servidor.", "server_declined": "Você não permitiu este servidor, então nada foi enviado a ele.", "server_invalid": "Este endereço de servidor não é válido." + }, + "desktop_keystore": { + "title": "Certificados deste computador", + "description": "Este servidor pode pedir para usar os seus certificados e cartões inteligentes. Mesmo assim, ser-lhe-á perguntado antes de cada certificado ser usado.", + "label": "Acesso deste servidor", + "label_description": "Retire a sua permissão. O servidor terá de pedir novamente.", + "revoke": "Retirar", + "revoked": "Permissão retirada" } } diff --git a/locales/ro/common.json b/locales/ro/common.json index adb7069f6..a50a742c9 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5818,6 +5818,26 @@ "unpin": "Nu mai păstra offline", "unpin_label": "Nu mai păstra {name} offline" }, + "offlineDrafts": { + "title": "Ciorne pe acest dispozitiv", + "description": "Aceste ciorne sunt salvate pe acest dispozitiv. Ajung singure pe server imediat ce poate fi contactat.", + "saved_on_device": "Salvat pe acest dispozitiv", + "status_waiting": "Se așteaptă serverul", + "status_sending": "Se salvează pe server", + "status_uncertain": "Se verifică dacă a ajuns pe server", + "status_refused": "Serverul a refuzat această ciornă: {error}", + "file_missing": "Un fișier atașat nu mai este pe acest dispozitiv", + "open": "Deschide", + "open_label": "Deschide {subject}", + "retry": "Încearcă din nou", + "retry_label": "Încearcă din nou: {subject}", + "toast_refused": "Ciorna {subject} nu a putut fi salvată pe server. Rămâne pe acest dispozitiv.", + "toast_conflict": "Ciorna {subject} a fost modificată pe alt dispozitiv. Ambele versiuni sunt păstrate.", + "conflict_changed": "Modificată pe alt dispozitiv. Ambele versiuni sunt în Ciorne.", + "conflict_gone": "Modificată sau eliminată pe alt dispozitiv. Versiunea dvs. este în Ciorne.", + "banner_refused": "Ciorne refuzate de server: {count}", + "banner_waiting": "Ciorne care așteaptă serverul: {count}" + }, "deep_link": { "message_not_found": "Acest mesaj nu mai este disponibil.", "thread_not_found": "Această conversație nu mai este disponibilă.", @@ -5934,6 +5954,10 @@ "title": "Acces complet la această aplicație", "body": "Rulează cu aceleași drepturi ca webmailul însuși. Poate citi toate e-mailurile și cheile stocate și poate acționa în numele dvs. Permiteți acest lucru doar pluginurilor în care aveți încredere deplină." }, + "keystore": { + "title": "Folosește certificatele de pe acest computer", + "body": "Cere să semneze și să decripteze mesaje cu certificate de pe acest computer sau de pe un smart card. Aplicația vă întreabă mai întâi. Pluginul nu primește niciodată cheia privată, dar cât timp permiteți poate semna în numele dvs. și poate citi ce decriptează." + }, "email_raw_send": { "title": "Trimiterea mesajelor create de el însuși", "body": "Trimite mesaje complete, de exemplu semnate sau criptate, din contul dvs." @@ -6584,5 +6608,13 @@ "server_needed": "Introdu mai întâi adresa serverului.", "server_declined": "Nu ai permis acest server, așa că nu i s-a trimis nimic.", "server_invalid": "Această adresă de server nu este validă." + }, + "desktop_keystore": { + "title": "Certificate de pe acest computer", + "description": "Acest server poate cere să vă folosească certificatele și smart cardurile. Veți fi întrebat în continuare înainte de folosirea fiecărui certificat.", + "label": "Accesul acestui server", + "label_description": "Retrageți permisiunea. Serverul va trebui să ceară din nou.", + "revoke": "Retrage", + "revoked": "Permisiune retrasă" } } diff --git a/locales/ru/common.json b/locales/ru/common.json index 3f3b0c228..a9da6db0b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5818,6 +5818,26 @@ "unpin": "Больше не хранить без сети", "unpin_label": "Больше не хранить {name} без сети" }, + "offlineDrafts": { + "title": "Черновики на этом устройстве", + "description": "Эти черновики сохранены на этом устройстве. Они сами уйдут на сервер, как только он станет доступен.", + "saved_on_device": "Сохранено на этом устройстве", + "status_waiting": "Ожидание сервера", + "status_sending": "Сохранение на сервере", + "status_uncertain": "Проверка, дошёл ли он до сервера", + "status_refused": "Сервер отклонил этот черновик: {error}", + "file_missing": "Вложенного файла больше нет на этом устройстве", + "open": "Открыть", + "open_label": "Открыть {subject}", + "retry": "Повторить", + "retry_label": "Повторить: {subject}", + "toast_refused": "Черновик {subject} не удалось сохранить на сервере. Он остаётся на этом устройстве.", + "toast_conflict": "Черновик {subject} изменён на другом устройстве. Сохранены обе версии.", + "conflict_changed": "Изменён на другом устройстве. Обе версии находятся в черновиках.", + "conflict_gone": "Изменён или удалён на другом устройстве. Ваша версия находится в черновиках.", + "banner_refused": "Черновики, отклонённые сервером: {count}", + "banner_waiting": "Черновики, ожидающие сервер: {count}" + }, "deep_link": { "message_not_found": "Это письмо больше недоступно.", "thread_not_found": "Эта переписка больше недоступна.", @@ -5934,6 +5954,10 @@ "title": "Полный доступ к приложению", "body": "Работает с теми же правами, что и сам webmail. Может читать всю вашу почту и сохранённые ключи и действовать от вашего имени. Разрешайте это только плагинам, которым полностью доверяете." }, + "keystore": { + "title": "Использовать сертификаты на этом компьютере", + "body": "Запрашивает подпись и расшифровку сообщений сертификатами на этом компьютере или смарт-карте. Сначала приложение спросит вас. Плагин никогда не получает закрытый ключ, но пока вы разрешаете, он может подписывать от вашего имени и читать то, что расшифровывает." + }, "email_raw_send": { "title": "Отправлять собственные письма", "body": "Отправлять с вашего аккаунта готовые письма, например подписанные или зашифрованные." @@ -6584,5 +6608,13 @@ "server_needed": "Сначала введите адрес сервера.", "server_declined": "Вы не разрешили этот сервер, поэтому ему ничего не отправлено.", "server_invalid": "Этот адрес сервера недействителен." + }, + "desktop_keystore": { + "title": "Сертификаты на этом компьютере", + "description": "Этот сервер может запрашивать использование ваших сертификатов и смарт-карт. Перед использованием каждого сертификата вас всё равно спросят.", + "label": "Доступ для этого сервера", + "label_description": "Отзовите своё разрешение. Серверу придётся запросить его снова.", + "revoke": "Отозвать", + "revoked": "Разрешение отозвано" } } diff --git a/locales/sk/common.json b/locales/sk/common.json index d32f33f18..365f3dfda 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5818,6 +5818,26 @@ "unpin": "Prestať uchovávať offline", "unpin_label": "Prestať uchovávať {name} offline" }, + "offlineDrafts": { + "title": "Koncepty v tomto zariadení", + "description": "Tieto koncepty sú uložené v tomto zariadení. Na server sa odošlú samy, hneď ako bude dostupný.", + "saved_on_device": "Uložené v tomto zariadení", + "status_waiting": "Čaká na server", + "status_sending": "Ukladá sa na server", + "status_uncertain": "Overuje sa, či dorazil na server", + "status_refused": "Server tento koncept odmietol: {error}", + "file_missing": "Priložený súbor už v tomto zariadení nie je", + "open": "Otvoriť", + "open_label": "Otvoriť {subject}", + "retry": "Skúsiť znova", + "retry_label": "Skúsiť znova: {subject}", + "toast_refused": "Koncept {subject} sa nepodarilo uložiť na server. Zostáva v tomto zariadení.", + "toast_conflict": "Koncept {subject} bol zmenený v inom zariadení. Obe verzie zostávajú zachované.", + "conflict_changed": "Zmenené v inom zariadení. Obe verzie sú v Konceptoch.", + "conflict_gone": "Zmenené alebo odstránené v inom zariadení. Vaša verzia je v Konceptoch.", + "banner_refused": "Koncepty odmietnuté serverom: {count}", + "banner_waiting": "Koncepty čakajúce na server: {count}" + }, "deep_link": { "message_not_found": "Táto správa už nie je dostupná.", "thread_not_found": "Táto konverzácia už nie je dostupná.", @@ -5934,6 +5954,10 @@ "title": "Úplný prístup k tejto aplikácii", "body": "Beží s rovnakými právami ako samotný webmail. Môže čítať všetku vašu poštu a uložené kľúče a konať vo vašom mene. Povoľte to len doplnkom, ktorým úplne dôverujete." }, + "keystore": { + "title": "Používať certifikáty v tomto počítači", + "body": "Žiada o podpisovanie a dešifrovanie správ pomocou certifikátov v tomto počítači alebo na čipovej karte. Aplikácia sa vás najprv opýta. Doplnok nikdy nedostane súkromný kľúč, ale kým to povolíte, môže podpisovať vaším menom a čítať, čo dešifruje." + }, "email_raw_send": { "title": "Odosielať správy, ktoré sám vytvoril", "body": "Odosiela z vášho účtu kompletné správy, napríklad podpísané alebo šifrované." @@ -6584,5 +6608,13 @@ "server_needed": "Najprv zadajte adresu servera.", "server_declined": "Tento server ste nepovolili, preto sa naň nič neodoslalo.", "server_invalid": "Táto adresa servera nie je platná." + }, + "desktop_keystore": { + "title": "Certifikáty v tomto počítači", + "description": "Tento server môže žiadať o použitie vašich certifikátov a čipových kariet. Pred použitím každého certifikátu sa vás aj tak opýtame.", + "label": "Prístup tohto servera", + "label_description": "Odvolajte svoje povolenie. Server sa bude musieť opýtať znova.", + "revoke": "Odvolať", + "revoked": "Povolenie bolo odvolané" } } diff --git a/locales/th/common.json b/locales/th/common.json index 16063beec..600b9f8a0 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2163,6 +2163,26 @@ "unpin": "เลิกเก็บไว้ใช้ออฟไลน์", "unpin_label": "เลิกเก็บ {name} ไว้ใช้ออฟไลน์" }, + "offlineDrafts": { + "title": "ฉบับร่างในอุปกรณ์นี้", + "description": "ฉบับร่างเหล่านี้บันทึกไว้ในอุปกรณ์นี้ และจะส่งไปยังเซิร์ฟเวอร์เองเมื่อเชื่อมต่อได้", + "saved_on_device": "บันทึกในอุปกรณ์นี้แล้ว", + "status_waiting": "กำลังรอเซิร์ฟเวอร์", + "status_sending": "กำลังบันทึกไปยังเซิร์ฟเวอร์", + "status_uncertain": "กำลังตรวจสอบว่าถึงเซิร์ฟเวอร์แล้วหรือไม่", + "status_refused": "เซิร์ฟเวอร์ปฏิเสธฉบับร่างนี้: {error}", + "file_missing": "ไฟล์แนบไม่อยู่ในอุปกรณ์นี้แล้ว", + "open": "เปิด", + "open_label": "เปิด {subject}", + "retry": "ลองอีกครั้ง", + "retry_label": "ลอง {subject} อีกครั้ง", + "toast_refused": "บันทึกฉบับร่าง {subject} ไปยังเซิร์ฟเวอร์ไม่ได้ ฉบับร่างยังอยู่ในอุปกรณ์นี้", + "toast_conflict": "ฉบับร่าง {subject} ถูกเปลี่ยนในอุปกรณ์อื่น เก็บไว้ทั้งสองฉบับ", + "conflict_changed": "เปลี่ยนในอุปกรณ์อื่น ทั้งสองฉบับอยู่ในฉบับร่าง", + "conflict_gone": "เปลี่ยนหรือลบในอุปกรณ์อื่น ฉบับของคุณอยู่ในฉบับร่าง", + "banner_refused": "ฉบับร่างที่เซิร์ฟเวอร์ปฏิเสธ: {count}", + "banner_waiting": "ฉบับร่างที่รอเซิร์ฟเวอร์: {count}" + }, "plugin_pages": { "loading": "กำลังโหลดหน้าเว็บ...", "not_available_title": "หน้านี้ไม่พร้อมใช้งาน", @@ -2849,6 +2869,10 @@ "title": "สิทธิ์การเข้าถึงแอปพลิเคชันอย่างเต็มรูปแบบ", "body": "ทำงานด้วยสิทธิ์เดียวกันกับตัวเว็บเมลเอง สามารถอ่านอีเมลทั้งหมดและคีย์ลับที่จัดเก็บไว้ รวมถึงดำเนินการแทนคุณได้ อนุญาตเฉพาะปลั๊กอินที่คุณไว้วางใจอย่างสมบูรณ์เท่านั้น" }, + "keystore": { + "title": "ใช้ใบรับรองในคอมพิวเตอร์เครื่องนี้", + "body": "ขอลงนามและถอดรหัสข้อความด้วยใบรับรองในคอมพิวเตอร์เครื่องนี้หรือบนสมาร์ตการ์ด แอปจะถามคุณก่อน ปลั๊กอินไม่ได้รับคีย์ส่วนตัว แต่ตราบใดที่คุณอนุญาต ปลั๊กอินจะลงนามในนามของคุณและอ่านสิ่งที่ถอดรหัสได้" + }, "email_raw_send": { "title": "ส่งข้อความที่สร้างขึ้นเอง", "body": "ส่งข้อความฉบับสมบูรณ์ เช่น ข้อความที่มีการลงลายมือชื่อดิจิทัลหรือเข้ารหัส จากบัญชีของคุณ" @@ -6592,5 +6616,13 @@ "server_needed": "ป้อนที่อยู่เซิร์ฟเวอร์ก่อน", "server_declined": "คุณไม่ได้อนุญาตเซิร์ฟเวอร์นี้ จึงไม่มีการส่งข้อมูลใดไป", "server_invalid": "ที่อยู่เซิร์ฟเวอร์นี้ไม่ถูกต้อง" + }, + "desktop_keystore": { + "title": "ใบรับรองในคอมพิวเตอร์เครื่องนี้", + "description": "เซิร์ฟเวอร์นี้สามารถขอใช้ใบรับรองและสมาร์ตการ์ดของคุณได้ คุณจะยังถูกถามก่อนใช้ใบรับรองแต่ละใบ", + "label": "การเข้าถึงของเซิร์ฟเวอร์นี้", + "label_description": "ถอนการอนุญาตของคุณ เซิร์ฟเวอร์จะต้องขออีกครั้ง", + "revoke": "ถอน", + "revoked": "ถอนการอนุญาตแล้ว" } } diff --git a/locales/tr/common.json b/locales/tr/common.json index 5487fdd54..3ceb48445 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5818,6 +5818,26 @@ "unpin": "Çevrimdışı tutmayı bırak", "unpin_label": "{name} dosyasını çevrimdışı tutmayı bırak" }, + "offlineDrafts": { + "title": "Bu cihazdaki taslaklar", + "description": "Bu taslaklar bu cihaza kaydedildi. Sunucuya ulaşılır ulaşılmaz kendiliğinden gönderilir.", + "saved_on_device": "Bu cihaza kaydedildi", + "status_waiting": "Sunucu bekleniyor", + "status_sending": "Sunucuya kaydediliyor", + "status_uncertain": "Sunucuya ulaşıp ulaşmadığı denetleniyor", + "status_refused": "Sunucu bu taslağı reddetti: {error}", + "file_missing": "Ekli bir dosya artık bu cihazda değil", + "open": "Aç", + "open_label": "{subject} öğesini aç", + "retry": "Yeniden dene", + "retry_label": "{subject} için yeniden dene", + "toast_refused": "{subject} taslağı sunucuya kaydedilemedi. Bu cihazda kalıyor.", + "toast_conflict": "{subject} taslağı başka bir cihazda değiştirildi. İki sürüm de saklanıyor.", + "conflict_changed": "Başka bir cihazda değiştirildi. İki sürüm de Taslaklar'da.", + "conflict_gone": "Başka bir cihazda değiştirildi veya kaldırıldı. Sizin sürümünüz Taslaklar'da.", + "banner_refused": "Sunucunun reddettiği taslaklar: {count}", + "banner_waiting": "Sunucuyu bekleyen taslaklar: {count}" + }, "deep_link": { "message_not_found": "Bu ileti artık kullanılamıyor.", "thread_not_found": "Bu sohbet artık kullanılamıyor.", @@ -5934,6 +5954,10 @@ "title": "Bu uygulamaya tam erişim", "body": "Webmail'in kendisiyle aynı haklarla çalışır. Tüm e-postalarınızı ve kayıtlı anahtarlarınızı okuyabilir, sizin adınıza işlem yapabilir. Buna yalnızca tamamen güvendiğiniz eklentiler için izin verin." }, + "keystore": { + "title": "Bu bilgisayardaki sertifikaları kullan", + "body": "Bu bilgisayardaki veya akıllı karttaki sertifikalarla iletileri imzalamak ve şifresini çözmek ister. Uygulama önce size sorar. Eklenti özel anahtarı asla almaz, ancak siz izin verdiğiniz sürece adınıza imzalayabilir ve şifresini çözdüğünü okuyabilir." + }, "email_raw_send": { "title": "Kendi oluşturduğu iletileri gönderme", "body": "Hesabınızdan imzalı veya şifreli iletiler gibi eksiksiz iletiler gönderme." @@ -6584,5 +6608,13 @@ "server_needed": "Önce sunucu adresini girin.", "server_declined": "Bu sunucuya izin vermediniz, bu yüzden ona hiçbir şey gönderilmedi.", "server_invalid": "Bu sunucu adresi geçerli değil." + }, + "desktop_keystore": { + "title": "Bu bilgisayardaki sertifikalar", + "description": "Bu sunucu, sertifikalarınızı ve akıllı kartlarınızı kullanmak isteyebilir. Her sertifika kullanılmadan önce yine de size sorulur.", + "label": "Bu sunucunun erişimi", + "label_description": "İzninizi geri alın. Sunucunun yeniden sorması gerekir.", + "revoke": "Geri al", + "revoked": "İzin geri alındı" } } diff --git a/locales/uk/common.json b/locales/uk/common.json index 0ca8dc4b7..fb286e371 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5818,6 +5818,26 @@ "unpin": "Більше не зберігати без мережі", "unpin_label": "Більше не зберігати {name} без мережі" }, + "offlineDrafts": { + "title": "Чернетки на цьому пристрої", + "description": "Ці чернетки збережено на цьому пристрої. Вони самі підуть на сервер, щойно він стане доступним.", + "saved_on_device": "Збережено на цьому пристрої", + "status_waiting": "Очікування сервера", + "status_sending": "Збереження на сервері", + "status_uncertain": "Перевірка, чи дійшла вона до сервера", + "status_refused": "Сервер відхилив цю чернетку: {error}", + "file_missing": "Вкладеного файлу більше немає на цьому пристрої", + "open": "Відкрити", + "open_label": "Відкрити {subject}", + "retry": "Повторити", + "retry_label": "Повторити: {subject}", + "toast_refused": "Чернетку {subject} не вдалося зберегти на сервері. Вона залишається на цьому пристрої.", + "toast_conflict": "Чернетку {subject} змінено на іншому пристрої. Збережено обидві версії.", + "conflict_changed": "Змінено на іншому пристрої. Обидві версії є в чернетках.", + "conflict_gone": "Змінено або видалено на іншому пристрої. Ваша версія є в чернетках.", + "banner_refused": "Чернетки, відхилені сервером: {count}", + "banner_waiting": "Чернетки, що чекають на сервер: {count}" + }, "deep_link": { "message_not_found": "Цей лист більше недоступний.", "thread_not_found": "Ця розмова більше недоступна.", @@ -5934,6 +5954,10 @@ "title": "Повний доступ до програми", "body": "Працює з тими самими правами, що й сам webmail. Може читати всю вашу пошту та збережені ключі й діяти від вашого імені. Дозволяйте це лише плагінам, яким повністю довіряєте." }, + "keystore": { + "title": "Використовувати сертифікати на цьому комп'ютері", + "body": "Просить підписувати та розшифровувати повідомлення сертифікатами на цьому комп'ютері або смарт-картці. Спершу застосунок запитає вас. Плагін ніколи не отримує закритий ключ, але поки ви дозволяєте, він може підписувати від вашого імені та читати те, що розшифровує." + }, "email_raw_send": { "title": "Надсилати власні листи", "body": "Надсилати з вашого облікового запису готові листи, наприклад підписані або зашифровані." @@ -6584,5 +6608,13 @@ "server_needed": "Спочатку введіть адресу сервера.", "server_declined": "Ви не дозволили цей сервер, тому йому нічого не надіслано.", "server_invalid": "Ця адреса сервера недійсна." + }, + "desktop_keystore": { + "title": "Сертифікати на цьому комп'ютері", + "description": "Цей сервер може просити використовувати ваші сертифікати та смарт-картки. Перед використанням кожного сертифіката вас однаково запитають.", + "label": "Доступ для цього сервера", + "label_description": "Відкличте свій дозвіл. Серверу доведеться запитати знову.", + "revoke": "Відкликати", + "revoked": "Дозвіл відкликано" } } diff --git a/locales/vi/common.json b/locales/vi/common.json index 275042a5e..41282e782 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5830,6 +5830,26 @@ "unpin": "Ngừng giữ ngoại tuyến", "unpin_label": "Ngừng giữ {name} ngoại tuyến" }, + "offlineDrafts": { + "title": "Bản nháp trên thiết bị này", + "description": "Các bản nháp này được lưu trên thiết bị này. Chúng sẽ tự chuyển lên máy chủ ngay khi kết nối được.", + "saved_on_device": "Đã lưu trên thiết bị này", + "status_waiting": "Đang chờ máy chủ", + "status_sending": "Đang lưu lên máy chủ", + "status_uncertain": "Đang kiểm tra xem đã đến máy chủ chưa", + "status_refused": "Máy chủ đã từ chối bản nháp này: {error}", + "file_missing": "Một tệp đính kèm không còn trên thiết bị này", + "open": "Mở", + "open_label": "Mở {subject}", + "retry": "Thử lại", + "retry_label": "Thử lại {subject}", + "toast_refused": "Không thể lưu bản nháp {subject} lên máy chủ. Bản nháp vẫn ở trên thiết bị này.", + "toast_conflict": "Bản nháp {subject} đã được thay đổi trên thiết bị khác. Cả hai phiên bản đều được giữ lại.", + "conflict_changed": "Đã thay đổi trên thiết bị khác. Cả hai phiên bản đều ở trong Bản nháp.", + "conflict_gone": "Đã thay đổi hoặc xóa trên thiết bị khác. Phiên bản của bạn ở trong Bản nháp.", + "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}" + }, "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.", @@ -5946,6 +5966,10 @@ "title": "Toàn quyền truy cập vào ứng dụng này", "body": "Chạy với các quyền tương tự như chính webmail. Nó có thể đọc tất cả thư và các khóa được lưu trữ của bạn và hành động thay bạn. Chỉ cho phép điều này đối với các plugin mà bạn hoàn toàn tin tưởng." }, + "keystore": { + "title": "Dùng chứng chỉ trên máy tính này", + "body": "Yêu cầu ký và giải mã thư bằng chứng chỉ trên máy tính này hoặc thẻ thông minh. Ứng dụng sẽ hỏi bạn trước. Tiện ích không bao giờ nhận được khóa riêng, nhưng khi bạn cho phép, tiện ích có thể ký dưới tên bạn và đọc nội dung đã giải mã." + }, "email_raw_send": { "title": "Gửi thư nó tự tạo", "body": "Gửi thư hoàn chỉnh, ví dụ như thư đã ký hoặc mã hóa, từ tài khoản của bạn." @@ -6596,5 +6620,13 @@ "server_needed": "Trước tiên hãy nhập địa chỉ máy chủ.", "server_declined": "Bạn không cho phép máy chủ này nên không có gì được gửi đến đó.", "server_invalid": "Địa chỉ máy chủ này không hợp lệ." + }, + "desktop_keystore": { + "title": "Chứng chỉ trên máy tính này", + "description": "Máy chủ này có thể yêu cầu dùng chứng chỉ và thẻ thông minh của bạn. Bạn vẫn được hỏi trước khi mỗi chứng chỉ được dùng.", + "label": "Quyền truy cập của máy chủ này", + "label_description": "Thu hồi quyền của bạn. Máy chủ sẽ phải hỏi lại.", + "revoke": "Thu hồi", + "revoked": "Đã thu hồi quyền" } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 67614a5dd..b3310374d 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5818,6 +5818,26 @@ "unpin": "不再離線保留", "unpin_label": "不再離線保留 {name}" }, + "offlineDrafts": { + "title": "此裝置上的草稿", + "description": "這些草稿儲存在此裝置上。一旦能連線到伺服器,就會自動上傳。", + "saved_on_device": "已儲存在此裝置上", + "status_waiting": "正在等待伺服器", + "status_sending": "正在儲存到伺服器", + "status_uncertain": "正在檢查是否已到達伺服器", + "status_refused": "伺服器拒絕了此草稿:{error}", + "file_missing": "附件已不在此裝置上", + "open": "開啟", + "open_label": "開啟 {subject}", + "retry": "重試", + "retry_label": "重試 {subject}", + "toast_refused": "草稿 {subject} 無法儲存到伺服器。它仍保留在此裝置上。", + "toast_conflict": "草稿 {subject} 已在其他裝置上變更。兩個版本都會保留。", + "conflict_changed": "已在其他裝置上變更。兩個版本都在草稿中。", + "conflict_gone": "已在其他裝置上變更或刪除。您的版本在草稿中。", + "banner_refused": "伺服器拒絕的草稿:{count}", + "banner_waiting": "等待伺服器的草稿:{count}" + }, "deep_link": { "message_not_found": "已無法使用此郵件。", "thread_not_found": "已無法使用此對話串。", @@ -5934,6 +5954,10 @@ "title": "完整存取此應用程式", "body": "以與網頁郵件本身相同的權限執行。它可以讀取您所有的郵件和已儲存的金鑰,並代表您執行動作。請只對您完全信任的外掛程式允許。" }, + "keystore": { + "title": "使用這台電腦上的憑證", + "body": "要求使用這台電腦或智慧卡上的憑證為郵件簽署及解密。應用程式會先詢問你。外掛程式絕不會取得私密金鑰,但在你允許期間,它可以用你的名義簽署,並讀取它解密的內容。" + }, "email_raw_send": { "title": "傳送它自行建立的郵件", "body": "從您的帳號傳送完整的郵件,例如已簽署或已加密的郵件。" @@ -6584,5 +6608,13 @@ "server_needed": "請先輸入伺服器位址。", "server_declined": "你沒有允許此伺服器,因此沒有傳送任何內容給它。", "server_invalid": "此伺服器位址無效。" + }, + "desktop_keystore": { + "title": "這台電腦上的憑證", + "description": "此伺服器可以要求使用你的憑證和智慧卡。每次使用憑證前仍會詢問你。", + "label": "此伺服器的存取權", + "label_description": "收回你的許可。伺服器需要重新詢問。", + "revoke": "收回", + "revoked": "已收回許可" } } diff --git a/locales/zh/common.json b/locales/zh/common.json index 20e0799ca..4693f1f58 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5818,6 +5818,26 @@ "unpin": "不再离线保留", "unpin_label": "不再离线保留 {name}" }, + "offlineDrafts": { + "title": "此设备上的草稿", + "description": "这些草稿保存在此设备上。一旦能连接服务器,就会自动上传。", + "saved_on_device": "已保存在此设备上", + "status_waiting": "正在等待服务器", + "status_sending": "正在保存到服务器", + "status_uncertain": "正在检查是否已到达服务器", + "status_refused": "服务器拒绝了此草稿:{error}", + "file_missing": "附件已不在此设备上", + "open": "打开", + "open_label": "打开 {subject}", + "retry": "重试", + "retry_label": "重试 {subject}", + "toast_refused": "草稿 {subject} 无法保存到服务器。它仍保留在此设备上。", + "toast_conflict": "草稿 {subject} 已在其他设备上更改。两个版本都会保留。", + "conflict_changed": "已在其他设备上更改。两个版本都在草稿中。", + "conflict_gone": "已在其他设备上更改或删除。您的版本在草稿中。", + "banner_refused": "服务器拒绝的草稿:{count}", + "banner_waiting": "等待服务器的草稿:{count}" + }, "deep_link": { "message_not_found": "此邮件已不可用。", "thread_not_found": "此会话已不可用。", @@ -5934,6 +5954,10 @@ "title": "完全访问此应用", "body": "以与网页邮箱本身相同的权限运行。它可以读取您的所有邮件和已存储的密钥,并代表您执行操作。请只对您完全信任的插件允许。" }, + "keystore": { + "title": "使用此电脑上的证书", + "body": "请求使用此电脑或智能卡上的证书为邮件签名和解密。应用会先询问你。插件永远拿不到私钥,但在你允许期间,它可以以你的名义签名,并读取它解密的内容。" + }, "email_raw_send": { "title": "发送它自行构建的邮件", "body": "从您的账户发送完整的邮件,例如已签名或已加密的邮件。" @@ -6584,5 +6608,13 @@ "server_needed": "请先输入服务器地址。", "server_declined": "你没有允许此服务器,因此没有向它发送任何内容。", "server_invalid": "此服务器地址无效。" + }, + "desktop_keystore": { + "title": "此电脑上的证书", + "description": "此服务器可以请求使用你的证书和智能卡。每次使用证书前仍会询问你。", + "label": "此服务器的访问权限", + "label_description": "收回你的许可。服务器需要重新询问。", + "revoke": "收回", + "revoked": "已收回许可" } }