@rapidmx/web-client 0.36.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/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
|
@@ -22,12 +22,17 @@ import {
|
|
|
22
22
|
UpdateCalendarEventInput,
|
|
23
23
|
createCalendarEvent,
|
|
24
24
|
deleteCalendarEvent,
|
|
25
|
+
fullyOpenedEvent,
|
|
25
26
|
getCalendarEvent,
|
|
26
27
|
listCalendarEvents,
|
|
27
28
|
updateCalendarEvent,
|
|
28
29
|
} from "./calendarApi.js";
|
|
29
|
-
import { CalendarOccurrence, fromEventWallClock, toEventWallClock } from "./recurrence.js";
|
|
30
|
-
import { ApiClient } from "../util/api.js";
|
|
30
|
+
import { CalendarOccurrence, fromEventWallClock, seriesEndDate, toEventWallClock } from "./recurrence.js";
|
|
31
|
+
import { ApiClient, ApiRequestError } from "../util/api.js";
|
|
32
|
+
import { KeysLockedError } from "../crypto/masterKey.js";
|
|
33
|
+
import { isOpenedRecord, isProtectedRecord } from "../zeroAccess/sealedRecords.js";
|
|
34
|
+
import { followMeetingKeyExpiry } from "../videoconf/protectedMeetings.js";
|
|
35
|
+
import { isSealedContentOpened, markSealedContentOpened } from "./protectedEvents.js";
|
|
31
36
|
|
|
32
37
|
/** `client`, given by every function below, is an explicit `ApiClient` from `createApiClient()` (e.g. one
|
|
33
38
|
* account of a multi-account app) to call instead of the default global `apiFetch()` - see `withClient()`'s
|
|
@@ -36,6 +41,24 @@ import { ApiClient } from "../util/api.js";
|
|
|
36
41
|
|
|
37
42
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
38
43
|
|
|
44
|
+
/** What `SealedOccurrenceError` says. */
|
|
45
|
+
export const SEALED_OCCURRENCE_MESSAGE =
|
|
46
|
+
"This event came from an invitation to a zero-access mailbox, so one occurrence of it can't be changed on its own when it moves to another calendar or the server can't keep it protected. Change the entire series, or ask the organizer to.";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One occurrence of an event made from an invitation that reached a zero-access mailbox (`sealedContent`) that `detachOccurrence()` can't
|
|
50
|
+
* split off without its content being stored readable: one moved to another calendar (restapi re-seals a detached occurrence's content
|
|
51
|
+
* only in the calendar of the series it belongs to; it refuses to move a sealed event for the same reason), or one a server stored
|
|
52
|
+
* unsealed (one from before restapi re-sealed such a create - the copy is deleted at once). An `ApiRequestError` (409), as
|
|
53
|
+
* `SealedMoveError` is, so the event dialog and the drag handler show its message.
|
|
54
|
+
*/
|
|
55
|
+
export class SealedOccurrenceError extends ApiRequestError {
|
|
56
|
+
constructor() {
|
|
57
|
+
super(SEALED_OCCURRENCE_MESSAGE, 409, "sealed-occurrence");
|
|
58
|
+
this.name = "SealedOccurrenceError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
39
62
|
function positiveModulo(value: number, modulus: number): number {
|
|
40
63
|
return ((value % modulus) + modulus) % modulus;
|
|
41
64
|
}
|
|
@@ -49,6 +72,7 @@ function addException(event: CalendarEvent, occurrenceStart: string, client?: Ap
|
|
|
49
72
|
recurrenceRule: { ...rule, exceptions: [...rule.exceptions, occurrenceStart] },
|
|
50
73
|
},
|
|
51
74
|
client,
|
|
75
|
+
event,
|
|
52
76
|
);
|
|
53
77
|
}
|
|
54
78
|
|
|
@@ -84,7 +108,7 @@ export async function saveEventSeries(
|
|
|
84
108
|
// master's existing (unchanged) instant onto a different wall clock, exactly as the doc comment above
|
|
85
109
|
// promises - so all three fields, not just startDate, gate entry into the shift path below.
|
|
86
110
|
if (fields.startDate === undefined && fields.timezone === undefined && fields.allDay === undefined) {
|
|
87
|
-
return updateCalendarEvent(update, client);
|
|
111
|
+
return updateCalendarEvent(update, client, occurrence);
|
|
88
112
|
}
|
|
89
113
|
const master = await getCalendarEvent(occurrence.uid, client);
|
|
90
114
|
const newTimezone = fields.timezone ?? master.timezone;
|
|
@@ -93,7 +117,7 @@ export async function saveEventSeries(
|
|
|
93
117
|
const newStartInstant = Date.parse(fields.startDate ?? master.startDate);
|
|
94
118
|
const deltaWallMs = toEventWallClock(newStartInstant, newTimezone, newAllDay) - oldStartWall;
|
|
95
119
|
if (deltaWallMs === 0 && newTimezone === master.timezone && newAllDay === master.allDay) {
|
|
96
|
-
return updateCalendarEvent(update, client);
|
|
120
|
+
return updateCalendarEvent(update, client, occurrence);
|
|
97
121
|
}
|
|
98
122
|
// An occurrence is identified by its nominal wall-clock start (the rule's date + the master's time of day),
|
|
99
123
|
// not by the instant's own wall time: one that fell in a DST gap was pushed forward (02:30 -> 03:30 EDT),
|
|
@@ -112,7 +136,7 @@ export async function saveEventSeries(
|
|
|
112
136
|
if (rule) {
|
|
113
137
|
update.recurrenceRule = { ...rule, exceptions: rule.exceptions.map(shift) };
|
|
114
138
|
}
|
|
115
|
-
const saved = await updateCalendarEvent(update, client);
|
|
139
|
+
const saved = await updateCalendarEvent(update, client, occurrence);
|
|
116
140
|
|
|
117
141
|
let detachedOccurrenceSyncFailed = false;
|
|
118
142
|
try {
|
|
@@ -126,6 +150,7 @@ export async function saveEventSeries(
|
|
|
126
150
|
await updateCalendarEvent(
|
|
127
151
|
{ uid: event.uid, version: event.version, recurrenceId: shift(event.recurrenceId!) } as UpdateCalendarEventInput,
|
|
128
152
|
client,
|
|
153
|
+
event,
|
|
129
154
|
);
|
|
130
155
|
} catch {
|
|
131
156
|
detachedOccurrenceSyncFailed = true;
|
|
@@ -148,12 +173,34 @@ export async function saveEventSeries(
|
|
|
148
173
|
* original start as its `recurrenceId` (the RFC5545 way of identifying a modified instance), and never
|
|
149
174
|
* carries the series' `recurrenceRule` — even if `fields` includes one (e.g. the event modal's full
|
|
150
175
|
* field set).
|
|
176
|
+
*
|
|
177
|
+
* A protected occurrence (zero-access) is copied from its opened content: one that can't be opened here - its mailbox is locked - holds
|
|
178
|
+
* only placeholders, which the new event would store as its real title, location and description. It throws `KeysLockedError` instead,
|
|
179
|
+
* before anything is written. One of an event made from a zero-access invitation (`sealedContent`) is sent opened too: restapi's create
|
|
180
|
+
* seals it again to the series' key (`prepareCreate()` finds the series by `icalUid` in the same mailbox), storing only placeholders, and
|
|
181
|
+
* the event returned is marked opened with the content sent. Moved to another calendar it throws `SealedOccurrenceError` before anything
|
|
182
|
+
* is sent (the series isn't there to seal it to), and a copy the server returns unsealed is deleted and refused the same way.
|
|
151
183
|
*/
|
|
152
184
|
export async function detachOccurrence(
|
|
153
185
|
occurrence: CalendarOccurrence,
|
|
154
186
|
fields: Partial<CalendarEventInput>,
|
|
155
187
|
client?: ApiClient,
|
|
156
188
|
): Promise<CalendarEvent> {
|
|
189
|
+
occurrence = await fullyOpenedEvent(occurrence);
|
|
190
|
+
// Nor one of an event made from an invitation to a zero-access mailbox (`sealedContent`) whose content wasn't opened - asked of the
|
|
191
|
+
// mark, not of its title: an untitled one opens to an empty title, and a title can be anything.
|
|
192
|
+
if ((isProtectedRecord(occurrence) && !isOpenedRecord(occurrence)) || (occurrence.sealedContent && !isSealedContentOpened(occurrence))) {
|
|
193
|
+
throw new KeysLockedError("This event is protected and your mailbox is locked. Unlock it to change one occurrence of the event.");
|
|
194
|
+
}
|
|
195
|
+
const sealedContent: boolean = !!occurrence.sealedContent;
|
|
196
|
+
// Sealed again only in the series' own calendar (see `SealedOccurrenceError`).
|
|
197
|
+
if (
|
|
198
|
+
sealedContent &&
|
|
199
|
+
((fields.mailboxUid !== undefined && fields.mailboxUid !== occurrence.mailboxUid) ||
|
|
200
|
+
(fields.folderUid !== undefined && fields.folderUid !== occurrence.folderUid))
|
|
201
|
+
) {
|
|
202
|
+
throw new SealedOccurrenceError();
|
|
203
|
+
}
|
|
157
204
|
const originalStart = occurrence.recurrenceId!;
|
|
158
205
|
// `icalUid`/`recurrenceId` aren't part of `CalendarEventInput`, but `@rapidmx/restapi`'s
|
|
159
206
|
// `CalendarEvent` model accepts both on create (and `createCalendarEvent` already sends `icalUid`).
|
|
@@ -186,13 +233,27 @@ export async function detachOccurrence(
|
|
|
186
233
|
recurrenceId: originalStart,
|
|
187
234
|
};
|
|
188
235
|
const created = await createCalendarEvent(input, client);
|
|
236
|
+
if (sealedContent && !created.sealedContent) {
|
|
237
|
+
// A server that stored the opened content readable: taken back at once, and the occurrence left as it was.
|
|
238
|
+
await deleteCalendarEvent(created.uid, created.version, client).catch(() => undefined);
|
|
239
|
+
throw new SealedOccurrenceError();
|
|
240
|
+
}
|
|
189
241
|
try {
|
|
190
242
|
await addException(occurrence, originalStart, client);
|
|
191
243
|
} catch (err) {
|
|
192
244
|
await deleteCalendarEvent(created.uid, created.version, client).catch(() => undefined);
|
|
193
245
|
throw err;
|
|
194
246
|
}
|
|
195
|
-
|
|
247
|
+
// Stored with placeholders: shown with the content it was given, as an opened event.
|
|
248
|
+
return sealedContent
|
|
249
|
+
? markSealedContentOpened({
|
|
250
|
+
...created,
|
|
251
|
+
title: input.title,
|
|
252
|
+
location: input.location,
|
|
253
|
+
description: input.description ?? null,
|
|
254
|
+
descriptionHtml: input.descriptionHtml ?? null,
|
|
255
|
+
})
|
|
256
|
+
: created;
|
|
196
257
|
}
|
|
197
258
|
|
|
198
259
|
/** Deletes an entire recurring series (or a genuinely non-recurring event) outright. */
|
|
@@ -211,14 +272,22 @@ export function deleteEventOccurrence(occurrence: CalendarOccurrence, client?: A
|
|
|
211
272
|
* drag behavior, which never silently reschedules an entire series from a single dragged instance;
|
|
212
273
|
* moving the whole series is only ever done explicitly, via the event modal's "entire series" option.
|
|
213
274
|
*/
|
|
275
|
+
/** Saves new times of non-recurring `occurrence`, then moves its video meeting's key expiry to follow the new end: a protected meeting's key is
|
|
276
|
+
* discarded an hour after the end it was made for, so a meeting dragged later couldn't be joined. Best effort - it never fails the move. */
|
|
277
|
+
async function reschedule(occurrence: CalendarOccurrence, times: { startDate?: string; endDate: string }, client: ApiClient | undefined): Promise<CalendarEvent> {
|
|
278
|
+
const saved = await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...times }, client, occurrence);
|
|
279
|
+
if (saved.videoMeetingUid) {
|
|
280
|
+
await followMeetingKeyExpiry(saved.videoMeetingUid, seriesEndDate(saved), client);
|
|
281
|
+
}
|
|
282
|
+
return saved;
|
|
283
|
+
}
|
|
284
|
+
|
|
214
285
|
export function moveOccurrence(occurrence: CalendarOccurrence, deltaMs: number, client?: ApiClient): Promise<CalendarEvent> {
|
|
215
286
|
const fields = {
|
|
216
287
|
startDate: new Date(new Date(occurrence.startDate).getTime() + deltaMs).toISOString(),
|
|
217
288
|
endDate: new Date(new Date(occurrence.endDate).getTime() + deltaMs).toISOString(),
|
|
218
289
|
};
|
|
219
|
-
return occurrence.isRecurringOccurrence
|
|
220
|
-
? detachOccurrence(occurrence, fields, client)
|
|
221
|
-
: updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...fields }, client);
|
|
290
|
+
return occurrence.isRecurringOccurrence ? detachOccurrence(occurrence, fields, client) : reschedule(occurrence, fields, client);
|
|
222
291
|
}
|
|
223
292
|
|
|
224
293
|
/**
|
|
@@ -230,7 +299,5 @@ export function resizeOccurrenceEnd(occurrence: CalendarOccurrence, newEndDate:
|
|
|
230
299
|
const start = new Date(occurrence.startDate);
|
|
231
300
|
const minEnd = new Date(start.getTime() + 15 * 60_000);
|
|
232
301
|
const endDate = (newEndDate.getTime() > minEnd.getTime() ? newEndDate : minEnd).toISOString();
|
|
233
|
-
return occurrence.isRecurringOccurrence
|
|
234
|
-
? detachOccurrence(occurrence, { endDate }, client)
|
|
235
|
-
: updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, endDate }, client);
|
|
302
|
+
return occurrence.isRecurringOccurrence ? detachOccurrence(occurrence, { endDate }, client) : reschedule(occurrence, { endDate }, client);
|
|
236
303
|
}
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
* it now stands, ready to draw. */
|
|
10
10
|
|
|
11
11
|
import { ApiClient, ApiRequestError, withClient } from "../util/api.js";
|
|
12
|
-
import
|
|
12
|
+
import { followMeetingKeyExpiry, meetingLinkSecrets } from "../videoconf/protectedMeetings.js";
|
|
13
|
+
import { isProtectedRecord } from "../zeroAccess/sealedRecords.js";
|
|
14
|
+
import { type CalendarEvent, type EventVisibility, type GuestPermissions, getCalendarEvent } from "./calendarApi.js";
|
|
15
|
+
import { type InvitationContent, invitationContentOf } from "./protectedEvents.js";
|
|
16
|
+
import { seriesEndDate } from "./recurrence.js";
|
|
13
17
|
import { sealedInviteCalendar } from "./sealedInvites.js";
|
|
14
18
|
|
|
15
19
|
/** The calendar file to send for `messageUid` - a zero-access message this device decrypted (`sealedInvites.ts`) - as the request body's
|
|
@@ -167,11 +171,77 @@ export function proposeNewTime(messageUid: string, proposal: ProposedTime, clien
|
|
|
167
171
|
});
|
|
168
172
|
}
|
|
169
173
|
|
|
170
|
-
/**
|
|
171
|
-
|
|
174
|
+
/** Opens an event's `sealedContent` with the mailbox's unlocked keys (`apps/shared/calendar/sealedEvents.ts` `openSealedEvent()`), marking it
|
|
175
|
+
* opened, or returns it as it is when it can't. */
|
|
176
|
+
export type SealedContentOpener = (event: CalendarEvent) => Promise<CalendarEvent>;
|
|
177
|
+
|
|
178
|
+
/** What `acceptProposal()` refuses with, before sending anything, for a protected meeting this device can't open (its mailbox is locked). */
|
|
179
|
+
export const PROTECTED_PROPOSAL_MESSAGE = "This meeting is protected. Unlock your mailbox to accept the proposed time, so its guests can be sent the new time.";
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* What the server needs to send the moved meeting's invitations itself (`invitationContent`): for a meeting of a protected calendar
|
|
183
|
+
* (`sealedFields`), or made from a zero-access invitation (`sealedContent`, opened with `openSealedContent`), its real title, location and
|
|
184
|
+
* description, and for a meeting protected by a per-meeting key every guest's link key
|
|
185
|
+
* (`meetingLinkSecrets`) - with the event as read. No content for a meeting that needs neither (the server's scheduling job invites for it),
|
|
186
|
+
* and nothing at all for one that isn't on the calendar or can't be read. A protected meeting this device can't open is refused (`PROTECTED_PROPOSAL_MESSAGE`) rather than left to the server's 400.
|
|
187
|
+
*/
|
|
188
|
+
async function proposalInvitations(
|
|
189
|
+
calendarEventUid: string | undefined,
|
|
190
|
+
client: ApiClient | undefined,
|
|
191
|
+
openSealedContent: SealedContentOpener | undefined,
|
|
192
|
+
): Promise<{ event?: CalendarEvent; invitationContent?: Partial<InvitationContent> & { meetingLinkSecrets?: Record<string, string> } }> {
|
|
193
|
+
if (!calendarEventUid) {
|
|
194
|
+
return {};
|
|
195
|
+
}
|
|
196
|
+
let event: CalendarEvent;
|
|
197
|
+
try {
|
|
198
|
+
event = await getCalendarEvent(calendarEventUid, client);
|
|
199
|
+
} catch {
|
|
200
|
+
// Unreadable here: the server, which reads it, decides whether the proposal can be accepted without them.
|
|
201
|
+
return {};
|
|
202
|
+
}
|
|
203
|
+
// A meeting made from a zero-access invitation keeps its content sealed whole (`sealedContent`), which `getCalendarEvent()` doesn't
|
|
204
|
+
// open: opened here, it is invited for with its real title, location and description.
|
|
205
|
+
if (event.sealedContent && openSealedContent) {
|
|
206
|
+
event = await openSealedContent(event);
|
|
207
|
+
}
|
|
208
|
+
const content = invitationContentOf(event);
|
|
209
|
+
if ((isProtectedRecord(event) || event.sealedContent) && !content && (event as { encryptionOrigin?: string }).encryptionOrigin !== "originated") {
|
|
210
|
+
throw new ApiRequestError(PROTECTED_PROPOSAL_MESSAGE, 400);
|
|
211
|
+
}
|
|
212
|
+
const secrets = await meetingLinkSecrets({ videoMeetingUid: event.videoMeetingUid, attendees: event.attendees, organizer: event.organizer }, client);
|
|
213
|
+
if (!content && !secrets) {
|
|
214
|
+
return { event };
|
|
215
|
+
}
|
|
216
|
+
return { event, invitationContent: { ...(content ?? {}), ...(secrets ? { meetingLinkSecrets: secrets } : {}) } };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Accepts an attendee's proposed time (a `COUNTER`) for a meeting the reader organizes (`calendarEventUid`, the invitation's
|
|
221
|
+
* `calendarEventUid`): the server moves the event and sends the attendees the update. For a protected meeting - of a calendar zero-access
|
|
222
|
+
* encryption protects, or with a per-meeting key - the server can't compose the invitations by itself: this sends the decrypted title,
|
|
223
|
+
* location and description and the guests' link keys with it (`invitationContent`). Without them (the mailbox locked, or the meeting's key
|
|
224
|
+
* doesn't open here) the server answers 400 and nothing moves. A meeting made from a zero-access invitation (`sealedContent`) is opened
|
|
225
|
+
* with `openSealedContent` (the app passes `openSealedEvent()`); one that can't be is refused before anything is sent. A meeting with a
|
|
226
|
+
* video meeting has its key expiry moved to follow the new time (`followMeetingKeyExpiry()`, best effort). Resolves with the invitation as
|
|
227
|
+
* it now stands.
|
|
228
|
+
*/
|
|
229
|
+
export async function acceptProposal(
|
|
230
|
+
messageUid: string,
|
|
231
|
+
client?: ApiClient,
|
|
232
|
+
calendarEventUid?: string,
|
|
233
|
+
openSealedContent?: SealedContentOpener,
|
|
234
|
+
): Promise<MessageInvite> {
|
|
172
235
|
const source = sealedSource(messageUid);
|
|
173
|
-
|
|
236
|
+
const { event, invitationContent } = await proposalInvitations(calendarEventUid, client, openSealedContent);
|
|
237
|
+
const body = { ...source, ...(invitationContent ? { invitationContent } : {}) };
|
|
238
|
+
const accepted = await withClient<MessageInvite>(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/accept-proposal`, {
|
|
174
239
|
method: "POST",
|
|
175
|
-
...(
|
|
240
|
+
...(Object.keys(body).length > 0 ? { body: JSON.stringify(body) } : {}),
|
|
176
241
|
});
|
|
242
|
+
// A `COUNTER`'s `startDate`/`endDate` are the proposed time, which the meeting now has.
|
|
243
|
+
if (event?.videoMeetingUid && accepted.startDate && accepted.endDate) {
|
|
244
|
+
await followMeetingKeyExpiry(event.videoMeetingUid, seriesEndDate({ ...event, startDate: accepted.startDate, endDate: accepted.endDate }), client);
|
|
245
|
+
}
|
|
246
|
+
return accepted;
|
|
177
247
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Writes to an event of a calendar zero-access encryption protects (restapi `specs/search.md` §3 *What It Covers*, *Calendar invitations*).
|
|
7
|
+
* The server stores its title, location and description sealed, with placeholders in their place, so it can't tell what changed or invite
|
|
8
|
+
* with them by itself. A write therefore sends only the content fields that differ from what this device shows, and - when the event was
|
|
9
|
+
* opened here - the whole content as `invitationContent`, which the server invites with and never stores. A content field of an opened
|
|
10
|
+
* event that held a value and is emptied is named in `clearedFields`, the only way restapi applies an empty sealed field as a clear.
|
|
11
|
+
*/
|
|
12
|
+
import type { CalendarEvent } from "./calendarApi.js";
|
|
13
|
+
import { isOpenedRecord, isProtectedRecord } from "../zeroAccess/sealedRecords.js";
|
|
14
|
+
import { KeysLockedError } from "../crypto/masterKey.js";
|
|
15
|
+
|
|
16
|
+
/** The fields zero-access encryption seals on an event. */
|
|
17
|
+
export const EVENT_CONTENT_FIELDS = ["title", "location", "description", "descriptionHtml"] as const;
|
|
18
|
+
|
|
19
|
+
/** What the server composes a protected event's invitations with. */
|
|
20
|
+
export interface InvitationContent {
|
|
21
|
+
title: string;
|
|
22
|
+
location?: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
descriptionHtml?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const text = (value: unknown): string => (typeof value === "string" ? value : "");
|
|
28
|
+
|
|
29
|
+
/** Set on an event made from a zero-access invitation (`sealedContent`) whose sealed content was opened on this device
|
|
30
|
+
* (`apps/shared/calendar/sealedEvents.ts`). A symbol, so it survives a spread copy (an occurrence) and is never sent. */
|
|
31
|
+
const CONTENT_OPENED = Symbol("openedSealedContent");
|
|
32
|
+
|
|
33
|
+
/** `event` marked as holding its opened `sealedContent` (its real title, location and description). */
|
|
34
|
+
export function markSealedContentOpened<T extends CalendarEvent>(event: T): T {
|
|
35
|
+
return { ...event, [CONTENT_OPENED]: true };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Whether `event`'s `sealedContent` was opened on this device: its title, location and description are the real ones. */
|
|
39
|
+
export function isSealedContentOpened(event: CalendarEvent | undefined): boolean {
|
|
40
|
+
return !!event && (event as any)[CONTENT_OPENED] === true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether `event` shows its real content: nothing of it is sealed, or every sealed part (`sealedFields`, `sealedContent`) was opened. */
|
|
44
|
+
function shownOpened(event: CalendarEvent): boolean {
|
|
45
|
+
return (!isProtectedRecord(event) || isOpenedRecord(event)) && (!event.sealedContent || isSealedContentOpened(event));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `current`'s content with `changes` applied, for the server to invite (or name a reply or change request) with - `undefined` unless
|
|
49
|
+
* `current` is protected (`sealedFields`) or made from a zero-access invitation (`sealedContent`) and was opened on this device (its values
|
|
50
|
+
* are otherwise placeholders). */
|
|
51
|
+
export function invitationContentOf(current: CalendarEvent | undefined, changes: Record<string, unknown> = {}): InvitationContent | undefined {
|
|
52
|
+
if (!current || !(isProtectedRecord(current) || current.sealedContent) || !shownOpened(current)) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
const value = (field: (typeof EVENT_CONTENT_FIELDS)[number]): string => text(field in changes ? changes[field] : current[field]);
|
|
56
|
+
const title = value("title");
|
|
57
|
+
if (!title) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
const optional = (field: (typeof EVENT_CONTENT_FIELDS)[number]): string | undefined => value(field) || undefined;
|
|
61
|
+
return { title, location: optional("location"), description: optional("description"), descriptionHtml: optional("descriptionHtml") };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* `input` as written to `current`: unchanged when `current` isn't protected. Otherwise without the content fields whose values `current`
|
|
66
|
+
* already shows (a placeholder is "unchanged" too, so a locked device's write never overwrites the sealed value), and with
|
|
67
|
+
* `invitationContent` when `current` was opened - and `clearedFields` naming the sealed ones it empties.
|
|
68
|
+
*/
|
|
69
|
+
export function protectedEventWrite<T extends object>(current: CalendarEvent | undefined, input: T): T {
|
|
70
|
+
if (current?.sealedContent) {
|
|
71
|
+
return sealedContentWrite(current, input);
|
|
72
|
+
}
|
|
73
|
+
if (!isProtectedRecord(current)) {
|
|
74
|
+
return input;
|
|
75
|
+
}
|
|
76
|
+
const changes: Record<string, unknown> = { ...(input as Record<string, unknown>) };
|
|
77
|
+
const clearedFields: string[] = [];
|
|
78
|
+
for (const field of EVENT_CONTENT_FIELDS) {
|
|
79
|
+
if (!(field in changes)) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (text(changes[field]) === text(current![field])) {
|
|
83
|
+
delete changes[field];
|
|
84
|
+
} else if (!text(changes[field]) && current!.sealedFields![field] !== undefined && isOpenedRecord(current)) {
|
|
85
|
+
clearedFields.push(field);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (clearedFields.length > 0) {
|
|
89
|
+
changes.clearedFields = clearedFields;
|
|
90
|
+
}
|
|
91
|
+
const invitationContent = invitationContentOf(current, changes);
|
|
92
|
+
return (invitationContent ? { ...changes, invitationContent } : changes) as T;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* `input` as written to `current`, an event made from a zero-access invitation (`sealedContent`). restapi seals its whole content again
|
|
97
|
+
* from the content fields a write sends (`BaseCalendarEventRoute.keepSealed()`), dropping one the write leaves out, since it can't read the
|
|
98
|
+
* sealed value to keep it. So a write that sends any of them sends them all: those it leaves out (the event dialog sends an unchanged
|
|
99
|
+
* description not at all) are filled in with what `current` shows opened - the description and its HTML as one, as the dialog sends them.
|
|
100
|
+
* This holds too for a row that also keeps its title sealed field by field (`sealedFields.title`), whose sealed content's title is empty.
|
|
101
|
+
* Shown with placeholders (its mailbox is locked), the placeholders it would send back are left out, so nothing is sealed again, and a
|
|
102
|
+
* changed content field is refused (`KeysLockedError`) before anything is sent: the rest of the content can't be sent with it. Opened, a
|
|
103
|
+
* write whose location, description or description HTML differs from the one shown raises `sequence` past `current`'s, the only way
|
|
104
|
+
* restapi learns it changed.
|
|
105
|
+
*/
|
|
106
|
+
function sealedContentWrite<T extends object>(current: CalendarEvent, input: T): T {
|
|
107
|
+
const given = input as Record<string, unknown>;
|
|
108
|
+
if (!EVENT_CONTENT_FIELDS.some((field) => given[field] !== undefined)) {
|
|
109
|
+
return input;
|
|
110
|
+
}
|
|
111
|
+
const changes: Record<string, unknown> = { ...given };
|
|
112
|
+
if (!shownOpened(current)) {
|
|
113
|
+
for (const field of EVENT_CONTENT_FIELDS) {
|
|
114
|
+
if (field in changes && text(changes[field]) === text(current[field])) {
|
|
115
|
+
delete changes[field];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (EVENT_CONTENT_FIELDS.some((field) => changes[field] !== undefined)) {
|
|
119
|
+
throw new KeysLockedError("This event is protected and your mailbox is locked. Unlock it to change its title, location or description.");
|
|
120
|
+
}
|
|
121
|
+
return changes as T;
|
|
122
|
+
}
|
|
123
|
+
for (const field of ["title", "location"] as const) {
|
|
124
|
+
if (changes[field] === undefined) {
|
|
125
|
+
changes[field] = current[field] ?? null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (changes.description === undefined && changes.descriptionHtml === undefined) {
|
|
129
|
+
changes.description = current.description ?? null;
|
|
130
|
+
changes.descriptionHtml = current.descriptionHtml ?? null;
|
|
131
|
+
}
|
|
132
|
+
// restapi can't compare a location or description sealed whole (it holds placeholders), so a real change raises the sequence here: an
|
|
133
|
+
// organizer-owned meeting then invites again, as for a readable event's new location or description. A description field the write
|
|
134
|
+
// doesn't send is compared no more than restapi compares it for a readable event. An unchanged one leaves it alone.
|
|
135
|
+
const descriptionChanged = (["description", "descriptionHtml"] as const).some(
|
|
136
|
+
(field) => changes[field] !== undefined && text(changes[field]) !== text(current[field])
|
|
137
|
+
);
|
|
138
|
+
if (descriptionChanged || text(changes.location) !== text(current.location)) {
|
|
139
|
+
changes.sequence = (current.sequence ?? 0) + 1;
|
|
140
|
+
}
|
|
141
|
+
const invitationContent = isProtectedRecord(current) ? invitationContentOf(current, changes) : undefined;
|
|
142
|
+
return (invitationContent ? { ...changes, invitationContent } : changes) as T;
|
|
143
|
+
}
|
|
@@ -334,3 +334,20 @@ export const WEEKDAY_LABELS: Record<WeekdayCode, string> = {
|
|
|
334
334
|
SA: "Sat",
|
|
335
335
|
SU: "Sun",
|
|
336
336
|
};
|
|
337
|
+
|
|
338
|
+
/** When `event`'s last occurrence ends: its own end, or a series' last occurrence's - far ahead for a series without an end. */
|
|
339
|
+
export function seriesEndDate(event: CalendarEvent): Date {
|
|
340
|
+
const rule = event.recurrenceRule;
|
|
341
|
+
const end = new Date(event.endDate);
|
|
342
|
+
if (!rule) {
|
|
343
|
+
return end;
|
|
344
|
+
}
|
|
345
|
+
if (!rule.until && !rule.count) {
|
|
346
|
+
return new Date(8.64e15);
|
|
347
|
+
}
|
|
348
|
+
const duration = end.valueOf() - new Date(event.startDate).valueOf();
|
|
349
|
+
const horizon = new Date(rule.until ? new Date(rule.until).valueOf() + 86_400_000 : Date.now() + 5 * 365 * 86_400_000);
|
|
350
|
+
const occurrences = expandOccurrences(event, new Date(event.startDate), horizon);
|
|
351
|
+
const last = occurrences[occurrences.length - 1];
|
|
352
|
+
return last ? new Date(new Date(last.startDate).valueOf() + duration) : end;
|
|
353
|
+
}
|
|
@@ -5,26 +5,66 @@
|
|
|
5
5
|
/**
|
|
6
6
|
* The calendar files of zero-access messages, as decrypted on this device (`MessageSecurityResult.calendar`). The server holds only
|
|
7
7
|
* the sealed copy of such a message, so every invitation call for it (`inviteApi.ts`) sends the file along. Kept in memory for the
|
|
8
|
-
* messages read most recently,
|
|
8
|
+
* messages read most recently, only while their mailbox is unlocked: its lock forgets them (telling `onSealedInvitesForgotten()`
|
|
9
|
+
* listeners, so what was read with them goes too), and so does sign-out (`clearSealedInvites()`).
|
|
9
10
|
*/
|
|
11
|
+
import { getUnlockedKeys, subscribeKeySession } from "../crypto/keySession.js";
|
|
10
12
|
|
|
11
13
|
/** How many messages' calendar files are kept. */
|
|
12
14
|
const MAX_SEALED_INVITES = 100;
|
|
13
15
|
|
|
14
|
-
const calendars: Map<string, string> = new Map();
|
|
16
|
+
const calendars: Map<string, { mailboxUid: string; ics: string }> = new Map();
|
|
17
|
+
const forgottenListeners = new Set<(messageUids: string[]) => void>();
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
let watchingKeys = false;
|
|
20
|
+
|
|
21
|
+
/** A mailbox's files go the moment it locks, so nothing opened outlives its key session. Subscribed on the first file, not on import. */
|
|
22
|
+
function forgetOnLock(): void {
|
|
23
|
+
if (watchingKeys) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
watchingKeys = true;
|
|
27
|
+
subscribeKeySession(({ mailboxUid, state }) => {
|
|
28
|
+
if (state !== "locked") {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const forgotten: string[] = [];
|
|
32
|
+
for (const [messageUid, entry] of calendars) {
|
|
33
|
+
if (entry.mailboxUid === mailboxUid) {
|
|
34
|
+
calendars.delete(messageUid);
|
|
35
|
+
forgotten.push(messageUid);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (forgotten.length) {
|
|
39
|
+
forgottenListeners.forEach((listener) => listener(forgotten));
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Remembers the decrypted calendar file of zero-access message `messageUid` of `mailboxUid` - not while that mailbox is locked (a
|
|
45
|
+
* pane still drawing what it decrypted before the lock). */
|
|
46
|
+
export function rememberSealedInvite(messageUid: string, ics: string, mailboxUid: string): void {
|
|
47
|
+
if (!getUnlockedKeys(mailboxUid)) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
forgetOnLock();
|
|
18
51
|
calendars.delete(messageUid);
|
|
19
|
-
calendars.set(messageUid, ics);
|
|
52
|
+
calendars.set(messageUid, { mailboxUid, ics });
|
|
20
53
|
while (calendars.size > MAX_SEALED_INVITES) {
|
|
21
54
|
calendars.delete(calendars.keys().next().value!);
|
|
22
55
|
}
|
|
23
56
|
}
|
|
24
57
|
|
|
25
|
-
/** The decrypted calendar file of zero-access message `messageUid`, when this device has read it. */
|
|
58
|
+
/** The decrypted calendar file of zero-access message `messageUid`, when this device has read it and its mailbox is unlocked. */
|
|
26
59
|
export function sealedInviteCalendar(messageUid: string): string | undefined {
|
|
27
|
-
|
|
60
|
+
const entry = calendars.get(messageUid);
|
|
61
|
+
return entry && getUnlockedKeys(entry.mailboxUid) ? entry.ics : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Calls `listener` with the messages whose files a mailbox's lock forgot. Returns the unsubscribe. */
|
|
65
|
+
export function onSealedInvitesForgotten(listener: (messageUids: string[]) => void): () => void {
|
|
66
|
+
forgottenListeners.add(listener);
|
|
67
|
+
return () => forgottenListeners.delete(listener);
|
|
28
68
|
}
|
|
29
69
|
|
|
30
70
|
/** Forgets every remembered calendar file (sign-out). */
|
|
@@ -5,16 +5,10 @@
|
|
|
5
5
|
/** Typed wrappers over `@rapidmx/restapi`'s `/mail/contacts` REST surface — see `mailApi.ts`'s own header
|
|
6
6
|
* comment for the shared ACL/authorization model every wrapper file here follows. */
|
|
7
7
|
|
|
8
|
-
import { ApiClient, apiUrl, withClient, withClientRaw } from "../util/api.js";
|
|
8
|
+
import { ApiClient, apiUrl, withClient, withClientBlob, withClientRaw } from "../util/api.js";
|
|
9
9
|
import { ListParams, SETTINGS_LIST_LIMIT, buildQuery } from "../util/apiQuery.js";
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
type KeyConflict,
|
|
13
|
-
type PreviousKey,
|
|
14
|
-
type PublicKey,
|
|
15
|
-
type RejectedKey,
|
|
16
|
-
signingKeyFingerprints,
|
|
17
|
-
} from "../crypto/keyvaultApi.js";
|
|
10
|
+
import { guardSealedMove } from "../zeroAccess/sealedMoves.js";
|
|
11
|
+
import { type EncryptionPreference, type EndToEndFormat, type KeyConflict, type PreviousKey, type PublicKey, type RejectedKey, signingKeyFingerprints, isOpenPgpKey, isTrustedForVerification } from "../crypto/keyvaultApi.js";
|
|
18
12
|
|
|
19
13
|
export type ContactAddressKind = "home" | "work" | "other";
|
|
20
14
|
|
|
@@ -68,6 +62,15 @@ export interface Contact {
|
|
|
68
62
|
* via `crypto/keyvaultApi.ts`'s `lookupKeys()` at compose time, never fetched on message receipt
|
|
69
63
|
* (that would leak read timing to the sender's server). */
|
|
70
64
|
encryptPreference?: EncryptionPreference;
|
|
65
|
+
/** The protected details sealed to the mailbox's zero-access key while it protects contacts (`displayName` then shows the first email
|
|
66
|
+
* address, the other protected fields are empty) - opened with `openSealedRecords()`. Server-written. */
|
|
67
|
+
sealedFields?: Record<string, string> | null;
|
|
68
|
+
/** The photo (`photoBlobKey`) is sealed to the mailbox's zero-access key: it is fetched and opened, never shown by URL. Server-written. */
|
|
69
|
+
photoSealed?: boolean | null;
|
|
70
|
+
/** The end-to-end format the user chose for this contact ("Preferred format"); absent or null for automatic. */
|
|
71
|
+
preferredFormat?: EndToEndFormat | null;
|
|
72
|
+
/** The format this address was last seen using, from authenticated mail it sent (server-written). */
|
|
73
|
+
observedFormat?: { format: EndToEndFormat; at: number } | null;
|
|
71
74
|
/** This contact's pinned public keys. Trust is TOFU (trust-on-first-use) — see `keyConflicts` for what
|
|
72
75
|
* happens when a newly observed key differs from one of these. */
|
|
73
76
|
keys?: PublicKey[];
|
|
@@ -89,6 +92,13 @@ const CONTACT_SORT = JSON.stringify({ displayName: "ASC", uid: "ASC" });
|
|
|
89
92
|
|
|
90
93
|
/** Lists a folder's contacts, alphabetically by display name. Never includes soft-deleted contacts — see
|
|
91
94
|
* `listDeletedContacts()` for those. */
|
|
95
|
+
/** The protected contact fields a list of contacts shows - all a list opens (`openSealedRecords(contacts, CONTACT_LIST_FIELDS)`); a view of
|
|
96
|
+
* one contact opens the rest. */
|
|
97
|
+
export const CONTACT_LIST_FIELDS: readonly string[] = ["displayName", "phones", "categories"];
|
|
98
|
+
|
|
99
|
+
/** The protected fields a duplicate check compares (an address-less card is matched by name and phone). */
|
|
100
|
+
export const CONTACT_MATCH_FIELDS: readonly string[] = ["displayName", "phones"];
|
|
101
|
+
|
|
92
102
|
export function listContacts(folderUid: string, params: ListParams = {}, client?: ApiClient): Promise<Contact[]> {
|
|
93
103
|
return withClient(client, `/mail/contacts?${buildQuery(params, { folderUid, sort: CONTACT_SORT })}`);
|
|
94
104
|
}
|
|
@@ -106,6 +116,14 @@ export function pinnedSigningFingerprintsFor(contacts: Contact[], address: strin
|
|
|
106
116
|
return [...new Set(contactsMatching(contacts, address).flatMap((contact) => signingKeyFingerprints(contact.keys, contact.previousKeys)))];
|
|
107
117
|
}
|
|
108
118
|
|
|
119
|
+
/** The OpenPGP certificates (base64, `PublicKey.publicKey`) pinned on every contact in `contacts` for `address` - current and
|
|
120
|
+
* previous ones still trusted for verification (`isTrustedForVerification()`) - which an OpenPGP signature from that sender is
|
|
121
|
+
* checked against (an OpenPGP signature carries only a key ID, so the key itself is needed). De-duplicated by fingerprint. */
|
|
122
|
+
export function pinnedOpenPgpKeysFor(contacts: Contact[], address: string): string[] {
|
|
123
|
+
const keys = contactsMatching(contacts, address).flatMap((contact) => [...(contact.keys ?? []), ...(contact.previousKeys ?? [])]);
|
|
124
|
+
return uniqueByFingerprint(keys.filter((key) => isOpenPgpKey(key) && isTrustedForVerification(key))).map((key) => key.publicKey);
|
|
125
|
+
}
|
|
126
|
+
|
|
109
127
|
/** Page size and page cap for `fetchPinnedSigningFingerprints()` and `fetchSignerKeyState()` (500 is restapi's own
|
|
110
128
|
* `limit` cap). */
|
|
111
129
|
export const PINNED_FINGERPRINT_PAGE_SIZE = 500;
|
|
@@ -149,6 +167,9 @@ export interface SignerKeyState {
|
|
|
149
167
|
previous: PreviousKey[];
|
|
150
168
|
/** The first unresolved signing-key conflict found, if any. */
|
|
151
169
|
conflict?: KeyConflict;
|
|
170
|
+
/** The pinned OpenPGP certificates (which sign as well as encrypt), de-duplicated by fingerprint - what an OpenPGP signer's
|
|
171
|
+
* key-changed comparison shows. Absent when there are none. */
|
|
172
|
+
openPgp?: PublicKey[];
|
|
152
173
|
}
|
|
153
174
|
|
|
154
175
|
function uniqueByFingerprint<T extends PublicKey>(keys: T[]): T[] {
|
|
@@ -174,7 +195,8 @@ export function signerKeyStateFor(contacts: Contact[], address: string): SignerK
|
|
|
174
195
|
matching.flatMap((contact) => (contact.previousKeys ?? []).filter((key) => key.useType === "sign")).sort((a, b) => b.replacedAt - a.replacedAt),
|
|
175
196
|
);
|
|
176
197
|
const conflict = matching.flatMap((contact) => contact.keyConflicts ?? []).find((entry) => entry.useType === "sign");
|
|
177
|
-
|
|
198
|
+
const openPgp = uniqueByFingerprint(matching.flatMap((contact) => (contact.keys ?? []).filter((key) => isOpenPgpKey(key))));
|
|
199
|
+
return { pinned, previous, ...(conflict ? { conflict } : {}), ...(openPgp.length > 0 ? { openPgp } : {}) };
|
|
178
200
|
}
|
|
179
201
|
|
|
180
202
|
/** Fetches `signerKeyStateFor()` for `address` from the contacts in `folderUids`, paging exactly like
|
|
@@ -258,11 +280,15 @@ export interface UpdateContactInput extends ContactInput {
|
|
|
258
280
|
* fetched `Contact`. An `UpdateContactInput` is also a valid `ContactPatch`. */
|
|
259
281
|
export type ContactPatch = Partial<Contact> & Pick<Contact, "uid" | "version">;
|
|
260
282
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
283
|
+
/** Saves `patch`. A patch moving the contact to another mailbox (`mailboxUid`) is refused with `SealedMoveError` when the contact is
|
|
284
|
+
* protected - before sending anything when `current` (the contact as this device has it) says so, else from the server's refusal. */
|
|
285
|
+
export function updateContact(patch: ContactPatch, client?: ApiClient, current?: Contact): Promise<Contact> {
|
|
286
|
+
return guardSealedMove(current, patch.mailboxUid, () =>
|
|
287
|
+
withClient<Contact>(client, `/mail/contacts/${encodeURIComponent(patch.uid)}`, {
|
|
288
|
+
method: "PUT",
|
|
289
|
+
body: JSON.stringify(patch),
|
|
290
|
+
}),
|
|
291
|
+
);
|
|
266
292
|
}
|
|
267
293
|
|
|
268
294
|
/** Thin `updateContact()` wrapper for toggling favorite/star status — same pattern as `mailApi.ts`'s
|
|
@@ -291,8 +317,8 @@ export function uploadContactPhoto(uid: string, version: number, file: Blob, cli
|
|
|
291
317
|
}
|
|
292
318
|
|
|
293
319
|
/** The bytes of a contact's own picture through an explicit `client` - for one that authenticates with a token, whose `<img>` cannot (see `contactPhotoUrl()`). */
|
|
294
|
-
export function fetchContactPhoto(uid: string, version: number, client
|
|
295
|
-
return client
|
|
320
|
+
export function fetchContactPhoto(uid: string, version: number, client?: ApiClient): Promise<Blob> {
|
|
321
|
+
return withClientBlob(client, `/mail/contacts/${encodeURIComponent(uid)}/photo?v=${version}`, "Could not load the contact's picture.");
|
|
296
322
|
}
|
|
297
323
|
|
|
298
324
|
/** Removes a contact's own picture and returns the updated contact. */
|