@rapidmx/web-client 0.35.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/escrow/matters/[uid].tsx +4 -3
- package/apps/shared/calendar/calendarReminders.ts +35 -0
- package/apps/shared/calendar/sealedEvents.ts +113 -0
- package/apps/shared/calendar/useCalendarReminders.ts +28 -9
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +89 -3
- package/apps/shared/components/calendar/EventDetails.tsx +13 -2
- package/apps/shared/components/calendar/EventEditor.tsx +124 -17
- package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
- package/apps/shared/components/calendar/EventModal.tsx +19 -2
- package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
- package/apps/shared/components/calendar/eventForm.ts +2 -0
- package/apps/shared/components/contacts/ContactCard.tsx +13 -1
- package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
- package/apps/shared/components/contacts/ContactForm.tsx +56 -7
- package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +8 -4
- package/apps/shared/components/contacts/contactKeys.ts +3 -3
- package/apps/shared/components/layout/AppShell.tsx +34 -5
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +30 -1
- package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +122 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
- package/apps/shared/components/mail/ConversationList.tsx +1 -1
- package/apps/shared/components/mail/InviteCard.tsx +11 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +59 -26
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
- package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
- package/apps/shared/components/mail/compose/draftResume.ts +28 -3
- package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
- package/apps/shared/components/mail/compose/quotedBody.ts +4 -3
- package/apps/shared/components/mail/invite/inviteStore.ts +17 -1
- package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
- package/apps/shared/components/mail/pinnedSigners.ts +14 -0
- package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
- package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
- package/apps/shared/components/settings/KeyExportSetting.tsx +173 -0
- package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
- package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
- package/apps/shared/components/settings/ZeroAccessSetting.tsx +128 -0
- package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
- package/apps/shared/mail/decryptedMessages.ts +11 -3
- package/apps/shared/mail/outbox/sendJob.ts +349 -50
- package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
- package/apps/shared/mail/reportNotices.ts +3 -1
- package/apps/shared/mail/zeroAccessSource.ts +27 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +84 -24
- package/apps/shared/search/localIndexBuilder.ts +57 -20
- package/apps/shared/search/localIndexRpcClient.ts +96 -12
- package/apps/shared/search/localIndexSchema.ts +20 -4
- package/apps/shared/search/localIndexTransport.ts +6 -2
- package/apps/shared/search/localIndexWorker.ts +37 -18
- package/apps/shared/search/searchTier2.ts +5 -0
- package/apps/shared/search/zeroAccessIndexSync.ts +679 -0
- package/apps/shared/search/zeroAccessSyncState.ts +70 -0
- package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
- package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
- package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
- package/apps/www/_shell.tsx +2 -0
- package/apps/www/calendar/index.tsx +225 -61
- package/apps/www/contacts/index.tsx +67 -11
- package/apps/www/index.tsx +19 -3
- package/apps/www/settings/encryption/index.tsx +21 -1
- package/apps/www/settings/privacy/index.tsx +89 -39
- package/apps/www/tasks/index.tsx +24 -6
- package/dist/apps/escrow/matters/[uid].js +2 -2
- package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
- package/dist/apps/shared/calendar/calendarReminders.js +26 -0
- package/dist/apps/shared/calendar/sealedEvents.d.ts +14 -0
- package/dist/apps/shared/calendar/sealedEvents.js +85 -0
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
- package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +25 -2
- package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
- package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
- package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
- package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
- package/dist/apps/shared/components/calendar/EventModal.js +15 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
- package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
- package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
- package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
- package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
- package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
- package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
- package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
- package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
- package/dist/apps/shared/components/layout/AppShell.js +17 -6
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +27 -2
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +17 -0
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +98 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
- package/dist/apps/shared/components/mail/ConversationList.js +1 -1
- package/dist/apps/shared/components/mail/InviteCard.js +11 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +46 -21
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
- package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
- package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +1 -1
- package/dist/apps/shared/components/mail/compose/quotedBody.js +4 -3
- package/dist/apps/shared/components/mail/invite/inviteStore.js +16 -1
- package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
- package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
- package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
- package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
- package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/KeyExportSetting.js +90 -0
- package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +16 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.js +80 -0
- package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
- package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
- package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
- package/dist/apps/shared/mail/decryptedMessages.js +11 -3
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
- package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
- package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
- package/dist/apps/shared/mail/reportNotices.js +3 -1
- package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
- package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +74 -24
- package/dist/apps/shared/search/localIndexBuilder.d.ts +9 -1
- package/dist/apps/shared/search/localIndexBuilder.js +50 -17
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
- package/dist/apps/shared/search/localIndexRpcClient.js +129 -11
- package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
- package/dist/apps/shared/search/localIndexSchema.js +18 -4
- package/dist/apps/shared/search/localIndexTransport.d.ts +6 -2
- package/dist/apps/shared/search/localIndexWorker.d.ts +11 -0
- package/dist/apps/shared/search/localIndexWorker.js +22 -15
- package/dist/apps/shared/search/searchTier2.js +5 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +102 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.js +544 -0
- package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
- package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
- package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
- package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
- package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
- package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
- package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
- package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
- package/dist/apps/www/_shell.d.ts +1 -1
- package/dist/apps/www/_shell.js +2 -2
- package/dist/apps/www/calendar/index.js +88 -20
- package/dist/apps/www/contacts/index.js +64 -11
- package/dist/apps/www/index.js +19 -3
- package/dist/apps/www/settings/encryption/index.js +16 -4
- package/dist/apps/www/settings/privacy/index.js +47 -6
- package/dist/apps/www/tasks/index.js +21 -7
- package/dist/lib/admin/domainsApi.d.ts +1 -1
- package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
- package/dist/lib/calendar/calendarApi.d.ts +52 -6
- package/dist/lib/calendar/calendarApi.js +110 -17
- package/dist/lib/calendar/calendarMutations.d.ts +20 -7
- package/dist/lib/calendar/calendarMutations.js +73 -14
- package/dist/lib/calendar/inviteApi.d.ts +17 -3
- package/dist/lib/calendar/inviteApi.js +86 -7
- package/dist/lib/calendar/protectedEvents.d.ts +31 -0
- package/dist/lib/calendar/protectedEvents.js +113 -0
- package/dist/lib/calendar/recurrence.d.ts +2 -0
- package/dist/lib/calendar/recurrence.js +16 -0
- package/dist/lib/calendar/sealedInvites.d.ts +9 -0
- package/dist/lib/calendar/sealedInvites.js +65 -0
- package/dist/lib/contacts/contactsApi.d.ts +29 -3
- package/dist/lib/contacts/contactsApi.js +23 -7
- package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
- package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
- package/dist/lib/crypto/composeSecurity.d.ts +11 -3
- package/dist/lib/crypto/composeSecurity.js +13 -5
- package/dist/lib/crypto/keyExport.d.ts +54 -0
- package/dist/lib/crypto/keyExport.js +161 -0
- package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
- package/dist/lib/crypto/keyPairMatch.js +65 -0
- package/dist/lib/crypto/keySession.d.ts +64 -3
- package/dist/lib/crypto/keySession.js +179 -12
- package/dist/lib/crypto/keyvaultApi.d.ts +129 -8
- package/dist/lib/crypto/keyvaultApi.js +45 -3
- package/dist/lib/crypto/meetingKeys.d.ts +25 -0
- package/dist/lib/crypto/meetingKeys.js +87 -0
- package/dist/lib/crypto/messageSecurity.d.ts +56 -3
- package/dist/lib/crypto/messageSecurity.js +201 -13
- package/dist/lib/crypto/mime.d.ts +5 -0
- package/dist/lib/crypto/mime.js +25 -0
- package/dist/lib/crypto/openPgpKey.d.ts +39 -0
- package/dist/lib/crypto/openPgpKey.js +89 -0
- package/dist/lib/crypto/openpgp.d.ts +47 -0
- package/dist/lib/crypto/openpgp.js +88 -0
- package/dist/lib/crypto/pgpMessage.d.ts +51 -0
- package/dist/lib/crypto/pgpMessage.js +271 -0
- package/dist/lib/crypto/sharedMailboxKeys.d.ts +62 -0
- package/dist/lib/crypto/sharedMailboxKeys.js +337 -0
- package/dist/lib/crypto/smimeMessage.d.ts +21 -0
- package/dist/lib/crypto/smimeMessage.js +11 -7
- package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
- package/dist/lib/crypto/zeroAccessKey.js +71 -0
- package/dist/lib/mail/conversationsApi.d.ts +6 -0
- package/dist/lib/mail/mailApi.d.ts +63 -2
- package/dist/lib/mail/mailApi.js +5 -1
- package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
- package/dist/lib/mail/mailboxImportApi.js +15 -0
- package/dist/lib/search/zeroAccessIndexApi.d.ts +85 -0
- package/dist/lib/search/zeroAccessIndexApi.js +35 -0
- package/dist/lib/tasks/tasksApi.d.ts +11 -1
- package/dist/lib/tasks/tasksApi.js +10 -4
- package/dist/lib/util/clientPlatform.d.ts +14 -0
- package/dist/lib/util/clientPlatform.js +26 -0
- package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
- package/dist/lib/videoconf/protectedMeetings.js +181 -0
- package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
- package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
- package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
- package/dist/lib/zeroAccess/sealedMoves.js +63 -0
- package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
- package/dist/lib/zeroAccess/sealedRecords.js +136 -0
- package/lib/admin/domainsApi.ts +1 -1
- package/lib/admin/escrowAccessRequestsApi.ts +6 -0
- package/lib/calendar/calendarApi.ts +143 -17
- package/lib/calendar/calendarMutations.ts +79 -12
- package/lib/calendar/inviteApi.ts +101 -8
- package/lib/calendar/protectedEvents.ts +143 -0
- package/lib/calendar/recurrence.ts +17 -0
- package/lib/calendar/sealedInvites.ts +73 -0
- package/lib/contacts/contactsApi.ts +43 -17
- package/lib/contacts/useContactPhotoSrc.ts +103 -13
- package/lib/crypto/composeSecurity.ts +20 -5
- package/lib/crypto/keyExport.ts +229 -0
- package/lib/crypto/keyPairMatch.ts +69 -0
- package/lib/crypto/keySession.ts +216 -12
- package/lib/crypto/keyvaultApi.ts +174 -10
- package/lib/crypto/meetingKeys.ts +118 -0
- package/lib/crypto/messageSecurity.ts +284 -16
- package/lib/crypto/mime.ts +26 -0
- package/lib/crypto/openPgpKey.ts +118 -0
- package/lib/crypto/openpgp.ts +115 -0
- package/lib/crypto/pgpMessage.ts +336 -0
- package/lib/crypto/sharedMailboxKeys.ts +402 -0
- package/lib/crypto/smimeMessage.ts +11 -7
- package/lib/crypto/zeroAccessKey.ts +102 -0
- package/lib/mail/conversationsApi.ts +3 -0
- package/lib/mail/mailApi.ts +60 -3
- package/lib/mail/mailboxImportApi.ts +24 -0
- package/lib/search/zeroAccessIndexApi.ts +123 -0
- package/lib/tasks/tasksApi.ts +19 -5
- package/lib/util/clientPlatform.ts +50 -0
- package/lib/videoconf/protectedMeetings.ts +221 -0
- package/lib/videoconf/videoMeetingsApi.ts +15 -4
- package/lib/zeroAccess/sealedMoves.ts +79 -0
- package/lib/zeroAccess/sealedRecords.ts +153 -0
- package/package.json +14 -5
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import { cancelScheduledSend, getMessage, listAttachments, listFolders, queueMessageSend } from "../../../../lib/mail/mailApi.js";
|
|
5
|
+
import { cancelScheduledSend, deleteMessage, getMessage, listAttachments, listFolders, queueMessageSend } from "../../../../lib/mail/mailApi.js";
|
|
6
6
|
import { describeSendEventError } from "../../../../lib/mail/sendEvents.js";
|
|
7
7
|
import { formatRecipient } from "../../components/mail/compose/recipients.js";
|
|
8
8
|
import { loadOriginalMessage } from "../../components/mail/compose/quotedBody.js";
|
|
@@ -11,7 +11,7 @@ import { dismiss, notify } from "../../notifications/store.js";
|
|
|
11
11
|
import { beginPendingSend, finishPendingSend, setPendingStage } from "./pendingSends.js";
|
|
12
12
|
import { failOutgoing, forgetOutgoing, markOutgoingSending, markOutgoingSent } from "./outgoingReplies.js";
|
|
13
13
|
import { openComposeFromOutside } from "./composeBridge.js";
|
|
14
|
-
import { describeMessage, forgetRetainedRequest, notifySent, openDraftFromRequest, retainedRequest } from "./sendJob.js";
|
|
14
|
+
import { copyFailed, copyRetried, describeMessage, forgetCopies, forgetRetainedRequest, notifySent, openDraftFromRequest, retainedRequest, unsentCopies, } from "./sendJob.js";
|
|
15
15
|
/**
|
|
16
16
|
* What the user is told when the server reports how a queued message went (`send-succeeded`, `send-failed`, `send-retrying` push events,
|
|
17
17
|
* see `@rapidmx/react-shared`'s `parseSendEvent()`): a quiet, single "Message sent" for a message this tab sent (several close together are
|
|
@@ -36,14 +36,39 @@ async function moveBackToDrafts(uid, mailboxUid, client) {
|
|
|
36
36
|
function escapeHtml(text) {
|
|
37
37
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
38
38
|
}
|
|
39
|
-
/** "Open draft" for a message the server failed to relay: back into Drafts, and into a compose window with what was written.
|
|
40
|
-
|
|
39
|
+
/** "Open draft" for a message the server failed to relay: back into Drafts, and into a compose window with what was written. `request`: the
|
|
40
|
+
* one it was queued from, when this tab holds it. */
|
|
41
|
+
async function openFailedDraft(event, client, request) {
|
|
42
|
+
// A message sent as several copies (one per encryption format) is written again only when none of them went: while another copy has
|
|
43
|
+
// gone or may still go, the whole message sent again would reach its recipients a second time - however the action came to be offered.
|
|
44
|
+
const copies = unsentCopies(request);
|
|
45
|
+
if (request?.copyFates && !copies) {
|
|
46
|
+
notify({
|
|
47
|
+
id: `open-draft:${event.uid}`,
|
|
48
|
+
kind: "info",
|
|
49
|
+
title: "Only this copy didn't go",
|
|
50
|
+
message: "The copy for its other recipients is sent on its own. Use Retry to send this one to its own recipients.",
|
|
51
|
+
timeoutMs: 8000,
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
41
55
|
try {
|
|
42
|
-
|
|
56
|
+
// Of a message sent as several copies, its own draft is the one written again.
|
|
57
|
+
const moved = await moveBackToDrafts(copies ? request.draft.uid : event.uid, event.mailboxUid ?? "", client);
|
|
43
58
|
// Back in Drafts it is no longer a message on its way in its conversation.
|
|
44
59
|
forgetOutgoing(event.uid);
|
|
45
|
-
const request = retainedRequest(event.uid);
|
|
46
60
|
if (request) {
|
|
61
|
+
// The other copies (the OpenPGP one's own draft) go: the message is encrypted afresh when it is sent again, and none of them may
|
|
62
|
+
// be retried meanwhile.
|
|
63
|
+
for (const uid of copies?.filter((uid) => uid !== request.draft.uid) ?? []) {
|
|
64
|
+
const extra = await moveBackToDrafts(uid, event.mailboxUid ?? "", client);
|
|
65
|
+
await deleteMessage(extra.uid, extra.version, client);
|
|
66
|
+
dismiss(failedId(uid));
|
|
67
|
+
forgetOutgoing(uid);
|
|
68
|
+
}
|
|
69
|
+
if (request.copyFates) {
|
|
70
|
+
forgetCopies(request.copyFates);
|
|
71
|
+
}
|
|
47
72
|
openDraftFromRequest({ ...request, draft: moved });
|
|
48
73
|
return;
|
|
49
74
|
}
|
|
@@ -81,21 +106,25 @@ async function openFailedDraft(event, client) {
|
|
|
81
106
|
notifyApiError(err, "Couldn't open this draft");
|
|
82
107
|
}
|
|
83
108
|
}
|
|
84
|
-
/** "Retry" for a message the server failed to relay: the same send call again - a failed message stays in Outbox and the server queues it afresh (with a new retry budget).
|
|
85
|
-
|
|
109
|
+
/** "Retry" for a message the server failed to relay: the same send call again - a failed message stays in Outbox and the server queues it afresh (with a new retry budget).
|
|
110
|
+
* `request`: the one it was queued from, when this tab holds it - a copy of a message sent as several is then awaited again. */
|
|
111
|
+
async function retryFailed(event, client, request) {
|
|
86
112
|
if (!beginPendingSend({ draftUid: event.uid, mailboxUid: event.mailboxUid ?? "", subject: event.subject, recipients: event.recipients, scheduled: false })) {
|
|
87
113
|
return;
|
|
88
114
|
}
|
|
89
115
|
dismiss(failedId(event.uid));
|
|
90
116
|
markOutgoingSending(event.uid);
|
|
117
|
+
copyRetried(request, event.uid);
|
|
91
118
|
try {
|
|
92
119
|
setPendingStage(event.uid, "queuing");
|
|
93
120
|
const result = await queueMessageSend(event.uid, client);
|
|
94
|
-
|
|
121
|
+
// Relayed before the server answered - and so, when it is one copy of several, maybe the last of them.
|
|
122
|
+
if (!result.queued && forgetRetainedRequest(event.uid)) {
|
|
95
123
|
notifySent();
|
|
96
124
|
}
|
|
97
125
|
}
|
|
98
126
|
catch (err) {
|
|
127
|
+
copyFailed(request, event.uid);
|
|
99
128
|
notifyApiError(err, "This message wasn't sent", { dedupeKey: failedId(event.uid) });
|
|
100
129
|
}
|
|
101
130
|
finally {
|
|
@@ -108,26 +137,29 @@ async function retryFailed(event, client) {
|
|
|
108
137
|
export function handleSendEvent(event, client) {
|
|
109
138
|
if (event.action === "send-succeeded") {
|
|
110
139
|
dismiss(retryId(event.uid));
|
|
111
|
-
|
|
140
|
+
// A partly-sent message's failure (`notifyFailure()`) is shown under its first copy's uid but is of a copy that was never handed over:
|
|
141
|
+
// it stays, with the Retry that hands that copy over.
|
|
142
|
+
if (![...(retainedRequest(event.uid)?.copyFates?.values() ?? [])].includes("unsent")) {
|
|
143
|
+
dismiss(failedId(event.uid));
|
|
144
|
+
}
|
|
112
145
|
// The conversation on screen swaps the message it drew for the Sent Items copy (a message this tab did not send has nothing drawn).
|
|
113
146
|
markOutgoingSent(event.uid);
|
|
114
147
|
// The server reports every message it relays, scheduled ones from long ago included: only a message this tab queued in the background is
|
|
115
148
|
// worth a confirmation (several are one pop-up counting up). Anything else - a send-later message going out, another tab's send - is quiet.
|
|
116
|
-
|
|
117
|
-
|
|
149
|
+
// A message sent as several copies (one per encryption format) is confirmed once, when the last of them has gone.
|
|
150
|
+
if (retainedRequest(event.uid) && forgetRetainedRequest(event.uid)) {
|
|
118
151
|
notifySent();
|
|
119
152
|
}
|
|
120
153
|
return;
|
|
121
154
|
}
|
|
122
155
|
const failure = describeSendEventError(event.error, event.action === "send-failed" ? "The server couldn't send this message." : "The server couldn't send this message yet.");
|
|
123
|
-
const what = describeMessage(event.subject, summary(event.recipients));
|
|
124
156
|
if (event.action === "send-retrying") {
|
|
125
157
|
const next = event.nextAttemptAt ? Date.parse(event.nextAttemptAt) : NaN;
|
|
126
158
|
notify({
|
|
127
159
|
id: retryId(event.uid),
|
|
128
160
|
kind: "info",
|
|
129
161
|
title: "Retrying to send",
|
|
130
|
-
message: `${
|
|
162
|
+
message: `${describeMessage(event.subject, summary(event.recipients))} (attempt ${event.attempt + 1}${Number.isNaN(next) ? "" : `, at ${new Date(next).toLocaleTimeString()}`}). ${failure.message}`,
|
|
131
163
|
details: failure.lines,
|
|
132
164
|
sticky: false,
|
|
133
165
|
timeoutMs: 8000,
|
|
@@ -135,18 +167,42 @@ export function handleSendEvent(event, client) {
|
|
|
135
167
|
return;
|
|
136
168
|
}
|
|
137
169
|
dismiss(retryId(event.uid));
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
170
|
+
const request = retainedRequest(event.uid);
|
|
171
|
+
copyFailed(request, event.uid);
|
|
172
|
+
notifySendFailure(event, failure, request, client);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The sticky "This message wasn't sent" for a message that failed, `failure` saying why, with what can be done about it - for one the server
|
|
176
|
+
* failed to relay, and for a message sent as several copies (`SendRequest.copyUids`) of which this tab couldn't hand over the rest after the
|
|
177
|
+
* server had failed one (`sendJob.ts`'s `notifyFailure()`). `request`: the one it was queued from, when this tab holds it.
|
|
178
|
+
*/
|
|
179
|
+
export function notifySendFailure(event, failure, request, client) {
|
|
180
|
+
// Every copy of a message sent as several (one per encryption format, `SendRequest.copyUids`) failed or never went: nobody has it, and it
|
|
181
|
+
// is the whole message's failure - one pop-up, whose Retry hands each copy over again and whose "Open draft" writes the message again.
|
|
182
|
+
const copies = unsentCopies(request);
|
|
183
|
+
// One copy of several, while another has gone or may still go: the others' recipients have theirs or will, and "Open draft" would send
|
|
184
|
+
// the whole message to them again - Retry sends just this copy, to just its own recipients.
|
|
185
|
+
const oneCopy = !!request?.copyFates && !copies;
|
|
186
|
+
const retry = () => {
|
|
187
|
+
for (const uid of copies ?? [event.uid]) {
|
|
188
|
+
void retryFailed({ ...event, uid }, client, request);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
const actions = [{ label: "Retry", onClick: retry }, ...(oneCopy ? [] : [{ label: "Open draft", onClick: () => void openFailedDraft(event, client, request) }])];
|
|
192
|
+
const message = oneCopy ? `The copy for some of its recipients didn't go - the others' copy is sent on its own. ${failure.message}` : failure.message;
|
|
193
|
+
// What an earlier copy's failure said of the others is no longer so: this pop-up stands for the whole message.
|
|
194
|
+
for (const uid of copies?.filter((uid) => uid !== event.uid) ?? []) {
|
|
195
|
+
dismiss(failedId(uid));
|
|
196
|
+
failOutgoing(uid, message, actions);
|
|
197
|
+
}
|
|
142
198
|
notify({
|
|
143
199
|
id: failedId(event.uid),
|
|
144
200
|
kind: "error",
|
|
145
201
|
title: "This message wasn't sent",
|
|
146
|
-
message: `${
|
|
202
|
+
message: `${describeMessage(event.subject, summary(event.recipients))}: ${message}`,
|
|
147
203
|
details: failure.lines,
|
|
148
204
|
actions,
|
|
149
205
|
});
|
|
150
206
|
// The conversation on screen shows the same failure under the message, with the same ways out.
|
|
151
|
-
failOutgoing(event.uid,
|
|
207
|
+
failOutgoing(event.uid, message, actions);
|
|
152
208
|
}
|
|
@@ -42,6 +42,8 @@ export function reportNotice(result, subject, alwaysTrust) {
|
|
|
42
42
|
? "The spam filter could not be trained from this report."
|
|
43
43
|
: result.learnSkipped === "encrypted"
|
|
44
44
|
? "Not used to train the spam filter: the message is encrypted."
|
|
45
|
-
:
|
|
45
|
+
: result.learnSkipped === "zero_access"
|
|
46
|
+
? "Not used to train the spam filter: unlock your mailbox to let it learn from protected mail."
|
|
47
|
+
: undefined;
|
|
46
48
|
return { title: kind.title, message: parts.join(" "), hint };
|
|
47
49
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Message } from "../../../lib/mail/mailApi.js";
|
|
2
|
+
import type { ApiClient } from "../../../lib/util/api.js";
|
|
3
|
+
/**
|
|
4
|
+
* The original source of a zero-access `message`, decrypted with its mailbox's unlocked keys and base64-encoded - what the server, which holds
|
|
5
|
+
* only the sealed copy, needs for an action that reads the message itself (`reportMessage()`'s `rawMessage`). `undefined` for any other
|
|
6
|
+
* message, while the mailbox is locked, or when it can't be fetched or opened: the action then goes ahead without it.
|
|
7
|
+
*/
|
|
8
|
+
export declare function zeroAccessSourceBase64(message: Pick<Message, "uid" | "mailboxUid" | "zeroAccess">, client?: ApiClient): Promise<string | undefined>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { getUnlockedKeys } from "../../../lib/crypto/keySession.js";
|
|
6
|
+
import { openZeroAccessMessage } from "../../../lib/crypto/messageSecurity.js";
|
|
7
|
+
import { getMessageRawContent } from "../../../lib/mail/mailApi.js";
|
|
8
|
+
/**
|
|
9
|
+
* The original source of a zero-access `message`, decrypted with its mailbox's unlocked keys and base64-encoded - what the server, which holds
|
|
10
|
+
* only the sealed copy, needs for an action that reads the message itself (`reportMessage()`'s `rawMessage`). `undefined` for any other
|
|
11
|
+
* message, while the mailbox is locked, or when it can't be fetched or opened: the action then goes ahead without it.
|
|
12
|
+
*/
|
|
13
|
+
export async function zeroAccessSourceBase64(message, client) {
|
|
14
|
+
const unlocked = message.zeroAccess ? getUnlockedKeys(message.mailboxUid) : undefined;
|
|
15
|
+
if (!unlocked) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const original = await openZeroAccessMessage(await getMessageRawContent(message.uid, client), unlocked);
|
|
20
|
+
// A binary string (one char per byte), which `btoa()` encodes as the bytes themselves.
|
|
21
|
+
return original === undefined ? undefined : btoa(original);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { PublicKey } from "../../../lib/crypto/keyvaultApi.js";
|
|
2
2
|
import type { Folder } from "../../../lib/mail/mailApi.js";
|
|
3
3
|
export declare const POLL_INTERVAL_MS = 5000;
|
|
4
|
+
/** How often an unlocked zero-access mailbox's local index is brought up to date with the server's sealed index. */
|
|
5
|
+
export declare const ZERO_ACCESS_SYNC_INTERVAL_MS = 120000;
|
|
4
6
|
export interface LocalIndexLifecycleProps {
|
|
5
7
|
mailboxUid?: string;
|
|
6
8
|
mailboxKeys?: PublicKey[];
|
|
@@ -8,20 +8,23 @@
|
|
|
8
8
|
* "MUST be destroyed on the same events that destroy private keys: explicit logout, session revocation,
|
|
9
9
|
* and the configurable idle timeout").
|
|
10
10
|
*
|
|
11
|
-
* **
|
|
12
|
-
* (
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* **Locks**: `localIndexRpcClient.ts` itself destroys an index it opened the moment `subscribeKeySession()` reports its mailbox locked,
|
|
12
|
+
* whatever destroyed the keys (idle timeout, the manual button, a sign-out) and whether or not this component is still mounted. This
|
|
13
|
+
* component subscribes to the same `locked` event to reset what it remembers of the mailbox (built, synced) at that moment, so a re-unlock
|
|
14
|
+
* builds again - even one made before the next poll tick, which a poll alone would never see as a lock, leaving the destroyed index
|
|
15
|
+
* unbuilt and every zero-access sync failing against it. Keys only ever go away through `destroyUnlockedKeys()`, which always reports
|
|
16
|
+
* the lock, so the poll doesn't watch for a lock itself. A lock while a build is still deriving its index key, before anything is open
|
|
17
|
+
* for the index client to destroy, is the builder's own to catch (`localIndexBuilder.ts`): it opens nothing with destroyed keys.
|
|
15
18
|
*
|
|
16
19
|
* Every mailbox this component has seen unlocked is tracked for the life of the mount - not just whichever
|
|
17
20
|
* is active right now - so switching mailboxes (or a folders-list refresh re-running the build effect)
|
|
18
21
|
* can never lose a pending unlocked-to-locked transition for a mailbox whose index was already built.
|
|
19
22
|
*
|
|
20
|
-
* **Explicit logout** navigates away immediately, which may not leave time for
|
|
23
|
+
* **Explicit logout** navigates away immediately, which may not leave time for the lock's asynchronous destroy - `AppShell.tsx`
|
|
21
24
|
* handles that case itself via `destroyAllLocalIndexes()`.
|
|
22
25
|
*/
|
|
23
26
|
import { useEffect, useRef } from "react";
|
|
24
|
-
import { getUnlockedKeys } from "../../../lib/crypto/keySession.js";
|
|
27
|
+
import { getUnlockedKeys, subscribeKeySession } from "../../../lib/crypto/keySession.js";
|
|
25
28
|
import { useApiClient } from "../../../lib/util/apiClientContext.js";
|
|
26
29
|
/**
|
|
27
30
|
* The index builder (with the message security code it decrypts through - PKI.js and the ASN.1/X.509 libraries, over half a
|
|
@@ -34,56 +37,103 @@ const loadBuilder = () => (builder ?? (builder = import("./localIndexBuilder.js"
|
|
|
34
37
|
builder = undefined;
|
|
35
38
|
throw err;
|
|
36
39
|
})));
|
|
37
|
-
|
|
40
|
+
/** The zero-access index sync (`zeroAccessIndexSync.ts`), loaded the same way: only once a zero-access mailbox is unlocked. */
|
|
41
|
+
let syncer;
|
|
42
|
+
const loadSyncer = () => (syncer ?? (syncer = import("./zeroAccessIndexSync.js").catch((err) => {
|
|
43
|
+
syncer = undefined;
|
|
44
|
+
throw err;
|
|
45
|
+
})));
|
|
46
|
+
import { pruneInaccessibleLocalIndexes } from "./localIndexRpcClient.js";
|
|
38
47
|
export const POLL_INTERVAL_MS = 5000;
|
|
48
|
+
/** How often an unlocked zero-access mailbox's local index is brought up to date with the server's sealed index. */
|
|
49
|
+
export const ZERO_ACCESS_SYNC_INTERVAL_MS = 120000;
|
|
39
50
|
/** Renders nothing - pure side-effect component, mounted by `MailShell.tsx`. */
|
|
40
51
|
export default function LocalIndexLifecycle({ mailboxUid, folders, accessibleMailboxUids }) {
|
|
41
52
|
// The explicit ApiClient (from useApiClient()) this component is rendered under - undefined for every existing caller (the SSR web/admin
|
|
42
53
|
// apps, Electron), which keeps building the index against the default global cookie-based session exactly as before. A native,
|
|
43
54
|
// multi-account host that renders this under an ApiClientContext.Provider gets its own account's client threaded through instead.
|
|
44
55
|
const client = useApiClient();
|
|
45
|
-
// mailboxUid -> whether it
|
|
46
|
-
// build this component started;
|
|
47
|
-
// effect re-run.
|
|
56
|
+
// mailboxUid -> whether it is unlocked as far as this component knows: set when its build starts, cleared by its lock. An entry exists
|
|
57
|
+
// for every mailbox whose build this component started; never reset by an effect re-run.
|
|
48
58
|
const trackedRef = useRef(new Map());
|
|
49
59
|
// The latest props (and client), for the long-lived polling interval below.
|
|
50
60
|
const latestRef = useRef({ mailboxUid, folders, client });
|
|
51
61
|
latestRef.current = { mailboxUid, folders, client };
|
|
62
|
+
// mailboxUid -> when its last zero-access sync started. A mailbox syncs only once its build of this unlock settled (`builtRef` - the
|
|
63
|
+
// sync writes into the index the build opened), first at once and then every interval; a mailbox in `syncingRef` has a pass running,
|
|
64
|
+
// and never gets a second one alongside it. A lock forgets all three, so a pass still winding down never holds up the next unlock's.
|
|
65
|
+
const lastSyncRef = useRef(new Map());
|
|
66
|
+
const builtRef = useRef(new Set());
|
|
67
|
+
// mailboxUid -> its latest build, so only that one's end starts the sync (not one a lock overtook).
|
|
68
|
+
const buildsRef = useRef(new Map());
|
|
69
|
+
const syncingRef = useRef(new Map());
|
|
70
|
+
function syncIfDue(targetMailboxUid, targetClient) {
|
|
71
|
+
const unlocked = getUnlockedKeys(targetMailboxUid);
|
|
72
|
+
const last = lastSyncRef.current.get(targetMailboxUid);
|
|
73
|
+
if (!unlocked?.zeroAccessKeys?.length ||
|
|
74
|
+
!builtRef.current.has(targetMailboxUid) ||
|
|
75
|
+
syncingRef.current.has(targetMailboxUid) ||
|
|
76
|
+
(last !== undefined && Date.now() - last < ZERO_ACCESS_SYNC_INTERVAL_MS)) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
lastSyncRef.current.set(targetMailboxUid, Date.now());
|
|
80
|
+
const pass = Symbol(targetMailboxUid);
|
|
81
|
+
syncingRef.current.set(targetMailboxUid, pass);
|
|
82
|
+
loadSyncer()
|
|
83
|
+
.then(({ syncZeroAccessIndex }) => syncZeroAccessIndex(targetMailboxUid, unlocked, targetClient))
|
|
84
|
+
.catch(() => undefined)
|
|
85
|
+
.finally(() => {
|
|
86
|
+
if (syncingRef.current.get(targetMailboxUid) === pass) {
|
|
87
|
+
syncingRef.current.delete(targetMailboxUid);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
52
91
|
function startBuildIfUnlocked(targetMailboxUid, targetFolders, targetClient) {
|
|
53
92
|
const unlocked = getUnlockedKeys(targetMailboxUid);
|
|
54
93
|
if (!unlocked || targetFolders.length === 0 || trackedRef.current.get(targetMailboxUid)) {
|
|
55
94
|
return;
|
|
56
95
|
}
|
|
57
96
|
trackedRef.current.set(targetMailboxUid, true);
|
|
97
|
+
const build = Symbol(targetMailboxUid);
|
|
98
|
+
buildsRef.current.set(targetMailboxUid, build);
|
|
58
99
|
// Never an unhandled rejection - a broken local index is best-effort infrastructure, not a build
|
|
59
100
|
// the rest of the app depends on.
|
|
60
|
-
|
|
101
|
+
// The sync runs however the build ended: zero-access mail reaches the index only through it, and a build that failed part-way
|
|
102
|
+
// isn't tried again until the next unlock. (Locked meanwhile, there are no keys to sync with.)
|
|
103
|
+
void loadBuilder()
|
|
61
104
|
.then(({ buildLocalIndex }) => buildLocalIndex(targetMailboxUid, unlocked, targetFolders, undefined, targetClient))
|
|
62
|
-
.catch(() => undefined)
|
|
105
|
+
.catch(() => undefined)
|
|
106
|
+
.then(() => {
|
|
107
|
+
if (buildsRef.current.get(targetMailboxUid) === build && trackedRef.current.get(targetMailboxUid)) {
|
|
108
|
+
builtRef.current.add(targetMailboxUid);
|
|
109
|
+
syncIfDue(targetMailboxUid, targetClient);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
63
112
|
}
|
|
64
113
|
useEffect(() => {
|
|
65
114
|
if (mailboxUid) {
|
|
66
115
|
startBuildIfUnlocked(mailboxUid, folders, client);
|
|
67
116
|
}
|
|
68
117
|
}, [mailboxUid, folders, client]);
|
|
118
|
+
// A lock forgets the mailbox at once: no build of this unlock counts as built any more (the cancelled build's end finds `buildsRef`
|
|
119
|
+
// cleared and starts no sync), and the next unlock's tick starts a new build. The cancel and destroy themselves are
|
|
120
|
+
// `localIndexRpcClient.ts`'s, which reacts to the same event.
|
|
121
|
+
useEffect(() => subscribeKeySession(({ mailboxUid: lockedUid, state }) => {
|
|
122
|
+
if (state === "locked" && trackedRef.current.get(lockedUid)) {
|
|
123
|
+
trackedRef.current.set(lockedUid, false);
|
|
124
|
+
lastSyncRef.current.delete(lockedUid);
|
|
125
|
+
builtRef.current.delete(lockedUid);
|
|
126
|
+
syncingRef.current.delete(lockedUid);
|
|
127
|
+
buildsRef.current.delete(lockedUid);
|
|
128
|
+
}
|
|
129
|
+
}), []);
|
|
69
130
|
useEffect(() => {
|
|
70
131
|
const interval = setInterval(() => {
|
|
71
|
-
for (const [trackedUid, wasUnlocked] of trackedRef.current) {
|
|
72
|
-
if (wasUnlocked && !getUnlockedKeys(trackedUid)) {
|
|
73
|
-
// A present-to-absent transition: something just destroyed this mailbox's keys.
|
|
74
|
-
trackedRef.current.set(trackedUid, false);
|
|
75
|
-
// Stop the pass still running with the now-destroyed keys; the destroy itself also makes
|
|
76
|
-
// the Worker reject anything that pass still sends.
|
|
77
|
-
loadBuilder()
|
|
78
|
-
.then(({ cancelLocalIndexBuild }) => cancelLocalIndexBuild(trackedUid))
|
|
79
|
-
.catch(() => undefined);
|
|
80
|
-
void destroyLocalIndex(trackedUid);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
132
|
// The active mailbox was (re-)unlocked since the last check, e.g. via an on-demand unlock prompt.
|
|
84
133
|
const { mailboxUid: activeUid, folders: activeFolders, client: activeClient } = latestRef.current;
|
|
85
134
|
if (activeUid) {
|
|
86
135
|
startBuildIfUnlocked(activeUid, activeFolders, activeClient);
|
|
136
|
+
syncIfDue(activeUid, activeClient);
|
|
87
137
|
}
|
|
88
138
|
}, POLL_INTERVAL_MS);
|
|
89
139
|
return () => clearInterval(interval);
|
|
@@ -15,11 +15,16 @@
|
|
|
15
15
|
* cross-folder interleaving imprecision here does not affect *what* ultimately survives the window, only
|
|
16
16
|
* the exact order entities are inserted (and therefore briefly evicted-and-reinserted) in.
|
|
17
17
|
*
|
|
18
|
-
* Only encrypted messages (`subject === "[...]"`) are indexed - see `ENCRYPTED_SUBJECT_PLACEHOLDER`'s
|
|
18
|
+
* Only encrypted messages (`subject === "[...]"`, or flagged `encrypted` by the server - OpenPGP mail keeps its real outer subject) are indexed - see `ENCRYPTED_SUBJECT_PLACEHOLDER`'s
|
|
19
19
|
* own precedent in `apps/www/index.tsx`'s inbox-list decrypt work. An unencrypted message is already
|
|
20
20
|
* fully searchable via Tier 1; indexing it here too would spend this index's bounded byte budget on
|
|
21
21
|
* content that didn't need it.
|
|
22
22
|
*
|
|
23
|
+
* **Shared with the mailbox's other devices (Source C).** In a mailbox with a zero-access key, a pass first imports the end-to-end mail
|
|
24
|
+
* other devices already decrypted (`importClientSegments()`), at each message's version, so it isn't decrypted again here; and what this
|
|
25
|
+
* pass decrypts it uploads for them, sealed to the zero-access key (`uploadClientDocuments()`). Both are best effort - see
|
|
26
|
+
* `zeroAccessIndexSync.ts`.
|
|
27
|
+
*
|
|
23
28
|
* **Verification seals.** A message this pass decrypts that has no seal for the vault's current `masterKeyGeneration` is
|
|
24
29
|
* evaluated with `evaluateMessageSecurityWithSeal()`, against the sender's pinned signing keys from the reader's
|
|
25
30
|
* contacts (`pinnedSigners.ts`, the message pane's own source, looked up once per sender per pass), so a verified
|
|
@@ -53,6 +58,9 @@ export declare const MAX_WALK_ATTEMPTS = 3;
|
|
|
53
58
|
/** Subtracted from the pass's start time before it's recorded as the end of guaranteed coverage - a
|
|
54
59
|
* message's server-assigned `receivedDate` can be ahead of this device's clock. */
|
|
55
60
|
export declare const CLOCK_SKEW_MARGIN_MS: number;
|
|
61
|
+
/** Decrypted end-to-end messages are uploaded for the mailbox's other devices (`uploadClientDocuments()`) whenever this many are waiting,
|
|
62
|
+
* not all at the end of the pass: a pass cut short keeps what it shared, and the plaintext isn't all held in memory at once. */
|
|
63
|
+
export declare const CLIENT_UPLOAD_BATCH = 500;
|
|
56
64
|
/** How many raw-MIME fetch+decrypt calls run at once - a page of 100 encrypted messages used to fire all
|
|
57
65
|
* 100 requests simultaneously. */
|
|
58
66
|
export declare const FETCH_CONCURRENCY = 6;
|
|
@@ -19,11 +19,16 @@
|
|
|
19
19
|
* cross-folder interleaving imprecision here does not affect *what* ultimately survives the window, only
|
|
20
20
|
* the exact order entities are inserted (and therefore briefly evicted-and-reinserted) in.
|
|
21
21
|
*
|
|
22
|
-
* Only encrypted messages (`subject === "[...]"`) are indexed - see `ENCRYPTED_SUBJECT_PLACEHOLDER`'s
|
|
22
|
+
* Only encrypted messages (`subject === "[...]"`, or flagged `encrypted` by the server - OpenPGP mail keeps its real outer subject) are indexed - see `ENCRYPTED_SUBJECT_PLACEHOLDER`'s
|
|
23
23
|
* own precedent in `apps/www/index.tsx`'s inbox-list decrypt work. An unencrypted message is already
|
|
24
24
|
* fully searchable via Tier 1; indexing it here too would spend this index's bounded byte budget on
|
|
25
25
|
* content that didn't need it.
|
|
26
26
|
*
|
|
27
|
+
* **Shared with the mailbox's other devices (Source C).** In a mailbox with a zero-access key, a pass first imports the end-to-end mail
|
|
28
|
+
* other devices already decrypted (`importClientSegments()`), at each message's version, so it isn't decrypted again here; and what this
|
|
29
|
+
* pass decrypts it uploads for them, sealed to the zero-access key (`uploadClientDocuments()`). Both are best effort - see
|
|
30
|
+
* `zeroAccessIndexSync.ts`.
|
|
31
|
+
*
|
|
27
32
|
* **Verification seals.** A message this pass decrypts that has no seal for the vault's current `masterKeyGeneration` is
|
|
28
33
|
* evaluated with `evaluateMessageSecurityWithSeal()`, against the sender's pinned signing keys from the reader's
|
|
29
34
|
* contacts (`pinnedSigners.ts`, the message pane's own source, looked up once per sender per pass), so a verified
|
|
@@ -37,11 +42,14 @@
|
|
|
37
42
|
*/
|
|
38
43
|
import { getMessageRawContent, listFolders, listMessages } from "../../../lib/mail/mailApi.js";
|
|
39
44
|
import { evaluateMessageSecurity, evaluateMessageSecurityWithSeal } from "../../../lib/crypto/messageSecurity.js";
|
|
40
|
-
import {
|
|
45
|
+
import { KeysLockedError } from "../../../lib/crypto/masterKey.js";
|
|
46
|
+
import { getPinnedSigners } from "../components/mail/pinnedSigners.js";
|
|
41
47
|
import { currentVerificationSeal, readVaultGeneration, sendVerificationSeal, verificationSealPending } from "../components/mail/verificationSeals.js";
|
|
42
|
-
import {
|
|
48
|
+
import { messageRowFlags, messageRowVersion } from "./localIndexSchema.js";
|
|
49
|
+
import { getIndexedVersions, moveLocalEntity, indexLocalEntities, initLocalIndex, nextLocalIndexGeneration, pruneLocalEntities, setLocalIndexBuilding, setLocalIndexWindow, } from "./localIndexRpcClient.js";
|
|
43
50
|
import { deriveLocalIndexKey } from "./localIndexKey.js";
|
|
44
51
|
import { getLocalIndexByteBudget } from "./localIndexSizePreference.js";
|
|
52
|
+
import { importClientSegments, rememberZeroAccessMetadata, uploadClientDocuments } from "./zeroAccessIndexSync.js";
|
|
45
53
|
/** The RFC 9788 placeholder subject every encrypted message's outer envelope carries server-side - see
|
|
46
54
|
* `apps/www/index.tsx`'s identical constant and its own doc comment for the full citation. */
|
|
47
55
|
const ENCRYPTED_SUBJECT_PLACEHOLDER = "[...]";
|
|
@@ -66,6 +74,9 @@ export const MAX_WALK_ATTEMPTS = 3;
|
|
|
66
74
|
* message's server-assigned `receivedDate` can be ahead of this device's clock. */
|
|
67
75
|
export const CLOCK_SKEW_MARGIN_MS = 10 * 60 * 1000;
|
|
68
76
|
const PAGE_SIZE = 100;
|
|
77
|
+
/** Decrypted end-to-end messages are uploaded for the mailbox's other devices (`uploadClientDocuments()`) whenever this many are waiting,
|
|
78
|
+
* not all at the end of the pass: a pass cut short keeps what it shared, and the plaintext isn't all held in memory at once. */
|
|
79
|
+
export const CLIENT_UPLOAD_BATCH = 500;
|
|
69
80
|
const MAX_APPROX_BYTES_PER_MESSAGE_PADDING = 512; // subject/participants/flags overhead beyond raw text length
|
|
70
81
|
const FETCH_FAILED = Symbol("fetch-failed");
|
|
71
82
|
/** How many raw-MIME fetch+decrypt calls run at once - a page of 100 encrypted messages used to fire all
|
|
@@ -88,7 +99,7 @@ function createPassSealer(mailboxUid, unlocked, signal, client) {
|
|
|
88
99
|
const key = address.toLowerCase();
|
|
89
100
|
let entry = pins.get(key);
|
|
90
101
|
if (!entry) {
|
|
91
|
-
entry =
|
|
102
|
+
entry = getPinnedSigners(mailboxUid, address, client).then((signers) => signers.openPgpKeys.length > 0 ? signers : signers.fingerprints.length > 0 ? signers.fingerprints : undefined, () => undefined);
|
|
92
103
|
pins.set(key, entry);
|
|
93
104
|
}
|
|
94
105
|
return entry;
|
|
@@ -153,11 +164,8 @@ function estimateByteSize(entity) {
|
|
|
153
164
|
const textBytes = [entity.subject, entity.body, entity.attachmentText, entity.participants].reduce((total, value) => total + (value ? encoder.encode(value).length : 0), 0);
|
|
154
165
|
return textBytes + MAX_APPROX_BYTES_PER_MESSAGE_PADDING;
|
|
155
166
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
function versionOf(message) {
|
|
159
|
-
return `${message.version}:${message.folderUid}`;
|
|
160
|
-
}
|
|
167
|
+
const versionOf = (message) => messageRowVersion(message);
|
|
168
|
+
const entityFlags = (message) => messageRowFlags(message);
|
|
161
169
|
/** `Promise.all(items.map(fn))`, but with at most `limit` calls in flight. Preserves input order. */
|
|
162
170
|
async function mapWithConcurrency(items, limit, fn) {
|
|
163
171
|
const results = new Array(items.length);
|
|
@@ -196,12 +204,7 @@ async function buildEntity(message, sealer) {
|
|
|
196
204
|
const participants = [message.from.address, message.from.displayName, ...message.recipients.map((r) => r.address)]
|
|
197
205
|
.filter(Boolean)
|
|
198
206
|
.join(" ");
|
|
199
|
-
const
|
|
200
|
-
.filter(([, value]) => value)
|
|
201
|
-
.map(([key]) => key);
|
|
202
|
-
// Leading/trailing comma so `flags LIKE '%,x,%'` (localIndexSchema.ts's buildSearchPredicates())
|
|
203
|
-
// matches correctly even for the first/last flag in the list.
|
|
204
|
-
const flags = `,${setFlags.join(",")},`;
|
|
207
|
+
const flags = entityFlags(message);
|
|
205
208
|
const entity = {
|
|
206
209
|
entityType: "message",
|
|
207
210
|
entityUid: message.uid,
|
|
@@ -284,9 +287,17 @@ async function runBuildPass(mailboxUid, unlocked, folders, windowConfig, signal,
|
|
|
284
287
|
const generation = nextLocalIndexGeneration();
|
|
285
288
|
const indexKey = await deriveLocalIndexKey(unlocked.masterKey, mailboxUid);
|
|
286
289
|
signal.throwIfAborted();
|
|
290
|
+
// A lock during the derive doesn't fail it (WebCrypto copied the master key first), and the index client only counts locks from
|
|
291
|
+
// `initLocalIndex()`'s own call on: open nothing with the destroyed keys.
|
|
292
|
+
if (unlocked.destroyed) {
|
|
293
|
+
throw new KeysLockedError();
|
|
294
|
+
}
|
|
287
295
|
await initLocalIndex({ mailboxUid, indexKey, generation });
|
|
288
296
|
let { evictedBefore } = await setLocalIndexWindow(mailboxUid, windowConfig.timeFloorMonths, windowConfig.byteBudgetBytes, generation);
|
|
289
297
|
await setLocalIndexBuilding(mailboxUid, true, { complete: false, generation });
|
|
298
|
+
// End-to-end mail another device already decrypted: imported at its version, so the walk below skips it.
|
|
299
|
+
await importClientSegments(mailboxUid, unlocked, client, { generation }).catch(() => undefined);
|
|
300
|
+
signal.throwIfAborted();
|
|
290
301
|
// Mail arriving after this moment isn't guaranteed to be picked up by this pass.
|
|
291
302
|
const coveredUntil = new Date(Date.now() - CLOCK_SKEW_MARGIN_MS).toISOString();
|
|
292
303
|
let cutoff;
|
|
@@ -307,6 +318,10 @@ async function runBuildPass(mailboxUid, unlocked, folders, windowConfig, signal,
|
|
|
307
318
|
.filter((f) => MESSAGE_FOLDER_TYPES.has(f.type))
|
|
308
319
|
.sort((a, b) => (FOLDER_PRIORITY[a.type] ?? 99) - (FOLDER_PRIORITY[b.type] ?? 99));
|
|
309
320
|
const seenEncryptedUids = new Set();
|
|
321
|
+
// What this pass decrypted and hasn't shared yet, for the mailbox's other devices.
|
|
322
|
+
const decrypted = [];
|
|
323
|
+
// Best effort: what fails to upload is decrypted again by the other devices themselves.
|
|
324
|
+
const share = () => uploadClientDocuments(mailboxUid, unlocked, decrypted.splice(0), client).catch(() => undefined);
|
|
310
325
|
async function walkFolder(folder) {
|
|
311
326
|
for (let page = 0;; page++) {
|
|
312
327
|
signal.throwIfAborted();
|
|
@@ -320,12 +335,25 @@ async function runBuildPass(mailboxUid, unlocked, folders, windowConfig, signal,
|
|
|
320
335
|
if (messages.length === 0) {
|
|
321
336
|
return "done";
|
|
322
337
|
}
|
|
323
|
-
|
|
338
|
+
// The server's `encrypted` flag too: an OpenPGP sender (Proton Mail and others) keeps the real outer subject.
|
|
339
|
+
const encrypted = messages.filter((m) => m.subject === ENCRYPTED_SUBJECT_PLACEHOLDER || m.encrypted);
|
|
324
340
|
for (const m of encrypted) {
|
|
325
341
|
seenEncryptedUids.add(m.uid);
|
|
326
342
|
}
|
|
343
|
+
// A zero-access message's content comes from the server's sealed index (`zeroAccessIndexSync.ts`), never decrypted here:
|
|
344
|
+
// only its folder and flags are recorded on its row - and remembered by version, so an unchanged one costs nothing next
|
|
345
|
+
// time. Its row may not be imported yet (a new device, or after a lock): the sync applies them when it imports it.
|
|
346
|
+
const sealed = encrypted.filter((m) => m.zeroAccess);
|
|
347
|
+
if (sealed.length > 0) {
|
|
348
|
+
const indexed = await getIndexedVersions(mailboxUid, sealed.map((m) => m.uid));
|
|
349
|
+
for (const m of sealed.filter((s) => indexed[s.uid] !== versionOf(s))) {
|
|
350
|
+
const metadata = { folderUid: m.folderUid, entityVersion: versionOf(m), flags: entityFlags(m) };
|
|
351
|
+
await moveLocalEntity(mailboxUid, m.uid, metadata.folderUid, metadata.entityVersion, metadata.flags);
|
|
352
|
+
rememberZeroAccessMetadata(mailboxUid, m.uid, metadata);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
327
355
|
// Anything older than the eviction watermark would only be inserted and evicted again.
|
|
328
|
-
const candidates = encrypted.filter((m) => !isOlderThan(m.receivedDate, evictedBefore));
|
|
356
|
+
const candidates = encrypted.filter((m) => !m.zeroAccess && !isOlderThan(m.receivedDate, evictedBefore));
|
|
329
357
|
if (candidates.length > 0) {
|
|
330
358
|
// Skip anything already indexed at the same version - a re-unlock used to re-fetch and
|
|
331
359
|
// re-decrypt the entire window every time.
|
|
@@ -339,6 +367,10 @@ async function runBuildPass(mailboxUid, unlocked, folders, windowConfig, signal,
|
|
|
339
367
|
const entities = built.filter((e) => e !== undefined && e !== FETCH_FAILED);
|
|
340
368
|
if (entities.length > 0) {
|
|
341
369
|
({ evictedBefore } = await indexLocalEntities(mailboxUid, entities, generation));
|
|
370
|
+
decrypted.push(...entities);
|
|
371
|
+
if (decrypted.length >= CLIENT_UPLOAD_BATCH) {
|
|
372
|
+
await share();
|
|
373
|
+
}
|
|
342
374
|
}
|
|
343
375
|
}
|
|
344
376
|
const oldestOnPage = messages[messages.length - 1].receivedDate;
|
|
@@ -397,6 +429,7 @@ async function runBuildPass(mailboxUid, unlocked, folders, windowConfig, signal,
|
|
|
397
429
|
const since = evictedBefore && isWithinTimeFloor(evictedBefore, cutoff) ? evictedBefore : cutoff?.toISOString();
|
|
398
430
|
await pruneLocalEntities(mailboxUid, [...seenEncryptedUids], since, { folderUids: reliable, generation });
|
|
399
431
|
}
|
|
432
|
+
await share();
|
|
400
433
|
}
|
|
401
434
|
catch (err) {
|
|
402
435
|
complete = false;
|