@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
package/lib/mail/mailApi.ts
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from "../util/api.js";
|
|
27
27
|
import { ListParams, buildQuery, sortQuery } from "../util/apiQuery.js";
|
|
28
28
|
import { deviceTimeZone } from "../util/timeZone.js";
|
|
29
|
-
import type { EncryptionPreference, PublicKey } from "../crypto/keyvaultApi.js";
|
|
29
|
+
import type { EncryptionPreference, PublicKey, ZeroAccessKey } from "../crypto/keyvaultApi.js";
|
|
30
30
|
import { bytesToBinaryString } from "../crypto/mime.js";
|
|
31
31
|
import { EMBEDDED_IMAGE_TYPES, newContentId, parseDataImage } from "./inlineImages.js";
|
|
32
32
|
import { MAX_QUOTED_IMAGE_BYTES } from "./compose/quotedImages.js";
|
|
@@ -129,6 +129,18 @@ export interface Mailbox {
|
|
|
129
129
|
* per the spec's own `PublicKey` doc comment. Absent/empty means no keys enrolled yet
|
|
130
130
|
* (`KeyEnrollmentGate` handles that state). */
|
|
131
131
|
keys?: PublicKey[];
|
|
132
|
+
/** The mailbox's zero-access certificates, managed by the server (never written by a client). Read them from
|
|
133
|
+
* `KeyVault.zeroAccessKeys` to unlock; here only so a settings page can tell whether one is enrolled. */
|
|
134
|
+
zeroAccessKeys?: ZeroAccessKey[];
|
|
135
|
+
/** "Enable zero-access encryption": unset means on. Only the mailbox's owner can change it - restapi drops anybody
|
|
136
|
+
* else's value. Has no effect while the server's `EncryptionPolicy.allowZeroAccess` is off. */
|
|
137
|
+
zeroAccessEnabled?: boolean;
|
|
138
|
+
/** Which data types zero-access encryption covers - see `ZeroAccessTypes`. Owner only. */
|
|
139
|
+
zeroAccessTypes?: ZeroAccessTypes;
|
|
140
|
+
/** The ActiveSync and Outlook clients that sync this mailbox, as the server last saw them. Server-written. */
|
|
141
|
+
syncClients?: SyncClients;
|
|
142
|
+
/** When the owner last exported a zero-access key for a phone or Outlook. Server-written. */
|
|
143
|
+
zeroAccessKeysExportedAt?: number;
|
|
132
144
|
/** How the caller reaches this mailbox: `"owner"` (theirs) or `"delegate"` (shared with them). Set by the plain list and read of
|
|
133
145
|
* mailboxes - not in an administration-scope answer - so a client can label the shared ones; see `isSharedWithMe()`. */
|
|
134
146
|
accessRole?: "owner" | "delegate";
|
|
@@ -257,6 +269,19 @@ export function autoProvisionMailbox(
|
|
|
257
269
|
* `null` clears them - `JSON.stringify()` drops an `undefined` value entirely, which would instead leave
|
|
258
270
|
* the old value in place.
|
|
259
271
|
*/
|
|
272
|
+
/** The data types zero-access encryption can cover (restapi `specs/search.md` §3 *What It Covers*). */
|
|
273
|
+
export type ZeroAccessDataType = "mail" | "calendar" | "contacts" | "tasks";
|
|
274
|
+
|
|
275
|
+
/** `Mailbox.zeroAccessTypes`: per data type, whether zero-access covers it. A type left out is undecided: mail counts as on, the others
|
|
276
|
+
* as off, until the server decides them from the owner's devices. */
|
|
277
|
+
export type ZeroAccessTypes = Partial<Record<ZeroAccessDataType, boolean>>;
|
|
278
|
+
|
|
279
|
+
/** `Mailbox.syncClients`: the ActiveSync devices (each with the data types it syncs) and Outlook. */
|
|
280
|
+
export interface SyncClients {
|
|
281
|
+
activesync?: { devices: { id: string; model?: string; types: ZeroAccessDataType[]; lastSeenAt: number }[] };
|
|
282
|
+
mapi?: { lastSeenAt: number };
|
|
283
|
+
}
|
|
284
|
+
|
|
260
285
|
export interface UpdateMailboxInput {
|
|
261
286
|
uid: string;
|
|
262
287
|
version: number;
|
|
@@ -281,6 +306,12 @@ export interface UpdateMailboxInput {
|
|
|
281
306
|
autoSendReceiptsInternal?: boolean;
|
|
282
307
|
autoSendReceiptsFederated?: boolean;
|
|
283
308
|
autoSendReceiptsExternal?: boolean;
|
|
309
|
+
/** Owner-only server-side (anybody else's value is dropped) - see `Mailbox.zeroAccessEnabled`. */
|
|
310
|
+
zeroAccessEnabled?: boolean;
|
|
311
|
+
/** Owner-only server-side; a partial choice is merged into the stored one - see `Mailbox.zeroAccessTypes`. */
|
|
312
|
+
zeroAccessTypes?: ZeroAccessTypes;
|
|
313
|
+
/** The mailbox's own encryption preference, replaced whole - see `Mailbox.encryptPreference`. */
|
|
314
|
+
encryptPreference?: EncryptionPreference;
|
|
284
315
|
/** Trusted-caller-only server-side. `null` (or `""`) unassigns the mailbox's escrow scope; the
|
|
285
316
|
* referenced `EscrowScope` must exist otherwise. See `Mailbox.escrowScopeId`. */
|
|
286
317
|
escrowScopeId?: string | null;
|
|
@@ -594,6 +625,10 @@ export interface Message {
|
|
|
594
625
|
* stored body, not from a flag this API exposes. Always present on a real `Message` (defaults
|
|
595
626
|
* `false` server-side), same optional-for-old-fixtures caveat as `Mailbox.oofEnabled`. */
|
|
596
627
|
encrypted?: boolean;
|
|
628
|
+
/** Set when the server sealed this message at rest on arrival (zero-access, restapi `specs/search.md` §3): its stored body is an
|
|
629
|
+
* S/MIME message only the mailbox's zero-access key opens, `subject` is `[...]` and `bodyPreview` empty - the real ones are in
|
|
630
|
+
* `sealedSummary`. */
|
|
631
|
+
zeroAccess?: { keyFingerprint: string; sealedSummary: string };
|
|
597
632
|
/** The raw RFC 2369/8058 `List-Unsubscribe` header value, if present — computed server-side by
|
|
598
633
|
* `@rapidmx/restapi`'s `ScanPipeline` from the stored MIME, the strongest single bulk-mail signal it
|
|
599
634
|
* captures. Used client-side to detect list traffic when replying, per
|
|
@@ -797,7 +832,7 @@ export type MessageReportKind = "junk" | "phishing" | "not_junk";
|
|
|
797
832
|
/** Why the server did not teach its spam filter from a report: `encrypted` (it has no plaintext to teach), `disabled` (learning is switched off),
|
|
798
833
|
* `unsupported` (no spam engine, or one that cannot learn), `too_large` (over the size it reads) or `failed` (the engine could not be reached,
|
|
799
834
|
* refused, or the message could not be read). The report itself still happened. */
|
|
800
|
-
export type MessageLearnSkipped = "encrypted" | "disabled" | "unsupported" | "too_large" | "failed";
|
|
835
|
+
export type MessageLearnSkipped = "encrypted" | "zero_access" | "disabled" | "unsupported" | "too_large" | "failed";
|
|
801
836
|
|
|
802
837
|
/** What `reportMessage()` answers. */
|
|
803
838
|
export interface MessageReportResult {
|
|
@@ -820,6 +855,9 @@ export interface ReportMessageOptions {
|
|
|
820
855
|
/** With `not_junk` only (else a 400): also add the message's From address to the mailbox's safe senders. Needs full access to the mailbox (403 otherwise,
|
|
821
856
|
* before anything is done). */
|
|
822
857
|
alwaysTrustSender?: boolean;
|
|
858
|
+
/** For a zero-access message only (else a 400): its original source, base64-encoded, decrypted here - the server holds only the sealed
|
|
859
|
+
* copy, so this is what its spam filter learns from (never stored). Without it the server skips learning (`learnSkipped: "zero_access"`). */
|
|
860
|
+
rawMessage?: string;
|
|
823
861
|
}
|
|
824
862
|
|
|
825
863
|
/**
|
|
@@ -837,7 +875,11 @@ export function reportMessage(
|
|
|
837
875
|
): Promise<MessageReportResult> {
|
|
838
876
|
return withClient(client, `/mail/messages/${encodeURIComponent(uid)}/report`, {
|
|
839
877
|
method: "POST",
|
|
840
|
-
body: JSON.stringify({
|
|
878
|
+
body: JSON.stringify({
|
|
879
|
+
kind,
|
|
880
|
+
...(options.alwaysTrustSender ? { alwaysTrustSender: true } : {}),
|
|
881
|
+
...(options.rawMessage ? { rawMessage: options.rawMessage } : {}),
|
|
882
|
+
}),
|
|
841
883
|
});
|
|
842
884
|
}
|
|
843
885
|
|
|
@@ -1073,8 +1115,21 @@ export interface AssembleDraftInput {
|
|
|
1073
1115
|
bcc?: ComposeRecipientInput[];
|
|
1074
1116
|
subject?: string;
|
|
1075
1117
|
html: string;
|
|
1118
|
+
/** See `AssembleIntent`. */
|
|
1119
|
+
intent?: AssembleIntent;
|
|
1120
|
+
/** The addresses this copy is delivered to, when not all of `to`/`cc`/`bcc`: one copy of a message sent in two end-to-end
|
|
1121
|
+
* formats (an S/MIME and an OpenPGP copy, each listing every recipient) goes only to the recipients of its own format. Each
|
|
1122
|
+
* must be one of the recipients. */
|
|
1123
|
+
deliverTo?: string[];
|
|
1076
1124
|
}
|
|
1077
1125
|
|
|
1126
|
+
/**
|
|
1127
|
+
* Why a draft is being assembled (server `ComposeIntent`). `"save"`, the default, keeps an edit: in a zero-access mailbox the server
|
|
1128
|
+
* stores it sealed, which it can't send. `"send"` is the assemble right before `sendMessage()`/`queueMessageSend()`: stored readable
|
|
1129
|
+
* until it is relayed, then sealed as the Sent Items copy.
|
|
1130
|
+
*/
|
|
1131
|
+
export type AssembleIntent = "save" | "send";
|
|
1132
|
+
|
|
1078
1133
|
/** What a draft records about the thread it belongs to - see `createDraft()`. */
|
|
1079
1134
|
export interface DraftThreading {
|
|
1080
1135
|
/** The `messageId` of the message being replied to (`Message.inReplyTo`). */
|
|
@@ -1266,6 +1321,8 @@ export interface AssembleDraftRawInput {
|
|
|
1266
1321
|
* `crypto/smimeMessage.ts`'s `buildSignedOnlyMessage()`/`buildEncryptedMessage()`, combined with
|
|
1267
1322
|
* the outer envelope headers (`From`/`To`/`Cc`/`Date`/`Message-ID`/`MIME-Version`) by the caller. */
|
|
1268
1323
|
rawMime: string;
|
|
1324
|
+
/** See `AssembleIntent`. */
|
|
1325
|
+
intent?: AssembleIntent;
|
|
1269
1326
|
}
|
|
1270
1327
|
|
|
1271
1328
|
/**
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { ApiClient, withClient, withClientRaw } from "../util/api.js";
|
|
13
13
|
import { RequestListParams, buildRequestListQuery } from "../util/apiQuery.js";
|
|
14
|
+
import type { Mailbox } from "./mailApi.js";
|
|
14
15
|
|
|
15
16
|
export type { RequestListParams };
|
|
16
17
|
|
|
@@ -79,3 +80,26 @@ export function listImportRequests(params: RequestListParams = {}, client?: ApiC
|
|
|
79
80
|
export function getImportRequest(uid: string, client?: ApiClient): Promise<MailboxImportRequest> {
|
|
80
81
|
return withClient(client, `/mail/mailbox-import-requests/${encodeURIComponent(uid)}`);
|
|
81
82
|
}
|
|
83
|
+
|
|
84
|
+
/** Why server-side import is refused for a mailbox whose mail is zero-access (restapi answers such an upload 409). */
|
|
85
|
+
export const ZERO_ACCESS_IMPORT_MESSAGE =
|
|
86
|
+
"Importing an archive isn't available while zero-access encryption protects this mailbox's mail: the server would have to keep " +
|
|
87
|
+
"the uploaded archive readable until the import ran, which zero-access encryption doesn't allow.";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether `mailbox`'s mail is stored zero-access encrypted, so restapi refuses a server-side import into it (`specs/search.md` §3: the
|
|
91
|
+
* uploaded archive would wait readable in the blob store). Decided as restapi's `zeroAccessStatus()` decides it for mail: the
|
|
92
|
+
* administrator's "Allow zero-access encryption" (`allowZeroAccess`; unknown counts as allowed, so an import is never offered only to be
|
|
93
|
+
* refused), the owner's switch (unset is on), mail among the protected types (undecided is on), and a key that isn't revoked.
|
|
94
|
+
*/
|
|
95
|
+
export function mailIsZeroAccess(
|
|
96
|
+
mailbox: Pick<Mailbox, "zeroAccessEnabled" | "zeroAccessTypes" | "zeroAccessKeys">,
|
|
97
|
+
allowZeroAccess: boolean | undefined,
|
|
98
|
+
): boolean {
|
|
99
|
+
return (
|
|
100
|
+
allowZeroAccess !== false &&
|
|
101
|
+
mailbox.zeroAccessEnabled !== false &&
|
|
102
|
+
mailbox.zeroAccessTypes?.mail !== false &&
|
|
103
|
+
(mailbox.zeroAccessKeys ?? []).some((key) => !key.revokedAt)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Typed wrappers over `@rapidmx/restapi`'s zero-access search index (`/mail/mailboxes/:id/search-index`, `specs/search.md` §7-8): the
|
|
7
|
+
* sealed index entries the server writes at delivery and the weekly segments clients compact them into. Everything here is
|
|
8
|
+
* ciphertext encrypted to the mailbox's zero-access key - `apps/shared/search/zeroAccessIndexSync.ts` decrypts and imports it.
|
|
9
|
+
*/
|
|
10
|
+
import { type ApiClient, withClient, withClientBlob } from "../util/api.js";
|
|
11
|
+
|
|
12
|
+
export type SegmentOrigin = "zero-access" | "client";
|
|
13
|
+
|
|
14
|
+
/** A compacted segment as the manifest lists it. */
|
|
15
|
+
export interface IndexSegment {
|
|
16
|
+
weekId: string;
|
|
17
|
+
generation: number;
|
|
18
|
+
origin: SegmentOrigin;
|
|
19
|
+
bytes: number;
|
|
20
|
+
etag: string;
|
|
21
|
+
schemaVersion: number;
|
|
22
|
+
keyFingerprint: string;
|
|
23
|
+
entryCount: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A week's entries not yet compacted. */
|
|
27
|
+
export interface IndexWeekEntries {
|
|
28
|
+
weekId: string;
|
|
29
|
+
pendingEntries: number;
|
|
30
|
+
latestEntryAt: string;
|
|
31
|
+
/** The week has closed and its grace period has passed: a client may compact it. */
|
|
32
|
+
compactable: boolean;
|
|
33
|
+
/** When the week becomes (or became) compactable - where the platform stagger starts. Absent from a server before it. */
|
|
34
|
+
compactableAt?: string;
|
|
35
|
+
/** Size of the week's pending sealed entries (bytes of base64). Absent from a server before it. */
|
|
36
|
+
pendingBytes?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SegmentManifest {
|
|
40
|
+
mailboxUid: string;
|
|
41
|
+
segments: IndexSegment[];
|
|
42
|
+
weeks: IndexWeekEntries[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One pending entry: `sealed` is base64 CMS `EnvelopedData` of a gzipped JSON `ZeroAccessIndexDocument`. */
|
|
46
|
+
export interface SealedIndexEntry {
|
|
47
|
+
uid: string;
|
|
48
|
+
weekId: string;
|
|
49
|
+
messageUid: string;
|
|
50
|
+
createdAt: string;
|
|
51
|
+
keyFingerprint: string;
|
|
52
|
+
sealed: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface SealedIndexEntryPage {
|
|
56
|
+
entries: SealedIndexEntry[];
|
|
57
|
+
next?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What a sealed entry (and each document of a segment) decrypts to - restapi's `ZeroAccessIndexDocument`. */
|
|
61
|
+
export interface ZeroAccessIndexDocument {
|
|
62
|
+
v: 1;
|
|
63
|
+
messageUid: string;
|
|
64
|
+
subject: string;
|
|
65
|
+
body: string;
|
|
66
|
+
attachmentText: string[];
|
|
67
|
+
attachmentNames: string[];
|
|
68
|
+
deferredAttachments: string[];
|
|
69
|
+
from: string;
|
|
70
|
+
to: string[];
|
|
71
|
+
cc: string[];
|
|
72
|
+
participants: string[];
|
|
73
|
+
dateForSort: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `PUT .../segments/:weekId`. */
|
|
77
|
+
export interface SegmentUpload {
|
|
78
|
+
expectedGeneration: number;
|
|
79
|
+
/** Base64 of the sealed file. */
|
|
80
|
+
segment: string;
|
|
81
|
+
keyFingerprint: string;
|
|
82
|
+
schemaVersion: number;
|
|
83
|
+
origin?: SegmentOrigin;
|
|
84
|
+
foldedEntryUids?: string[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const base = (mailboxUid: string): string => `/mail/mailboxes/${encodeURIComponent(mailboxUid)}/search-index`;
|
|
88
|
+
|
|
89
|
+
export function getSearchIndexManifest(mailboxUid: string, client?: ApiClient): Promise<SegmentManifest> {
|
|
90
|
+
return withClient(client, `${base(mailboxUid)}/manifest`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One page of pending entries: of `week` when given, written at or after `since` when given, after uid `after`. */
|
|
94
|
+
export function listSearchIndexEntries(
|
|
95
|
+
mailboxUid: string,
|
|
96
|
+
options: { week?: string; since?: string; after?: string; limit?: number } = {},
|
|
97
|
+
client?: ApiClient,
|
|
98
|
+
): Promise<SealedIndexEntryPage> {
|
|
99
|
+
const query = new URLSearchParams();
|
|
100
|
+
for (const [key, value] of Object.entries(options)) {
|
|
101
|
+
if (value !== undefined) {
|
|
102
|
+
query.set(key, String(value));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const search = query.toString();
|
|
106
|
+
return withClient(client, `${base(mailboxUid)}/entries${search ? `?${search}` : ""}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The sealed file of a week's segment generation. */
|
|
110
|
+
export async function getSearchIndexSegment(
|
|
111
|
+
mailboxUid: string,
|
|
112
|
+
segment: Pick<IndexSegment, "weekId" | "generation" | "origin">,
|
|
113
|
+
client?: ApiClient,
|
|
114
|
+
): Promise<Uint8Array> {
|
|
115
|
+
const query = `generation=${segment.generation}${segment.origin === "client" ? "&origin=client" : ""}`;
|
|
116
|
+
const blob = await withClientBlob(client, `${base(mailboxUid)}/segments/${encodeURIComponent(segment.weekId)}?${query}`, "Could not download a search index segment.");
|
|
117
|
+
return new Uint8Array(await blob.arrayBuffer());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Stores a compaction. Rejects with a `409` when the week's segment changed since `upload.expectedGeneration` was read. */
|
|
121
|
+
export function putSearchIndexSegment(mailboxUid: string, weekId: string, upload: SegmentUpload, client?: ApiClient): Promise<IndexSegment> {
|
|
122
|
+
return withClient(client, `${base(mailboxUid)}/segments/${encodeURIComponent(weekId)}`, { method: "PUT", body: JSON.stringify(upload) });
|
|
123
|
+
}
|
package/lib/tasks/tasksApi.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import { ApiClient, withClient } from "../util/api.js";
|
|
9
9
|
import { ListParams, SETTINGS_LIST_LIMIT, buildQuery } from "../util/apiQuery.js";
|
|
10
|
+
import { guardSealedMove } from "../zeroAccess/sealedMoves.js";
|
|
11
|
+
import { protectedRecordWrite } from "../zeroAccess/sealedRecords.js";
|
|
10
12
|
|
|
11
13
|
export type TaskPriority = "low" | "normal" | "high";
|
|
12
14
|
|
|
@@ -31,6 +33,9 @@ export interface Task {
|
|
|
31
33
|
myDay?: boolean;
|
|
32
34
|
/** The uid of the user this task is assigned to, if any. */
|
|
33
35
|
assignedTo?: string;
|
|
36
|
+
/** The title and notes sealed to the mailbox's zero-access key while it protects tasks (`title` is then a placeholder) - opened with
|
|
37
|
+
* `openSealedRecords()`. Server-written. */
|
|
38
|
+
sealedFields?: Record<string, string> | null;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
/** `client`, given by every function below, is an explicit `ApiClient` from `createApiClient()` (e.g. one
|
|
@@ -78,13 +83,22 @@ export interface UpdateTaskInput {
|
|
|
78
83
|
taskListUid?: string;
|
|
79
84
|
myDay?: boolean;
|
|
80
85
|
assignedTo?: string;
|
|
86
|
+
/** A move to another mailbox, with `folderUid` there - refused for a protected task (`SealedMoveError`). */
|
|
87
|
+
mailboxUid?: string;
|
|
88
|
+
folderUid?: string;
|
|
81
89
|
}
|
|
82
90
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
/** Saves `input`. One moving the task to another mailbox (`mailboxUid`) is refused with `SealedMoveError` when the task is protected -
|
|
92
|
+
* before sending anything when `current` (the task as this device has it) says so, else from the server's refusal. With `current`, a
|
|
93
|
+
* protected task's sealed fields go only when they changed from it, and an emptied one is named in `clearedFields` when `current` was
|
|
94
|
+
* opened here (`protectedRecordWrite()`). */
|
|
95
|
+
export function updateTask(input: UpdateTaskInput, client?: ApiClient, current?: Task): Promise<Task> {
|
|
96
|
+
return guardSealedMove(current, input.mailboxUid, () =>
|
|
97
|
+
withClient<Task>(client, `/mail/tasks/${encodeURIComponent(input.uid)}`, {
|
|
98
|
+
method: "PUT",
|
|
99
|
+
body: JSON.stringify(protectedRecordWrite(current, input)),
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
/** Toggles a task's `completed` flag in place — same pattern as `mailApi.ts`'s `setMessageRead`. */
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* What kind of client this is, for work that should prefer one over another - the search index's week compaction, which a phone
|
|
7
|
+
* should leave to a computer (restapi `specs/search.md` §8 *Who compacts*).
|
|
8
|
+
*
|
|
9
|
+
* - `"desktop"`: a native app on a computer (`tauri-client`, `electron-client`), which installs `window.rapidmx`.
|
|
10
|
+
* - `"mobile"`: a native app on a phone or tablet (the host sets `window.rapidmx.formFactor: "mobile"`), or a phone or tablet browser.
|
|
11
|
+
* - `"web"`: any other browser.
|
|
12
|
+
*/
|
|
13
|
+
export type ClientPlatform = "desktop" | "web" | "mobile";
|
|
14
|
+
|
|
15
|
+
interface NavigatorHints {
|
|
16
|
+
userAgent?: string;
|
|
17
|
+
userAgentData?: { mobile?: boolean };
|
|
18
|
+
connection?: { type?: string; saveData?: boolean };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MOBILE_USER_AGENT = /Android|iPhone|iPad|iPod|Mobile/i;
|
|
22
|
+
|
|
23
|
+
function navigatorHints(): NavigatorHints | undefined {
|
|
24
|
+
return typeof navigator === "undefined" ? undefined : navigator;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether this browser runs on a phone or tablet, by its User-Agent Client Hints, else its user agent string. */
|
|
28
|
+
function isMobileBrowser(): boolean {
|
|
29
|
+
const hints = navigatorHints();
|
|
30
|
+
if (typeof hints?.userAgentData?.mobile === "boolean") {
|
|
31
|
+
return hints.userAgentData.mobile;
|
|
32
|
+
}
|
|
33
|
+
return MOBILE_USER_AGENT.test(hints?.userAgent ?? "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** This client's platform (see the module comment). */
|
|
37
|
+
export function clientPlatform(): ClientPlatform {
|
|
38
|
+
const host = typeof window === "undefined" ? undefined : (window as { rapidmx?: { formFactor?: string } }).rapidmx;
|
|
39
|
+
if (host) {
|
|
40
|
+
return host.formFactor === "mobile" ? "mobile" : "desktop";
|
|
41
|
+
}
|
|
42
|
+
return isMobileBrowser() ? "mobile" : "web";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whether the connection is one to spare: cellular, or the user asked to save data (Network Information API, where the browser
|
|
46
|
+
* has it - absent, the connection is taken as unconstrained). */
|
|
47
|
+
export function isConstrainedConnection(): boolean {
|
|
48
|
+
const connection = navigatorHints()?.connection;
|
|
49
|
+
return connection?.saveData === true || connection?.type === "cellular";
|
|
50
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The organizer's side of a protected video meeting (meet-plugin `specs/meeting_privacy.md`): a meeting whose organizer has a zero-access
|
|
7
|
+
* key gets a temporary meeting key, made here. Its title and invitees' names are sealed to the meeting key and the organizer's
|
|
8
|
+
* zero-access certificate; the meeting key itself is wrapped to the organizer's zero-access certificate, so any of the organizer's
|
|
9
|
+
* devices can reopen it; and every participant's link carries the key after `#k=`, which this module supplies on each save that sends an
|
|
10
|
+
* invitation (`invitationContent.meetingLinkSecrets`) and on the organizer's own join link - never stored by the server.
|
|
11
|
+
*
|
|
12
|
+
* The meeting key is discarded an hour after the event ends (the last occurrence's end for a series with an end; five years at most).
|
|
13
|
+
*/
|
|
14
|
+
import { fromBase64, toBase64 } from "../crypto/encoding.js";
|
|
15
|
+
import { getUnlockedKeys, subscribeKeySession } from "../crypto/keySession.js";
|
|
16
|
+
import { generateMeetingKey, meetingKeyFragment } from "../crypto/meetingKeys.js";
|
|
17
|
+
import { decryptEnvelopedDataWithKeys, encryptForRecipients } from "../crypto/smime.js";
|
|
18
|
+
import type { ApiClient } from "../util/api.js";
|
|
19
|
+
import { getVideoMeeting, updateVideoMeeting, type VideoMeetingDetail, type VideoMeetingInvitee } from "./videoMeetingsApi.js";
|
|
20
|
+
|
|
21
|
+
/** How long after the meeting ends its key is kept (`mail:videoconf:key_grace_minutes`' default). */
|
|
22
|
+
export const MEETING_KEY_GRACE_MS = 60 * 60_000;
|
|
23
|
+
|
|
24
|
+
/** The longest a meeting key is kept: a series without an end keeps it this long, just inside the server's limit. */
|
|
25
|
+
const MAX_KEY_LIFETIME_MS = 5 * 365 * 24 * 60 * 60_000;
|
|
26
|
+
|
|
27
|
+
/** What `createVideoMeeting()` sends to protect a meeting. */
|
|
28
|
+
export interface MeetingProtectionInput {
|
|
29
|
+
meetingKey: { certificate: string };
|
|
30
|
+
keyExpiresAt: string;
|
|
31
|
+
keyWraps: { mailboxUid: string; recipientFingerprint: string; kind: "meeting-key"; cms: string }[];
|
|
32
|
+
sealedFields: { title: string };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A protected meeting's fields, as the server returns them to its organizer. */
|
|
36
|
+
export interface ProtectedMeetingFields {
|
|
37
|
+
keyProtected?: boolean | null;
|
|
38
|
+
keyWraps?: { mailboxUid: string; recipientFingerprint: string; kind: string; cms: string }[] | null;
|
|
39
|
+
/** When the meeting key is discarded (ISO). */
|
|
40
|
+
keyExpiresAt?: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Link keys by meeting uid, each with the organizer mailbox it belongs to: a protected meeting's key is opened once, and forgotten when
|
|
44
|
+
* that mailbox's keys are locked. `fragment: null`: a meeting known to be unprotected. */
|
|
45
|
+
const fragments = new Map<string, { mailboxUid: string; fragment: string | null }>();
|
|
46
|
+
|
|
47
|
+
/** Forgets every opened meeting key (sign-out, or the test seam). */
|
|
48
|
+
export function clearMeetingKeys(): void {
|
|
49
|
+
fragments.clear();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let watchingKeys = false;
|
|
53
|
+
|
|
54
|
+
/** Records `uid`'s link key, forgetting it - like everything opened with a mailbox's keys - once that mailbox locks. Subscribed on the
|
|
55
|
+
* first key remembered, not on import. */
|
|
56
|
+
function remember(uid: string, mailboxUid: string, fragment: string | null): void {
|
|
57
|
+
if (!watchingKeys) {
|
|
58
|
+
watchingKeys = true;
|
|
59
|
+
subscribeKeySession(({ mailboxUid: locked, state }) => {
|
|
60
|
+
if (state === "locked") {
|
|
61
|
+
for (const [meetingUid, entry] of fragments) {
|
|
62
|
+
if (entry.mailboxUid === locked) {
|
|
63
|
+
fragments.delete(meetingUid);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// A key opened or minted across an await the mailbox locked during: the lock's clear has already run, so it isn't kept.
|
|
70
|
+
if (fragment !== null && !getUnlockedKeys(mailboxUid)) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
fragments.set(uid, { mailboxUid, fragment });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Meeting `uid`'s remembered link key - `null` for one known to be unprotected - or `undefined` when none is remembered, or its mailbox
|
|
77
|
+
* isn't unlocked here (a key is never handed out past its mailbox's lock). */
|
|
78
|
+
function rememberedFragment(uid: string): string | null | undefined {
|
|
79
|
+
const entry = fragments.get(uid);
|
|
80
|
+
if (!entry || (entry.fragment !== null && !getUnlockedKeys(entry.mailboxUid))) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
return entry.fragment;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** When a meeting ending at `endDate` - the last occurrence's end, for a series that has one - has its key discarded. */
|
|
87
|
+
export function meetingKeyExpiry(endDate: Date, now: number = Date.now()): Date {
|
|
88
|
+
return new Date(Math.min(endDate.valueOf() + MEETING_KEY_GRACE_MS, now + MAX_KEY_LIFETIME_MS));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The organizer mailbox's active zero-access key, while the mailbox is unlocked. */
|
|
92
|
+
function organizerKey(mailboxUid: string): { certDer: Uint8Array; fingerprint: string; privateKey: CryptoKey } | undefined {
|
|
93
|
+
return getUnlockedKeys(mailboxUid)?.zeroAccessKeys?.[0];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What protects a new meeting of `mailboxUid` titled `title` with `invitees`, its key discarded at `expiresAt` - or `undefined` when
|
|
97
|
+
* the mailbox has no zero-access key unlocked here (the meeting is then not protected). `fragment` is every participant's link key. */
|
|
98
|
+
export async function protectMeeting(
|
|
99
|
+
mailboxUid: string,
|
|
100
|
+
title: string,
|
|
101
|
+
invitees: VideoMeetingInvitee[],
|
|
102
|
+
expiresAt: Date,
|
|
103
|
+
): Promise<{ protection: MeetingProtectionInput; invitees: { email: string; sealedFields?: { displayName: string } }[]; fragment: string } | undefined> {
|
|
104
|
+
const organizer = organizerKey(mailboxUid);
|
|
105
|
+
if (!organizer) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
const key = await generateMeetingKey(expiresAt);
|
|
109
|
+
const seal = async (value: unknown): Promise<string> =>
|
|
110
|
+
toBase64(await encryptForRecipients(new TextEncoder().encode(JSON.stringify(value)), [key.certificateDer, organizer.certDer]));
|
|
111
|
+
return {
|
|
112
|
+
protection: {
|
|
113
|
+
meetingKey: { certificate: toBase64(key.certificateDer) },
|
|
114
|
+
keyExpiresAt: expiresAt.toISOString(),
|
|
115
|
+
keyWraps: [{ mailboxUid, recipientFingerprint: organizer.fingerprint, kind: "meeting-key", cms: toBase64(await encryptForRecipients(key.pkcs8, [organizer.certDer])) }],
|
|
116
|
+
sealedFields: { title: await seal(title) },
|
|
117
|
+
},
|
|
118
|
+
invitees: await Promise.all(
|
|
119
|
+
invitees.map(async (invitee) => (invitee.displayName ? { email: invitee.email, sealedFields: { displayName: await seal(invitee.displayName) } } : { email: invitee.email })),
|
|
120
|
+
),
|
|
121
|
+
fragment: meetingKeyFragment(key.pkcs8),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Remembers a just-created meeting's link key - `null` for one that isn't protected - so the save that invites to it needn't fetch the
|
|
126
|
+
* meeting or reopen its wrap. `mailboxUid` is the organizer's mailbox: its lock forgets the key. */
|
|
127
|
+
export function rememberMeetingKey(meetingUid: string, fragment: string | null, mailboxUid: string): void {
|
|
128
|
+
remember(meetingUid, mailboxUid, fragment);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** `meeting`'s link key - remembered, or opened from the organizer's wrap with the mailbox's unlocked zero-access keys - or `undefined`
|
|
132
|
+
* when it isn't protected or can't be opened here. Never rejects. */
|
|
133
|
+
export async function meetingKeyFragmentOf(meeting: VideoMeetingDetail & ProtectedMeetingFields): Promise<string | undefined> {
|
|
134
|
+
if (!meeting.keyProtected) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
const known = rememberedFragment(meeting.uid);
|
|
138
|
+
if (known !== undefined) {
|
|
139
|
+
return known ?? undefined;
|
|
140
|
+
}
|
|
141
|
+
const keys = getUnlockedKeys(meeting.mailboxUid)?.zeroAccessKeys;
|
|
142
|
+
const wrap = meeting.keyWraps?.find((candidate) => keys?.some((key) => key.fingerprint === candidate.recipientFingerprint));
|
|
143
|
+
if (!keys?.length || !wrap) {
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const fragment = meetingKeyFragment(await decryptEnvelopedDataWithKeys(fromBase64(wrap.cms), keys));
|
|
148
|
+
// Locked while it was opened: neither kept nor handed out.
|
|
149
|
+
if (!getUnlockedKeys(meeting.mailboxUid)) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
remember(meeting.uid, meeting.mailboxUid, fragment);
|
|
153
|
+
return fragment;
|
|
154
|
+
} catch {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** `meeting`'s organizer join link, with the meeting key after `#` when it is protected - `null` when there is none, or the key can't
|
|
160
|
+
* be opened here (a protected meeting can't be joined without it). */
|
|
161
|
+
export async function organizerJoinLink(meeting: VideoMeetingDetail & ProtectedMeetingFields): Promise<string | null> {
|
|
162
|
+
if (!meeting.organizerJoinUrl) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
if (!meeting.keyProtected) {
|
|
166
|
+
return meeting.organizerJoinUrl;
|
|
167
|
+
}
|
|
168
|
+
const fragment = await meetingKeyFragmentOf(meeting);
|
|
169
|
+
return fragment ? `${meeting.organizerJoinUrl}#${fragment}` : null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** For a save of `event` that may invite: every attendee's link key (`invitationContent.meetingLinkSecrets`), when its video meeting is
|
|
173
|
+
* protected and its key opens here - else `undefined`. Never rejects. */
|
|
174
|
+
export async function meetingLinkSecrets(
|
|
175
|
+
event: { videoMeetingUid?: string | null; attendees?: { address: string }[]; organizer?: { address: string } },
|
|
176
|
+
client?: ApiClient,
|
|
177
|
+
): Promise<Record<string, string> | undefined> {
|
|
178
|
+
if (!event.videoMeetingUid) {
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const known = rememberedFragment(event.videoMeetingUid);
|
|
183
|
+
const fragment = known !== undefined ? known : await meetingKeyFragmentOf(await getVideoMeeting(event.videoMeetingUid, client));
|
|
184
|
+
if (!fragment) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
const organizer = event.organizer?.address.trim().toLowerCase();
|
|
188
|
+
const secrets: Record<string, string> = {};
|
|
189
|
+
for (const attendee of event.attendees ?? []) {
|
|
190
|
+
const address = attendee.address.trim().toLowerCase();
|
|
191
|
+
if (address && address !== organizer) {
|
|
192
|
+
secrets[address] = fragment;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return Object.keys(secrets).length > 0 ? secrets : undefined;
|
|
196
|
+
} catch {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Moves the key expiry of meeting `meetingUid` - when it is protected - to follow its event's new end, `endDate` (the last occurrence's
|
|
203
|
+
* end, for a series): its key is discarded an hour after the end it was made for, so a meeting moved later (or a series extended) would
|
|
204
|
+
* otherwise be unjoinable by then. Best effort, after the event itself is saved: a failure is logged, never thrown.
|
|
205
|
+
*/
|
|
206
|
+
export async function followMeetingKeyExpiry(meetingUid: string, endDate: Date, client?: ApiClient): Promise<void> {
|
|
207
|
+
try {
|
|
208
|
+
if (fragments.get(meetingUid)?.fragment === null) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const meeting: VideoMeetingDetail & ProtectedMeetingFields = await getVideoMeeting(meetingUid, client);
|
|
212
|
+
const keyExpiresAt = meetingKeyExpiry(endDate);
|
|
213
|
+
// Unprotected, or already right to the minute (an unchanged end).
|
|
214
|
+
if (!meeting.keyProtected || (meeting.keyExpiresAt && Math.abs(new Date(meeting.keyExpiresAt).valueOf() - keyExpiresAt.valueOf()) < 60_000)) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
await updateVideoMeeting(meetingUid, { keyExpiresAt: keyExpiresAt.toISOString() }, client);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.warn("Could not move the video meeting's key expiry to the event's new end", err);
|
|
220
|
+
}
|
|
221
|
+
}
|