@rapidmx/web-client 0.36.0 → 0.37.1
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/shared/calendar/calendarReminders.ts +35 -0
- package/apps/shared/calendar/sealedEvents.ts +42 -9
- 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 +29 -1
- 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 +6 -3
- 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 +11 -4
- package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
- package/apps/shared/components/mail/InviteCard.tsx +11 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
- 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/protectedContactSuggestions.ts +121 -0
- package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
- package/apps/shared/components/mail/pinnedSigners.ts +14 -0
- package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
- package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
- package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
- package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
- package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
- package/apps/shared/mail/outbox/sendJob.ts +349 -50
- package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
- package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
- package/apps/shared/search/localIndexBuilder.ts +35 -24
- package/apps/shared/search/localIndexRpcClient.ts +91 -11
- package/apps/shared/search/localIndexSchema.ts +18 -2
- package/apps/shared/search/localIndexTransport.ts +6 -3
- package/apps/shared/search/localIndexWorker.ts +35 -18
- package/apps/shared/search/searchTier2.ts +5 -0
- package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
- 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 +49 -5
- package/apps/www/contacts/index.tsx +67 -11
- package/apps/www/index.tsx +10 -1
- package/apps/www/settings/encryption/index.tsx +16 -1
- package/apps/www/settings/privacy/index.tsx +87 -38
- package/apps/www/tasks/index.tsx +24 -6
- 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 +3 -2
- package/dist/apps/shared/calendar/sealedEvents.js +40 -9
- 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 +9 -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 +6 -3
- 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 +10 -4
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
- package/dist/apps/shared/components/mail/InviteCard.js +11 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
- 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/protectedContactSuggestions.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -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/settings/KeyExportSetting.js +3 -1
- 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/ZeroAccessSetting.d.ts +2 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
- 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/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/search/LocalIndexLifecycle.js +55 -33
- package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
- package/dist/apps/shared/search/localIndexBuilder.js +31 -20
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
- package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
- package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
- package/dist/apps/shared/search/localIndexSchema.js +16 -2
- package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
- package/dist/apps/shared/search/localIndexWorker.d.ts +9 -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 +37 -6
- package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
- 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 +47 -5
- package/dist/apps/www/contacts/index.js +64 -11
- package/dist/apps/www/index.js +10 -1
- package/dist/apps/www/settings/encryption/index.js +14 -4
- package/dist/apps/www/settings/privacy/index.js +46 -5
- package/dist/apps/www/tasks/index.js +21 -7
- package/dist/lib/admin/domainsApi.d.ts +1 -1
- package/dist/lib/calendar/calendarApi.d.ts +46 -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 +61 -4
- 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 +6 -8
- package/dist/lib/calendar/sealedInvites.js +43 -6
- 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.js +2 -2
- package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
- package/dist/lib/crypto/keyPairMatch.js +65 -0
- package/dist/lib/crypto/keySession.d.ts +19 -2
- package/dist/lib/crypto/keySession.js +89 -13
- package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
- package/dist/lib/crypto/keyvaultApi.js +13 -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 +26 -2
- package/dist/lib/crypto/messageSecurity.js +122 -10
- 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 +15 -3
- package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
- package/dist/lib/crypto/smimeMessage.d.ts +21 -0
- package/dist/lib/crypto/smimeMessage.js +11 -7
- package/dist/lib/crypto/zeroAccessKey.js +5 -0
- package/dist/lib/mail/mailApi.d.ts +33 -0
- package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
- package/dist/lib/mail/mailboxImportApi.js +15 -0
- package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -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/calendar/calendarApi.ts +140 -17
- package/lib/calendar/calendarMutations.ts +79 -12
- package/lib/calendar/inviteApi.ts +75 -5
- package/lib/calendar/protectedEvents.ts +143 -0
- package/lib/calendar/recurrence.ts +17 -0
- package/lib/calendar/sealedInvites.ts +47 -7
- 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 +2 -2
- package/lib/crypto/keyPairMatch.ts +69 -0
- package/lib/crypto/keySession.ts +102 -12
- package/lib/crypto/keyvaultApi.ts +49 -7
- package/lib/crypto/meetingKeys.ts +118 -0
- package/lib/crypto/messageSecurity.ts +174 -13
- 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 +106 -28
- package/lib/crypto/smimeMessage.ts +11 -7
- package/lib/crypto/zeroAccessKey.ts +5 -0
- package/lib/mail/mailApi.ts +27 -0
- package/lib/mail/mailboxImportApi.ts +24 -0
- package/lib/search/zeroAccessIndexApi.ts +4 -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 +12 -3
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A calendar event, contact or task whose content is sealed to its mailbox's zero-access key - field by field (`sealedFields`), as one
|
|
3
|
+
* blob (`sealedContent`, an event from a zero-access invitation) or its photo (`photoSealed`) - can't move to another mailbox: what it
|
|
4
|
+
* keeps sealed is addressed to the mailbox it is in, and the server can't seal it again to the destination's key. restapi refuses such a
|
|
5
|
+
* move with a `409` (`ZeroAccessRouteSealing.refuseSealedMove()`).
|
|
6
|
+
*
|
|
7
|
+
* The record API wrappers (`updateContact()`, `updateCalendarEvent()`, `updateTask()`) refuse it before sending anything when they know the
|
|
8
|
+
* record (`guardSealedMove()`), and turn the server's refusal into the same `SealedMoveError` when they don't - one request, so nothing is
|
|
9
|
+
* left half moved. A UI offering a move asks `canMoveToMailbox()` first, and shows `SealedMoveError`'s message.
|
|
10
|
+
*/
|
|
11
|
+
import { ApiRequestError } from "../util/api.js";
|
|
12
|
+
/** The fields of a record that say whether its content is sealed, and where. */
|
|
13
|
+
export interface MovableRecord {
|
|
14
|
+
mailboxUid: string;
|
|
15
|
+
sealedFields?: Record<string, string> | null;
|
|
16
|
+
sealedContent?: unknown;
|
|
17
|
+
photoSealed?: boolean | null;
|
|
18
|
+
}
|
|
19
|
+
/** What a refused move says. */
|
|
20
|
+
export declare const SEALED_MOVE_MESSAGE = "This item is protected by its mailbox's zero-access encryption, so it can't be moved to another mailbox. Create a copy there instead.";
|
|
21
|
+
/** `SealedMoveError.code`. */
|
|
22
|
+
export declare const SEALED_MOVE_CODE = "sealed-move";
|
|
23
|
+
/** restapi's `ApiErrors.INVALID_REQUEST`, the code of its sealed-move refusal and of its other zero-access refusals (a version conflict,
|
|
24
|
+
* also a `409`, is `api-012`; an identifier already taken, `api-011`). */
|
|
25
|
+
export declare const INVALID_REQUEST_CODE = "api-003";
|
|
26
|
+
/** A move of a protected record to another mailbox, refused - by this client before sending it, or by the server. Nothing was written. */
|
|
27
|
+
export declare class SealedMoveError extends ApiRequestError {
|
|
28
|
+
constructor(message?: string);
|
|
29
|
+
}
|
|
30
|
+
/** Whether `record` keeps any of its content sealed to its mailbox's key. */
|
|
31
|
+
export declare function isSealedRecord(record: MovableRecord): boolean;
|
|
32
|
+
/** Whether `record` may move to mailbox `targetMailboxUid`: always within its own mailbox, and to another only when nothing of it is sealed. */
|
|
33
|
+
export declare function canMoveToMailbox(record: MovableRecord, targetMailboxUid: string): boolean;
|
|
34
|
+
/** Whether `err` is restapi's refusal of a sealed record's move. */
|
|
35
|
+
export declare function isSealedMoveRefusal(err: unknown): err is ApiRequestError;
|
|
36
|
+
/**
|
|
37
|
+
* Runs `write`, a save of a record that sets its `mailboxUid` to `targetMailboxUid` (`undefined`: the save doesn't set it). With `current`,
|
|
38
|
+
* the record as this device has it, a move of a sealed one is refused before anything is sent. A move the server refuses for the same reason
|
|
39
|
+
* rejects with `SealedMoveError` too.
|
|
40
|
+
*/
|
|
41
|
+
export declare function guardSealedMove<T>(current: MovableRecord | undefined, targetMailboxUid: string | undefined, write: () => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* A calendar event, contact or task whose content is sealed to its mailbox's zero-access key - field by field (`sealedFields`), as one
|
|
7
|
+
* blob (`sealedContent`, an event from a zero-access invitation) or its photo (`photoSealed`) - can't move to another mailbox: what it
|
|
8
|
+
* keeps sealed is addressed to the mailbox it is in, and the server can't seal it again to the destination's key. restapi refuses such a
|
|
9
|
+
* move with a `409` (`ZeroAccessRouteSealing.refuseSealedMove()`).
|
|
10
|
+
*
|
|
11
|
+
* The record API wrappers (`updateContact()`, `updateCalendarEvent()`, `updateTask()`) refuse it before sending anything when they know the
|
|
12
|
+
* record (`guardSealedMove()`), and turn the server's refusal into the same `SealedMoveError` when they don't - one request, so nothing is
|
|
13
|
+
* left half moved. A UI offering a move asks `canMoveToMailbox()` first, and shows `SealedMoveError`'s message.
|
|
14
|
+
*/
|
|
15
|
+
import { ApiRequestError } from "../util/api.js";
|
|
16
|
+
/** What a refused move says. */
|
|
17
|
+
export const SEALED_MOVE_MESSAGE = "This item is protected by its mailbox's zero-access encryption, so it can't be moved to another mailbox. Create a copy there instead.";
|
|
18
|
+
/** `SealedMoveError.code`. */
|
|
19
|
+
export const SEALED_MOVE_CODE = "sealed-move";
|
|
20
|
+
/** restapi's `ApiErrors.INVALID_REQUEST`, the code of its sealed-move refusal and of its other zero-access refusals (a version conflict,
|
|
21
|
+
* also a `409`, is `api-012`; an identifier already taken, `api-011`). */
|
|
22
|
+
export const INVALID_REQUEST_CODE = "api-003";
|
|
23
|
+
/** A move of a protected record to another mailbox, refused - by this client before sending it, or by the server. Nothing was written. */
|
|
24
|
+
export class SealedMoveError extends ApiRequestError {
|
|
25
|
+
constructor(message = SEALED_MOVE_MESSAGE) {
|
|
26
|
+
super(message, 409, SEALED_MOVE_CODE);
|
|
27
|
+
this.name = "SealedMoveError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Whether `record` keeps any of its content sealed to its mailbox's key. */
|
|
31
|
+
export function isSealedRecord(record) {
|
|
32
|
+
return (!!record.sealedFields && Object.keys(record.sealedFields).length > 0) || !!record.sealedContent || !!record.photoSealed;
|
|
33
|
+
}
|
|
34
|
+
/** Whether `record` may move to mailbox `targetMailboxUid`: always within its own mailbox, and to another only when nothing of it is sealed. */
|
|
35
|
+
export function canMoveToMailbox(record, targetMailboxUid) {
|
|
36
|
+
return targetMailboxUid === record.mailboxUid || !isSealedRecord(record);
|
|
37
|
+
}
|
|
38
|
+
/** Whether `err` is restapi's refusal of a sealed record's move. */
|
|
39
|
+
export function isSealedMoveRefusal(err) {
|
|
40
|
+
return err instanceof SealedMoveError || (err instanceof ApiRequestError && err.status === 409 && err.code === INVALID_REQUEST_CODE);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Runs `write`, a save of a record that sets its `mailboxUid` to `targetMailboxUid` (`undefined`: the save doesn't set it). With `current`,
|
|
44
|
+
* the record as this device has it, a move of a sealed one is refused before anything is sent. A move the server refuses for the same reason
|
|
45
|
+
* rejects with `SealedMoveError` too.
|
|
46
|
+
*/
|
|
47
|
+
export async function guardSealedMove(current, targetMailboxUid, write) {
|
|
48
|
+
if (targetMailboxUid === undefined) {
|
|
49
|
+
return write();
|
|
50
|
+
}
|
|
51
|
+
if (current && !canMoveToMailbox(current, targetMailboxUid)) {
|
|
52
|
+
throw new SealedMoveError();
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return await write();
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
if (current?.mailboxUid !== targetMailboxUid && isSealedMoveRefusal(err) && !(err instanceof SealedMoveError)) {
|
|
59
|
+
throw new SealedMoveError();
|
|
60
|
+
}
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** A record that may carry sealed fields. */
|
|
2
|
+
export interface SealedRecord {
|
|
3
|
+
mailboxUid: string;
|
|
4
|
+
/** Protected field name -> base64 CMS of its JSON value. */
|
|
5
|
+
sealedFields?: Record<string, string> | null;
|
|
6
|
+
}
|
|
7
|
+
/** Forgets every opened value (a mailbox locking, or the test seam). */
|
|
8
|
+
export declare function clearSealedRecords(): void;
|
|
9
|
+
/** Whether every protected field of `record` holds its real value - opened on this device - rather than its placeholder. */
|
|
10
|
+
export declare function isOpenedRecord(record: SealedRecord | undefined): boolean;
|
|
11
|
+
/** Whether `record` has protected fields. */
|
|
12
|
+
export declare function isProtectedRecord(record: SealedRecord | undefined): boolean;
|
|
13
|
+
/** `records` with every protected one this device can open shown with its real values - only the fields in `only`, when given (a list
|
|
14
|
+
* shows a contact's name before anyone asks for its phone numbers: each field is one decryption). Never rejects. */
|
|
15
|
+
export declare function openSealedRecords<T extends SealedRecord>(records: T[], only?: readonly string[]): Promise<T[]>;
|
|
16
|
+
/** `der` (a blob the server sealed to `mailboxUid`'s zero-access key, e.g. a protected contact's photo) opened, or `undefined` when this
|
|
17
|
+
* device can't (the mailbox is locked, or it isn't its key). */
|
|
18
|
+
export declare function openSealedBytes(mailboxUid: string, der: Uint8Array): Promise<Uint8Array | undefined>;
|
|
19
|
+
/**
|
|
20
|
+
* `input` as written to `shown` - the record the edit started from, as this device showed it: unchanged when `shown` isn't protected.
|
|
21
|
+
* Otherwise without the protected fields (those `shown.sealedFields` names) whose values are what `shown` has: an opened record's write
|
|
22
|
+
* sends only what changed, and a placeholder a record that couldn't be opened showed is never sent back as its value. A field of an opened
|
|
23
|
+
* record that held a value and is emptied is named in `clearedFields`: restapi applies an empty value of a sealed field as a clear only
|
|
24
|
+
* then (`ZeroAccessRouteSealing.takeClearedFields()`), since an empty placeholder sent back by a client that couldn't open the record would
|
|
25
|
+
* otherwise wipe the sealed value for good. `opened`: `shown` holds the real values (by default, whether it was opened here).
|
|
26
|
+
*/
|
|
27
|
+
export declare function protectedRecordWrite<T extends object>(shown: SealedRecord | undefined, input: T, opened?: boolean): T;
|
|
28
|
+
/** `openSealedRecords()` for one record. */
|
|
29
|
+
export declare function openSealedRecord<T extends SealedRecord>(record: T, only?: readonly string[]): Promise<T>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Calendar events, contacts and tasks of a mailbox whose zero-access encryption covers their type keep their protected fields only
|
|
7
|
+
* sealed, field by field, to the mailbox's zero-access key (`sealedFields`; the readable column holds a placeholder - restapi
|
|
8
|
+
* `specs/search.md` §3 *What It Covers*). They are opened here with the mailbox's unlocked keys; while it is locked a record keeps its
|
|
9
|
+
* placeholders. The decryption code is loaded only when a sealed record is actually seen.
|
|
10
|
+
*
|
|
11
|
+
* Writing needs nothing here: the client sends plaintext and the server seals it on arrival.
|
|
12
|
+
*/
|
|
13
|
+
import { fromBase64 } from "../crypto/encoding.js";
|
|
14
|
+
import { getUnlockedKeys, subscribeKeySession } from "../crypto/keySession.js";
|
|
15
|
+
/** Opened values by sealed text - the same sealed text always opens to the same value. Cleared with `clearSealedRecords()`. */
|
|
16
|
+
const opened = new Map();
|
|
17
|
+
/** Forgets every opened value (a mailbox locking, or the test seam). */
|
|
18
|
+
export function clearSealedRecords() {
|
|
19
|
+
opened.clear();
|
|
20
|
+
}
|
|
21
|
+
/** Set on a record whose every sealed field was opened. A symbol, so it survives a spread copy (an occurrence of an event) and is never sent. */
|
|
22
|
+
const OPENED = Symbol("openedSealedRecord");
|
|
23
|
+
/** Whether every protected field of `record` holds its real value - opened on this device - rather than its placeholder. */
|
|
24
|
+
export function isOpenedRecord(record) {
|
|
25
|
+
return !!record && record[OPENED] === true;
|
|
26
|
+
}
|
|
27
|
+
/** Whether `record` has protected fields. */
|
|
28
|
+
export function isProtectedRecord(record) {
|
|
29
|
+
return !!record?.sealedFields && Object.keys(record.sealedFields).length > 0;
|
|
30
|
+
}
|
|
31
|
+
/** `record` with every sealed field (of `only`, when given) this device can open set to its value; `undefined` when it can't open them
|
|
32
|
+
* (locked, or not its key). */
|
|
33
|
+
async function openRecord(record, only) {
|
|
34
|
+
const keys = getUnlockedKeys(record.mailboxUid)?.zeroAccessKeys;
|
|
35
|
+
if (!keys?.length) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const values = {};
|
|
40
|
+
for (const [name, sealed] of Object.entries(record.sealedFields)) {
|
|
41
|
+
if (only && !only.includes(name)) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (!opened.has(sealed)) {
|
|
45
|
+
const { decryptEnvelopedDataWithKeys } = await import("../crypto/smime.js");
|
|
46
|
+
const value = JSON.parse(new TextDecoder().decode(await decryptEnvelopedDataWithKeys(fromBase64(sealed), keys)));
|
|
47
|
+
// The mailbox locked while this was opened: the lock's clear has already run, so nothing is kept or shown.
|
|
48
|
+
if (!getUnlockedKeys(record.mailboxUid)) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
opened.set(sealed, value);
|
|
52
|
+
}
|
|
53
|
+
values[name] = opened.get(sealed);
|
|
54
|
+
}
|
|
55
|
+
return only ? { ...record, ...values } : { ...record, ...values, [OPENED]: true };
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let watchingKeys = false;
|
|
62
|
+
/** Opened values go when keys are locked (which signing out does), so nothing opened outlives its key session. Subscribed when the
|
|
63
|
+
* first protected record is opened, not on import. */
|
|
64
|
+
function forgetOnLock() {
|
|
65
|
+
if (watchingKeys) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
watchingKeys = true;
|
|
69
|
+
subscribeKeySession((event) => {
|
|
70
|
+
if (event.state === "locked") {
|
|
71
|
+
clearSealedRecords();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** `records` with every protected one this device can open shown with its real values - only the fields in `only`, when given (a list
|
|
76
|
+
* shows a contact's name before anyone asks for its phone numbers: each field is one decryption). Never rejects. */
|
|
77
|
+
export async function openSealedRecords(records, only) {
|
|
78
|
+
if (!records.some(isProtectedRecord)) {
|
|
79
|
+
return records;
|
|
80
|
+
}
|
|
81
|
+
forgetOnLock();
|
|
82
|
+
return Promise.all(records.map(async (record) => (isProtectedRecord(record) ? ((await openRecord(record, only)) ?? record) : record)));
|
|
83
|
+
}
|
|
84
|
+
/** `der` (a blob the server sealed to `mailboxUid`'s zero-access key, e.g. a protected contact's photo) opened, or `undefined` when this
|
|
85
|
+
* device can't (the mailbox is locked, or it isn't its key). */
|
|
86
|
+
export async function openSealedBytes(mailboxUid, der) {
|
|
87
|
+
const keys = getUnlockedKeys(mailboxUid)?.zeroAccessKeys;
|
|
88
|
+
if (!keys?.length) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const { decryptEnvelopedDataWithKeys } = await import("../crypto/smime.js");
|
|
93
|
+
const bytes = await decryptEnvelopedDataWithKeys(der, keys);
|
|
94
|
+
// Locked while it was opened: not handed out.
|
|
95
|
+
return getUnlockedKeys(mailboxUid) ? bytes : undefined;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** A field's value for comparing what a write sends with what was shown: every empty form (`null`, `undefined`, `""`, `[]`) is one. */
|
|
102
|
+
function comparable(value) {
|
|
103
|
+
return value === null || value === undefined || value === "" || (Array.isArray(value) && value.length === 0) ? "" : JSON.stringify(value);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* `input` as written to `shown` - the record the edit started from, as this device showed it: unchanged when `shown` isn't protected.
|
|
107
|
+
* Otherwise without the protected fields (those `shown.sealedFields` names) whose values are what `shown` has: an opened record's write
|
|
108
|
+
* sends only what changed, and a placeholder a record that couldn't be opened showed is never sent back as its value. A field of an opened
|
|
109
|
+
* record that held a value and is emptied is named in `clearedFields`: restapi applies an empty value of a sealed field as a clear only
|
|
110
|
+
* then (`ZeroAccessRouteSealing.takeClearedFields()`), since an empty placeholder sent back by a client that couldn't open the record would
|
|
111
|
+
* otherwise wipe the sealed value for good. `opened`: `shown` holds the real values (by default, whether it was opened here).
|
|
112
|
+
*/
|
|
113
|
+
export function protectedRecordWrite(shown, input, opened = isOpenedRecord(shown)) {
|
|
114
|
+
if (!isProtectedRecord(shown)) {
|
|
115
|
+
return input;
|
|
116
|
+
}
|
|
117
|
+
const changes = { ...input };
|
|
118
|
+
const clearedFields = [];
|
|
119
|
+
for (const field of Object.keys(shown.sealedFields)) {
|
|
120
|
+
if (!(field in changes)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const value = comparable(changes[field]);
|
|
124
|
+
if (value === comparable(shown[field])) {
|
|
125
|
+
delete changes[field];
|
|
126
|
+
}
|
|
127
|
+
else if (value === "" && opened) {
|
|
128
|
+
clearedFields.push(field);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return (clearedFields.length > 0 ? { ...changes, clearedFields } : changes);
|
|
132
|
+
}
|
|
133
|
+
/** `openSealedRecords()` for one record. */
|
|
134
|
+
export async function openSealedRecord(record, only) {
|
|
135
|
+
return (await openSealedRecords([record], only))[0];
|
|
136
|
+
}
|
package/lib/admin/domainsApi.ts
CHANGED
|
@@ -90,7 +90,7 @@ export function verifyDomain(uid: string, client?: ApiClient): Promise<Domain> {
|
|
|
90
90
|
return withClient(client, `/mail/domains/${encodeURIComponent(uid)}/verify`, { method: "POST" });
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
export type DnsRecordType = "ownership" | "mx" | "spf" | "dkim" | "dmarc" | "autodiscover_cname" | "autodiscover_srv";
|
|
93
|
+
export type DnsRecordType = "ownership" | "mx" | "spf" | "dkim" | "dmarc" | "openpgpkey_cname" | "autodiscover_cname" | "autodiscover_srv";
|
|
94
94
|
|
|
95
95
|
/** One mail-related DNS record this server recommends for a domain, live-checked against real DNS —
|
|
96
96
|
* purely diagnostic, never mutates anything (unlike `verifyDomain`). The two `autodiscover_*` types are only
|
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
* expansion happens entirely client-side (see `apps/shared/lib/recurrence.ts`) — the backend stores/returns
|
|
8
8
|
* `RecurrenceRule` as-is and does no RFC5545 expansion of its own. */
|
|
9
9
|
|
|
10
|
-
import { ApiClient, withClient } from "../util/api.js";
|
|
10
|
+
import { ApiClient, ApiRequestError, withClient } from "../util/api.js";
|
|
11
11
|
import { ListParams, buildQuery } from "../util/apiQuery.js";
|
|
12
|
+
import { INVALID_REQUEST_CODE, guardSealedMove } from "../zeroAccess/sealedMoves.js";
|
|
13
|
+
import { isOpenedRecord, isProtectedRecord, openSealedRecord, openSealedRecords } from "../zeroAccess/sealedRecords.js";
|
|
14
|
+
import { EVENT_CONTENT_FIELDS, type InvitationContent, invitationContentOf, isSealedContentOpened, markSealedContentOpened, protectedEventWrite } from "./protectedEvents.js";
|
|
15
|
+
import { meetingLinkSecrets } from "../videoconf/protectedMeetings.js";
|
|
12
16
|
|
|
13
17
|
export type AttendeeRole = "required" | "optional" | "resource";
|
|
14
18
|
export type AttendeeResponseStatus = "needsAction" | "accepted" | "declined" | "tentative";
|
|
@@ -138,6 +142,10 @@ export interface CalendarEvent {
|
|
|
138
142
|
/** Response-only. An event made from an invitation that reached a zero-access mailbox: its title, location and description are only
|
|
139
143
|
* sealed to the mailbox's zero-access key (`title` is `[...]`). `apps/shared/calendar/sealedEvents.ts` opens them. */
|
|
140
144
|
sealedContent?: { keyFingerprint: string; sealed: string };
|
|
145
|
+
/** Response-only. An event of a calendar zero-access encryption protects: its title, location and description, each sealed to the
|
|
146
|
+
* mailbox's zero-access key (the readable `title` is "Protected event"). Every function here returns events with them opened when the
|
|
147
|
+
* mailbox is unlocked (`lib/zeroAccess/sealedRecords.ts`); writes go through `protectedEvents.ts`. */
|
|
148
|
+
sealedFields?: Record<string, string> | null;
|
|
141
149
|
/** Who may see the event's details - absent (or `null`, on a row stored before this existed) reads as `"default"`; see `visibilityOf()`. */
|
|
142
150
|
visibility?: EventVisibility | null;
|
|
143
151
|
/** Whether the guests may ask for the event to change (default `false`). Absent or `null` reads as the default; see `guestPermissionsOf()`. */
|
|
@@ -173,6 +181,22 @@ const EVENT_SORT = JSON.stringify({ uid: "ASC" });
|
|
|
173
181
|
* `LIST_CALENDAR_EVENTS_MAX_PAGES` pages guards against looping forever should a server ever ignore
|
|
174
182
|
* `page` and keep returning full pages.
|
|
175
183
|
*/
|
|
184
|
+
/** What a list opens of a protected event: what the calendar views draw. Each field is one decryption, and a calendar lists every event
|
|
185
|
+
* it has - the rest (the description) is opened by the view that shows or edits it, by a search, and by a save. */
|
|
186
|
+
export const EVENT_LIST_FIELDS = ["title", "location"] as const;
|
|
187
|
+
|
|
188
|
+
/** `event` with every protected field opened, when only some were (a listed event) - for a write, which sends only what changed and
|
|
189
|
+
* invites with the whole content. The title of one whose `sealedContent` was opened stays: a row can hold both, and its per-field copy
|
|
190
|
+
* (`sealedFields.title`) is then an older title the sealed content's replaced (renamed since) - or, sealed without one, the same title
|
|
191
|
+
* `openSealedEvents()` already shows. */
|
|
192
|
+
export async function fullyOpenedEvent<T extends CalendarEvent | undefined>(event: T): Promise<T> {
|
|
193
|
+
if (!event || !isProtectedRecord(event) || isOpenedRecord(event)) {
|
|
194
|
+
return event;
|
|
195
|
+
}
|
|
196
|
+
const full = await openSealedRecord(event);
|
|
197
|
+
return isSealedContentOpened(event) ? { ...full, title: event.title } : full;
|
|
198
|
+
}
|
|
199
|
+
|
|
176
200
|
export async function listCalendarEvents(folderUid: string, client?: ApiClient): Promise<CalendarEvent[]> {
|
|
177
201
|
const events: CalendarEvent[] = [];
|
|
178
202
|
for (let page = 0; page < LIST_CALENDAR_EVENTS_MAX_PAGES; page++) {
|
|
@@ -185,11 +209,14 @@ export async function listCalendarEvents(folderUid: string, client?: ApiClient):
|
|
|
185
209
|
break;
|
|
186
210
|
}
|
|
187
211
|
}
|
|
188
|
-
return events;
|
|
212
|
+
return openSealedRecords(events, EVENT_LIST_FIELDS);
|
|
189
213
|
}
|
|
190
214
|
|
|
191
|
-
|
|
192
|
-
|
|
215
|
+
/** An event the server returned, opened. */
|
|
216
|
+
const opened = (event: CalendarEvent): Promise<CalendarEvent> => openSealedRecord(event);
|
|
217
|
+
|
|
218
|
+
export async function getCalendarEvent(uid: string, client?: ApiClient): Promise<CalendarEvent> {
|
|
219
|
+
return opened(await withClient<CalendarEvent>(client, `/mail/calendar-events/${encodeURIComponent(uid)}`));
|
|
193
220
|
}
|
|
194
221
|
|
|
195
222
|
export interface CalendarEventInput {
|
|
@@ -221,10 +248,18 @@ export interface CalendarEventInput {
|
|
|
221
248
|
guestsCanModify?: boolean;
|
|
222
249
|
guestsCanInviteOthers?: boolean;
|
|
223
250
|
guestsCanSeeGuestList?: boolean;
|
|
251
|
+
/**
|
|
252
|
+
* What the server sends a protected event's save-time invitations with (restapi `BaseCalendarEventRoute.takeInvitationContent()`).
|
|
253
|
+
* On a create, `{ defer: true }` holds them back for the save that follows - the event editor's, which attaches the video meeting and
|
|
254
|
+
* its link keys first - so attendees get one invitation, the one with the link. On an update, `{ defer: true }` sends none with this
|
|
255
|
+
* save either (they stay pending), and a content (`title`...) is what `updateCalendarEvent()` invites with when `current` couldn't be
|
|
256
|
+
* opened on this device - the content it opened wins otherwise.
|
|
257
|
+
*/
|
|
258
|
+
invitationContent?: Partial<InvitationContent> & { defer?: boolean };
|
|
224
259
|
}
|
|
225
260
|
|
|
226
|
-
export function createCalendarEvent(input: CalendarEventInput, client?: ApiClient): Promise<CalendarEvent> {
|
|
227
|
-
|
|
261
|
+
export async function createCalendarEvent(input: CalendarEventInput, client?: ApiClient): Promise<CalendarEvent> {
|
|
262
|
+
const created = await withClient<CalendarEvent>(client, "/mail/calendar-events", {
|
|
228
263
|
method: "POST",
|
|
229
264
|
body: JSON.stringify({
|
|
230
265
|
allDay: false,
|
|
@@ -236,6 +271,7 @@ export function createCalendarEvent(input: CalendarEventInput, client?: ApiClien
|
|
|
236
271
|
...input,
|
|
237
272
|
}),
|
|
238
273
|
});
|
|
274
|
+
return opened(created);
|
|
239
275
|
}
|
|
240
276
|
|
|
241
277
|
export interface UpdateCalendarEventInput extends Partial<CalendarEventInput> {
|
|
@@ -243,10 +279,87 @@ export interface UpdateCalendarEventInput extends Partial<CalendarEventInput> {
|
|
|
243
279
|
version: number;
|
|
244
280
|
}
|
|
245
281
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
282
|
+
/** Saves `input`. `current` is the event as this device shows it: for an event of a protected calendar, the write then sends only the
|
|
283
|
+
* content that changed, and the content the server invites with (`protectedEventWrite()`; `input.invitationContent` when `current` isn't
|
|
284
|
+
* opened, none with `{ defer: true }`). For an event whose video meeting is protected,
|
|
285
|
+
* every attendee's link key goes with it (`meetingLinkSecrets()`), for the server to add to their links in the invitations it sends. */
|
|
286
|
+
export function updateCalendarEvent(input: UpdateCalendarEventInput, client?: ApiClient, current?: CalendarEvent): Promise<CalendarEvent> {
|
|
287
|
+
// A move of a protected event to another mailbox is refused (`SealedMoveError`), before anything is opened or sent when `current` says so.
|
|
288
|
+
return guardSealedMove(current, input.mailboxUid, () => saveCalendarEvent(input, client, current));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The title restapi shows for an event whose title is sealed (its `PROTECTED_SUBJECT`). */
|
|
292
|
+
const PLACEHOLDER_TITLE = "[...]";
|
|
293
|
+
|
|
294
|
+
/** What `SealedEventContentError` says. */
|
|
295
|
+
export const SEALED_EVENT_CONTENT_MESSAGE =
|
|
296
|
+
"This event came from an invitation to a zero-access mailbox and its details are sealed, so its location or description can only be changed together with its title. Unlock your mailbox, open the event again and make the change there.";
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* restapi's refusal (409) of a location or description change to an event made from a zero-access invitation (`sealedContent`) sent
|
|
300
|
+
* with its placeholder title or none: resealing would lose the title. `saveCalendarEvent()` leaves out the placeholders of one it hasn't
|
|
301
|
+
* opened, so this comes back only when this device didn't know the event was sealed (it was filed sealed after it was loaded). An
|
|
302
|
+
* `ApiRequestError` (409), as `SealedMoveError` is, so the event dialog and the drag handler show its message.
|
|
303
|
+
*/
|
|
304
|
+
export class SealedEventContentError extends ApiRequestError {
|
|
305
|
+
constructor() {
|
|
306
|
+
super(SEALED_EVENT_CONTENT_MESSAGE, 409, "sealed-event-content");
|
|
307
|
+
this.name = "SealedEventContentError";
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Whether `err` is restapi's refusal of `body`, a write carrying a location or description without a real title. */
|
|
312
|
+
function isSealedContentTitleRefusal(err: unknown, body: Record<string, any>): boolean {
|
|
313
|
+
const hasValue = (field: string): boolean => body[field] !== undefined && body[field] !== null && body[field] !== "";
|
|
314
|
+
return (
|
|
315
|
+
err instanceof ApiRequestError &&
|
|
316
|
+
err.status === 409 &&
|
|
317
|
+
err.code === INVALID_REQUEST_CODE &&
|
|
318
|
+
(body.title === undefined || body.title === PLACEHOLDER_TITLE) &&
|
|
319
|
+
["location", "description", "descriptionHtml"].some(hasValue)
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function saveCalendarEvent(input: UpdateCalendarEventInput, client: ApiClient | undefined, current: CalendarEvent | undefined): Promise<CalendarEvent> {
|
|
324
|
+
current = await fullyOpenedEvent(current);
|
|
325
|
+
const { invitationContent: given, ...changes } = input;
|
|
326
|
+
const body: Record<string, any> = protectedEventWrite(current, changes);
|
|
327
|
+
if (given?.defer) {
|
|
328
|
+
body.invitationContent = { defer: true };
|
|
329
|
+
} else if (given?.title && !body.invitationContent && isProtectedRecord(current)) {
|
|
330
|
+
body.invitationContent = { title: given.title, location: given.location, description: given.description, descriptionHtml: given.descriptionHtml };
|
|
331
|
+
}
|
|
332
|
+
// A save that defers its invitations sends no link keys either: they go with the save that invites.
|
|
333
|
+
const secrets = given?.defer
|
|
334
|
+
? undefined
|
|
335
|
+
: await meetingLinkSecrets(
|
|
336
|
+
{ videoMeetingUid: input.videoMeetingUid ?? current?.videoMeetingUid, attendees: input.attendees ?? current?.attendees, organizer: current?.organizer ?? input.organizer },
|
|
337
|
+
client,
|
|
338
|
+
);
|
|
339
|
+
if (secrets) {
|
|
340
|
+
body.invitationContent = { ...(body.invitationContent ?? {}), meetingLinkSecrets: secrets };
|
|
341
|
+
}
|
|
342
|
+
let updated: CalendarEvent;
|
|
343
|
+
try {
|
|
344
|
+
updated = await withClient<CalendarEvent>(client, `/mail/calendar-events/${encodeURIComponent(input.uid)}`, {
|
|
345
|
+
method: "PUT",
|
|
346
|
+
body: JSON.stringify(body),
|
|
347
|
+
});
|
|
348
|
+
} catch (err) {
|
|
349
|
+
throw isSealedContentTitleRefusal(err, body) ? new SealedEventContentError() : err;
|
|
350
|
+
}
|
|
351
|
+
if (!updated.sealedContent || !isSealedContentOpened(current)) {
|
|
352
|
+
return opened(updated);
|
|
353
|
+
}
|
|
354
|
+
// An event made from a zero-access invitation, opened here, comes back with its placeholders: shown with the content this save sealed
|
|
355
|
+
// again (`protectedEventWrite()` sends all of it, or none and the sealed content stays), so a save that follows still writes it whole.
|
|
356
|
+
const content = Object.fromEntries(EVENT_CONTENT_FIELDS.map((field) => [field, field in body ? body[field] : current![field]]));
|
|
357
|
+
return markSealedContentOpened({
|
|
358
|
+
...(await opened(updated)),
|
|
359
|
+
title: content.title ?? "",
|
|
360
|
+
location: content.location ?? undefined,
|
|
361
|
+
description: content.description ?? null,
|
|
362
|
+
descriptionHtml: content.descriptionHtml ?? null,
|
|
250
363
|
});
|
|
251
364
|
}
|
|
252
365
|
|
|
@@ -265,17 +378,21 @@ export type AttendeeResponseInput = Exclude<AttendeeResponseStatus, "needsAction
|
|
|
265
378
|
* `"accepted"`/`"tentative"` the whole updated `CalendarEvent` comes back, with only the calling
|
|
266
379
|
* attendee's own `responseStatus` changed. On `"declined"`, the backend instead soft-deletes the
|
|
267
380
|
* mailbox's own copy of the event and returns only `{ uid }` — the caller should treat a decline the
|
|
268
|
-
* same as a delete rather than expecting an updated event back.
|
|
381
|
+
* same as a delete rather than expecting an updated event back. `current` (the event as this device shows it) names a protected event's
|
|
382
|
+
* reply by its real title (`invitationContentOf()`).
|
|
269
383
|
*/
|
|
270
|
-
export function respondToEvent(
|
|
384
|
+
export async function respondToEvent(
|
|
271
385
|
uid: string,
|
|
272
386
|
responseStatus: AttendeeResponseInput,
|
|
273
387
|
client?: ApiClient,
|
|
388
|
+
current?: CalendarEvent,
|
|
274
389
|
): Promise<CalendarEvent | { uid: string }> {
|
|
275
|
-
|
|
390
|
+
const invitationContent = invitationContentOf(await fullyOpenedEvent(current));
|
|
391
|
+
const result = await withClient<CalendarEvent | { uid: string }>(client, `/mail/calendar-events/${encodeURIComponent(uid)}/respond`, {
|
|
276
392
|
method: "POST",
|
|
277
|
-
body: JSON.stringify({ responseStatus }),
|
|
393
|
+
body: JSON.stringify(invitationContent ? { responseStatus, invitationContent } : { responseStatus }),
|
|
278
394
|
});
|
|
395
|
+
return "mailboxUid" in result ? opened(result) : result;
|
|
279
396
|
}
|
|
280
397
|
|
|
281
398
|
/** A change a guest asks the organizer for (`requestEventChange()`): only what is to differ. */
|
|
@@ -304,11 +421,17 @@ export interface EventChangeRequestResult {
|
|
|
304
421
|
* Asks the organizer to change an event, as a guest (not the organizer) whose copy allows it: the title, location, description or time need
|
|
305
422
|
* `guestsCanModify`, added guests need `guestsCanInviteOthers` (403 otherwise; 400 when nothing differs from what the event has, or a value is invalid).
|
|
306
423
|
* Nothing changes on this mailbox's own copy - the request is mailed to the organizer, and when the change is allowed it is applied there and
|
|
307
|
-
* arrives back later as an ordinary update of the invitation.
|
|
424
|
+
* arrives back later as an ordinary update of the invitation. `invitationContent` (the event's real content, `invitationContentOf()`)
|
|
425
|
+
* names a protected event in the request by its real title.
|
|
308
426
|
*/
|
|
309
|
-
export function requestEventChange(
|
|
427
|
+
export function requestEventChange(
|
|
428
|
+
uid: string,
|
|
429
|
+
request: EventChangeRequest,
|
|
430
|
+
client?: ApiClient,
|
|
431
|
+
invitationContent?: InvitationContent,
|
|
432
|
+
): Promise<EventChangeRequestResult> {
|
|
310
433
|
return withClient(client, `/mail/calendar-events/${encodeURIComponent(uid)}/request-change`, {
|
|
311
434
|
method: "POST",
|
|
312
|
-
body: JSON.stringify(request),
|
|
435
|
+
body: JSON.stringify(invitationContent ? { ...request, invitationContent } : request),
|
|
313
436
|
});
|
|
314
437
|
}
|