@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.
Files changed (227) hide show
  1. package/apps/shared/calendar/calendarReminders.ts +35 -0
  2. package/apps/shared/calendar/sealedEvents.ts +42 -9
  3. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  5. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +29 -1
  6. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  7. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  8. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  9. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  10. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  11. package/apps/shared/components/calendar/eventForm.ts +2 -0
  12. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  13. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  14. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  15. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  16. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  17. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  18. package/apps/shared/components/layout/AppShell.tsx +34 -5
  19. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +11 -4
  20. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
  21. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  22. package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  24. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  25. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  26. package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
  27. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  28. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  29. package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
  30. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  31. package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
  32. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  34. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  35. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  36. package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
  37. package/apps/shared/search/localIndexBuilder.ts +35 -24
  38. package/apps/shared/search/localIndexRpcClient.ts +91 -11
  39. package/apps/shared/search/localIndexSchema.ts +18 -2
  40. package/apps/shared/search/localIndexTransport.ts +6 -3
  41. package/apps/shared/search/localIndexWorker.ts +35 -18
  42. package/apps/shared/search/searchTier2.ts +5 -0
  43. package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
  44. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  45. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  46. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  47. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  48. package/apps/www/_shell.tsx +2 -0
  49. package/apps/www/calendar/index.tsx +49 -5
  50. package/apps/www/contacts/index.tsx +67 -11
  51. package/apps/www/index.tsx +10 -1
  52. package/apps/www/settings/encryption/index.tsx +16 -1
  53. package/apps/www/settings/privacy/index.tsx +87 -38
  54. package/apps/www/tasks/index.tsx +24 -6
  55. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  56. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  57. package/dist/apps/shared/calendar/sealedEvents.d.ts +3 -2
  58. package/dist/apps/shared/calendar/sealedEvents.js +40 -9
  59. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  60. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  61. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  62. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +9 -2
  63. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  64. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  65. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  66. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  67. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  68. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  69. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  70. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  71. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  72. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  73. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  74. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  75. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  76. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  77. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  78. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  79. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  80. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  81. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  82. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  83. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -4
  84. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
  85. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
  86. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  87. package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
  88. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  89. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  90. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  91. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  92. package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -1
  93. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  94. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  95. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  96. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  97. package/dist/apps/shared/components/settings/KeyExportSetting.js +3 -1
  98. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  99. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  100. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +2 -0
  101. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
  102. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  103. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  104. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  105. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  106. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  107. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  108. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  109. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  110. package/dist/apps/shared/search/LocalIndexLifecycle.js +55 -33
  111. package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
  112. package/dist/apps/shared/search/localIndexBuilder.js +31 -20
  113. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  114. package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
  115. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  116. package/dist/apps/shared/search/localIndexSchema.js +16 -2
  117. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
  118. package/dist/apps/shared/search/localIndexWorker.d.ts +9 -0
  119. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  120. package/dist/apps/shared/search/searchTier2.js +5 -0
  121. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +37 -6
  122. package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
  123. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  124. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  125. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  126. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  127. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  128. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  129. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  130. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  131. package/dist/apps/www/_shell.d.ts +1 -1
  132. package/dist/apps/www/_shell.js +2 -2
  133. package/dist/apps/www/calendar/index.js +47 -5
  134. package/dist/apps/www/contacts/index.js +64 -11
  135. package/dist/apps/www/index.js +10 -1
  136. package/dist/apps/www/settings/encryption/index.js +14 -4
  137. package/dist/apps/www/settings/privacy/index.js +46 -5
  138. package/dist/apps/www/tasks/index.js +21 -7
  139. package/dist/lib/admin/domainsApi.d.ts +1 -1
  140. package/dist/lib/calendar/calendarApi.d.ts +46 -6
  141. package/dist/lib/calendar/calendarApi.js +110 -17
  142. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  143. package/dist/lib/calendar/calendarMutations.js +73 -14
  144. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  145. package/dist/lib/calendar/inviteApi.js +61 -4
  146. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  147. package/dist/lib/calendar/protectedEvents.js +113 -0
  148. package/dist/lib/calendar/recurrence.d.ts +2 -0
  149. package/dist/lib/calendar/recurrence.js +16 -0
  150. package/dist/lib/calendar/sealedInvites.d.ts +6 -8
  151. package/dist/lib/calendar/sealedInvites.js +43 -6
  152. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  153. package/dist/lib/contacts/contactsApi.js +23 -7
  154. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  155. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  156. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  157. package/dist/lib/crypto/composeSecurity.js +13 -5
  158. package/dist/lib/crypto/keyExport.js +2 -2
  159. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  160. package/dist/lib/crypto/keyPairMatch.js +65 -0
  161. package/dist/lib/crypto/keySession.d.ts +19 -2
  162. package/dist/lib/crypto/keySession.js +89 -13
  163. package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
  164. package/dist/lib/crypto/keyvaultApi.js +13 -3
  165. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  166. package/dist/lib/crypto/meetingKeys.js +87 -0
  167. package/dist/lib/crypto/messageSecurity.d.ts +26 -2
  168. package/dist/lib/crypto/messageSecurity.js +122 -10
  169. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  170. package/dist/lib/crypto/openPgpKey.js +89 -0
  171. package/dist/lib/crypto/openpgp.d.ts +47 -0
  172. package/dist/lib/crypto/openpgp.js +88 -0
  173. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  174. package/dist/lib/crypto/pgpMessage.js +271 -0
  175. package/dist/lib/crypto/sharedMailboxKeys.d.ts +15 -3
  176. package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
  177. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  178. package/dist/lib/crypto/smimeMessage.js +11 -7
  179. package/dist/lib/crypto/zeroAccessKey.js +5 -0
  180. package/dist/lib/mail/mailApi.d.ts +33 -0
  181. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  182. package/dist/lib/mail/mailboxImportApi.js +15 -0
  183. package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -0
  184. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  185. package/dist/lib/tasks/tasksApi.js +10 -4
  186. package/dist/lib/util/clientPlatform.d.ts +14 -0
  187. package/dist/lib/util/clientPlatform.js +26 -0
  188. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  189. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  190. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  191. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  192. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  193. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  194. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  195. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  196. package/lib/admin/domainsApi.ts +1 -1
  197. package/lib/calendar/calendarApi.ts +140 -17
  198. package/lib/calendar/calendarMutations.ts +79 -12
  199. package/lib/calendar/inviteApi.ts +75 -5
  200. package/lib/calendar/protectedEvents.ts +143 -0
  201. package/lib/calendar/recurrence.ts +17 -0
  202. package/lib/calendar/sealedInvites.ts +47 -7
  203. package/lib/contacts/contactsApi.ts +43 -17
  204. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  205. package/lib/crypto/composeSecurity.ts +20 -5
  206. package/lib/crypto/keyExport.ts +2 -2
  207. package/lib/crypto/keyPairMatch.ts +69 -0
  208. package/lib/crypto/keySession.ts +102 -12
  209. package/lib/crypto/keyvaultApi.ts +49 -7
  210. package/lib/crypto/meetingKeys.ts +118 -0
  211. package/lib/crypto/messageSecurity.ts +174 -13
  212. package/lib/crypto/openPgpKey.ts +118 -0
  213. package/lib/crypto/openpgp.ts +115 -0
  214. package/lib/crypto/pgpMessage.ts +336 -0
  215. package/lib/crypto/sharedMailboxKeys.ts +106 -28
  216. package/lib/crypto/smimeMessage.ts +11 -7
  217. package/lib/crypto/zeroAccessKey.ts +5 -0
  218. package/lib/mail/mailApi.ts +27 -0
  219. package/lib/mail/mailboxImportApi.ts +24 -0
  220. package/lib/search/zeroAccessIndexApi.ts +4 -0
  221. package/lib/tasks/tasksApi.ts +19 -5
  222. package/lib/util/clientPlatform.ts +50 -0
  223. package/lib/videoconf/protectedMeetings.ts +221 -0
  224. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  225. package/lib/zeroAccess/sealedMoves.ts +79 -0
  226. package/lib/zeroAccess/sealedRecords.ts +153 -0
  227. package/package.json +12 -3
@@ -2,7 +2,8 @@
2
2
  * header comment for the shared ACL/authorization model every wrapper file here follows. Recurrence
3
3
  * expansion happens entirely client-side (see `apps/shared/lib/recurrence.ts`) — the backend stores/returns
4
4
  * `RecurrenceRule` as-is and does no RFC5545 expansion of its own. */
5
- import { ApiClient } from "../util/api.js";
5
+ import { ApiClient, ApiRequestError } from "../util/api.js";
6
+ import { type InvitationContent } from "./protectedEvents.js";
6
7
  export type AttendeeRole = "required" | "optional" | "resource";
7
8
  export type AttendeeResponseStatus = "needsAction" | "accepted" | "declined" | "tentative";
8
9
  export interface Attendee {
@@ -118,6 +119,10 @@ export interface CalendarEvent {
118
119
  keyFingerprint: string;
119
120
  sealed: string;
120
121
  };
122
+ /** Response-only. An event of a calendar zero-access encryption protects: its title, location and description, each sealed to the
123
+ * mailbox's zero-access key (the readable `title` is "Protected event"). Every function here returns events with them opened when the
124
+ * mailbox is unlocked (`lib/zeroAccess/sealedRecords.ts`); writes go through `protectedEvents.ts`. */
125
+ sealedFields?: Record<string, string> | null;
121
126
  /** Who may see the event's details - absent (or `null`, on a row stored before this existed) reads as `"default"`; see `visibilityOf()`. */
122
127
  visibility?: EventVisibility | null;
123
128
  /** Whether the guests may ask for the event to change (default `false`). Absent or `null` reads as the default; see `guestPermissionsOf()`. */
@@ -147,6 +152,14 @@ export interface CalendarEvent {
147
152
  * `LIST_CALENDAR_EVENTS_MAX_PAGES` pages guards against looping forever should a server ever ignore
148
153
  * `page` and keep returning full pages.
149
154
  */
155
+ /** What a list opens of a protected event: what the calendar views draw. Each field is one decryption, and a calendar lists every event
156
+ * it has - the rest (the description) is opened by the view that shows or edits it, by a search, and by a save. */
157
+ export declare const EVENT_LIST_FIELDS: readonly ["title", "location"];
158
+ /** `event` with every protected field opened, when only some were (a listed event) - for a write, which sends only what changed and
159
+ * invites with the whole content. The title of one whose `sealedContent` was opened stays: a row can hold both, and its per-field copy
160
+ * (`sealedFields.title`) is then an older title the sealed content's replaced (renamed since) - or, sealed without one, the same title
161
+ * `openSealedEvents()` already shows. */
162
+ export declare function fullyOpenedEvent<T extends CalendarEvent | undefined>(event: T): Promise<T>;
150
163
  export declare function listCalendarEvents(folderUid: string, client?: ApiClient): Promise<CalendarEvent[]>;
151
164
  export declare function getCalendarEvent(uid: string, client?: ApiClient): Promise<CalendarEvent>;
152
165
  export interface CalendarEventInput {
@@ -178,13 +191,38 @@ export interface CalendarEventInput {
178
191
  guestsCanModify?: boolean;
179
192
  guestsCanInviteOthers?: boolean;
180
193
  guestsCanSeeGuestList?: boolean;
194
+ /**
195
+ * What the server sends a protected event's save-time invitations with (restapi `BaseCalendarEventRoute.takeInvitationContent()`).
196
+ * On a create, `{ defer: true }` holds them back for the save that follows - the event editor's, which attaches the video meeting and
197
+ * its link keys first - so attendees get one invitation, the one with the link. On an update, `{ defer: true }` sends none with this
198
+ * save either (they stay pending), and a content (`title`...) is what `updateCalendarEvent()` invites with when `current` couldn't be
199
+ * opened on this device - the content it opened wins otherwise.
200
+ */
201
+ invitationContent?: Partial<InvitationContent> & {
202
+ defer?: boolean;
203
+ };
181
204
  }
182
205
  export declare function createCalendarEvent(input: CalendarEventInput, client?: ApiClient): Promise<CalendarEvent>;
183
206
  export interface UpdateCalendarEventInput extends Partial<CalendarEventInput> {
184
207
  uid: string;
185
208
  version: number;
186
209
  }
187
- export declare function updateCalendarEvent(input: UpdateCalendarEventInput, client?: ApiClient): Promise<CalendarEvent>;
210
+ /** Saves `input`. `current` is the event as this device shows it: for an event of a protected calendar, the write then sends only the
211
+ * content that changed, and the content the server invites with (`protectedEventWrite()`; `input.invitationContent` when `current` isn't
212
+ * opened, none with `{ defer: true }`). For an event whose video meeting is protected,
213
+ * every attendee's link key goes with it (`meetingLinkSecrets()`), for the server to add to their links in the invitations it sends. */
214
+ export declare function updateCalendarEvent(input: UpdateCalendarEventInput, client?: ApiClient, current?: CalendarEvent): Promise<CalendarEvent>;
215
+ /** What `SealedEventContentError` says. */
216
+ export declare const SEALED_EVENT_CONTENT_MESSAGE = "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.";
217
+ /**
218
+ * restapi's refusal (409) of a location or description change to an event made from a zero-access invitation (`sealedContent`) sent
219
+ * with its placeholder title or none: resealing would lose the title. `saveCalendarEvent()` leaves out the placeholders of one it hasn't
220
+ * 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
221
+ * `ApiRequestError` (409), as `SealedMoveError` is, so the event dialog and the drag handler show its message.
222
+ */
223
+ export declare class SealedEventContentError extends ApiRequestError {
224
+ constructor();
225
+ }
188
226
  export declare function deleteCalendarEvent(uid: string, version: number, client?: ApiClient): Promise<void>;
189
227
  /** The subset of `AttendeeResponseStatus` a caller can actually respond with — `"needsAction"` is only
190
228
  * ever a default/initial value, never a valid response. */
@@ -196,9 +234,10 @@ export type AttendeeResponseInput = Exclude<AttendeeResponseStatus, "needsAction
196
234
  * `"accepted"`/`"tentative"` the whole updated `CalendarEvent` comes back, with only the calling
197
235
  * attendee's own `responseStatus` changed. On `"declined"`, the backend instead soft-deletes the
198
236
  * mailbox's own copy of the event and returns only `{ uid }` — the caller should treat a decline the
199
- * same as a delete rather than expecting an updated event back.
237
+ * same as a delete rather than expecting an updated event back. `current` (the event as this device shows it) names a protected event's
238
+ * reply by its real title (`invitationContentOf()`).
200
239
  */
201
- export declare function respondToEvent(uid: string, responseStatus: AttendeeResponseInput, client?: ApiClient): Promise<CalendarEvent | {
240
+ export declare function respondToEvent(uid: string, responseStatus: AttendeeResponseInput, client?: ApiClient, current?: CalendarEvent): Promise<CalendarEvent | {
202
241
  uid: string;
203
242
  }>;
204
243
  /** A change a guest asks the organizer for (`requestEventChange()`): only what is to differ. */
@@ -231,6 +270,7 @@ export interface EventChangeRequestResult {
231
270
  * Asks the organizer to change an event, as a guest (not the organizer) whose copy allows it: the title, location, description or time need
232
271
  * `guestsCanModify`, added guests need `guestsCanInviteOthers` (403 otherwise; 400 when nothing differs from what the event has, or a value is invalid).
233
272
  * 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
234
- * arrives back later as an ordinary update of the invitation.
273
+ * arrives back later as an ordinary update of the invitation. `invitationContent` (the event's real content, `invitationContentOf()`)
274
+ * names a protected event in the request by its real title.
235
275
  */
236
- export declare function requestEventChange(uid: string, request: EventChangeRequest, client?: ApiClient): Promise<EventChangeRequestResult>;
276
+ export declare function requestEventChange(uid: string, request: EventChangeRequest, client?: ApiClient, invitationContent?: InvitationContent): Promise<EventChangeRequestResult>;
@@ -6,8 +6,12 @@
6
6
  * header comment for the shared ACL/authorization model every wrapper file here follows. Recurrence
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
- import { withClient } from "../util/api.js";
9
+ import { ApiRequestError, withClient } from "../util/api.js";
10
10
  import { buildQuery } from "../util/apiQuery.js";
11
+ import { INVALID_REQUEST_CODE, guardSealedMove } from "../zeroAccess/sealedMoves.js";
12
+ import { isOpenedRecord, isProtectedRecord, openSealedRecord, openSealedRecords } from "../zeroAccess/sealedRecords.js";
13
+ import { EVENT_CONTENT_FIELDS, invitationContentOf, isSealedContentOpened, markSealedContentOpened, protectedEventWrite } from "./protectedEvents.js";
14
+ import { meetingLinkSecrets } from "../videoconf/protectedMeetings.js";
11
15
  /** The guest permissions an event has when none were chosen (Google Calendar's own defaults). */
12
16
  export const DEFAULT_GUEST_PERMISSIONS = { guestsCanModify: false, guestsCanInviteOthers: true, guestsCanSeeGuestList: true };
13
17
  /** The guest permissions `source` says, a missing (or `null`) flag reading as its default - an event stored before they existed carries none. */
@@ -42,6 +46,20 @@ const EVENT_SORT = JSON.stringify({ uid: "ASC" });
42
46
  * `LIST_CALENDAR_EVENTS_MAX_PAGES` pages guards against looping forever should a server ever ignore
43
47
  * `page` and keep returning full pages.
44
48
  */
49
+ /** What a list opens of a protected event: what the calendar views draw. Each field is one decryption, and a calendar lists every event
50
+ * it has - the rest (the description) is opened by the view that shows or edits it, by a search, and by a save. */
51
+ export const EVENT_LIST_FIELDS = ["title", "location"];
52
+ /** `event` with every protected field opened, when only some were (a listed event) - for a write, which sends only what changed and
53
+ * invites with the whole content. The title of one whose `sealedContent` was opened stays: a row can hold both, and its per-field copy
54
+ * (`sealedFields.title`) is then an older title the sealed content's replaced (renamed since) - or, sealed without one, the same title
55
+ * `openSealedEvents()` already shows. */
56
+ export async function fullyOpenedEvent(event) {
57
+ if (!event || !isProtectedRecord(event) || isOpenedRecord(event)) {
58
+ return event;
59
+ }
60
+ const full = await openSealedRecord(event);
61
+ return isSealedContentOpened(event) ? { ...full, title: event.title } : full;
62
+ }
45
63
  export async function listCalendarEvents(folderUid, client) {
46
64
  const events = [];
47
65
  for (let page = 0; page < LIST_CALENDAR_EVENTS_MAX_PAGES; page++) {
@@ -51,13 +69,15 @@ export async function listCalendarEvents(folderUid, client) {
51
69
  break;
52
70
  }
53
71
  }
54
- return events;
72
+ return openSealedRecords(events, EVENT_LIST_FIELDS);
55
73
  }
56
- export function getCalendarEvent(uid, client) {
57
- return withClient(client, `/mail/calendar-events/${encodeURIComponent(uid)}`);
74
+ /** An event the server returned, opened. */
75
+ const opened = (event) => openSealedRecord(event);
76
+ export async function getCalendarEvent(uid, client) {
77
+ return opened(await withClient(client, `/mail/calendar-events/${encodeURIComponent(uid)}`));
58
78
  }
59
- export function createCalendarEvent(input, client) {
60
- return withClient(client, "/mail/calendar-events", {
79
+ export async function createCalendarEvent(input, client) {
80
+ const created = await withClient(client, "/mail/calendar-events", {
61
81
  method: "POST",
62
82
  body: JSON.stringify({
63
83
  allDay: false,
@@ -69,11 +89,80 @@ export function createCalendarEvent(input, client) {
69
89
  ...input,
70
90
  }),
71
91
  });
92
+ return opened(created);
93
+ }
94
+ /** Saves `input`. `current` is the event as this device shows it: for an event of a protected calendar, the write then sends only the
95
+ * content that changed, and the content the server invites with (`protectedEventWrite()`; `input.invitationContent` when `current` isn't
96
+ * opened, none with `{ defer: true }`). For an event whose video meeting is protected,
97
+ * every attendee's link key goes with it (`meetingLinkSecrets()`), for the server to add to their links in the invitations it sends. */
98
+ export function updateCalendarEvent(input, client, current) {
99
+ // A move of a protected event to another mailbox is refused (`SealedMoveError`), before anything is opened or sent when `current` says so.
100
+ return guardSealedMove(current, input.mailboxUid, () => saveCalendarEvent(input, client, current));
101
+ }
102
+ /** The title restapi shows for an event whose title is sealed (its `PROTECTED_SUBJECT`). */
103
+ const PLACEHOLDER_TITLE = "[...]";
104
+ /** What `SealedEventContentError` says. */
105
+ export const SEALED_EVENT_CONTENT_MESSAGE = "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.";
106
+ /**
107
+ * restapi's refusal (409) of a location or description change to an event made from a zero-access invitation (`sealedContent`) sent
108
+ * with its placeholder title or none: resealing would lose the title. `saveCalendarEvent()` leaves out the placeholders of one it hasn't
109
+ * 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
110
+ * `ApiRequestError` (409), as `SealedMoveError` is, so the event dialog and the drag handler show its message.
111
+ */
112
+ export class SealedEventContentError extends ApiRequestError {
113
+ constructor() {
114
+ super(SEALED_EVENT_CONTENT_MESSAGE, 409, "sealed-event-content");
115
+ this.name = "SealedEventContentError";
116
+ }
72
117
  }
73
- export function updateCalendarEvent(input, client) {
74
- return withClient(client, `/mail/calendar-events/${encodeURIComponent(input.uid)}`, {
75
- method: "PUT",
76
- body: JSON.stringify(input),
118
+ /** Whether `err` is restapi's refusal of `body`, a write carrying a location or description without a real title. */
119
+ function isSealedContentTitleRefusal(err, body) {
120
+ const hasValue = (field) => body[field] !== undefined && body[field] !== null && body[field] !== "";
121
+ return (err instanceof ApiRequestError &&
122
+ err.status === 409 &&
123
+ err.code === INVALID_REQUEST_CODE &&
124
+ (body.title === undefined || body.title === PLACEHOLDER_TITLE) &&
125
+ ["location", "description", "descriptionHtml"].some(hasValue));
126
+ }
127
+ async function saveCalendarEvent(input, client, current) {
128
+ current = await fullyOpenedEvent(current);
129
+ const { invitationContent: given, ...changes } = input;
130
+ const body = protectedEventWrite(current, changes);
131
+ if (given?.defer) {
132
+ body.invitationContent = { defer: true };
133
+ }
134
+ else if (given?.title && !body.invitationContent && isProtectedRecord(current)) {
135
+ body.invitationContent = { title: given.title, location: given.location, description: given.description, descriptionHtml: given.descriptionHtml };
136
+ }
137
+ // A save that defers its invitations sends no link keys either: they go with the save that invites.
138
+ const secrets = given?.defer
139
+ ? undefined
140
+ : await meetingLinkSecrets({ videoMeetingUid: input.videoMeetingUid ?? current?.videoMeetingUid, attendees: input.attendees ?? current?.attendees, organizer: current?.organizer ?? input.organizer }, client);
141
+ if (secrets) {
142
+ body.invitationContent = { ...(body.invitationContent ?? {}), meetingLinkSecrets: secrets };
143
+ }
144
+ let updated;
145
+ try {
146
+ updated = await withClient(client, `/mail/calendar-events/${encodeURIComponent(input.uid)}`, {
147
+ method: "PUT",
148
+ body: JSON.stringify(body),
149
+ });
150
+ }
151
+ catch (err) {
152
+ throw isSealedContentTitleRefusal(err, body) ? new SealedEventContentError() : err;
153
+ }
154
+ if (!updated.sealedContent || !isSealedContentOpened(current)) {
155
+ return opened(updated);
156
+ }
157
+ // An event made from a zero-access invitation, opened here, comes back with its placeholders: shown with the content this save sealed
158
+ // again (`protectedEventWrite()` sends all of it, or none and the sealed content stays), so a save that follows still writes it whole.
159
+ const content = Object.fromEntries(EVENT_CONTENT_FIELDS.map((field) => [field, field in body ? body[field] : current[field]]));
160
+ return markSealedContentOpened({
161
+ ...(await opened(updated)),
162
+ title: content.title ?? "",
163
+ location: content.location ?? undefined,
164
+ description: content.description ?? null,
165
+ descriptionHtml: content.descriptionHtml ?? null,
77
166
  });
78
167
  }
79
168
  export function deleteCalendarEvent(uid, version, client) {
@@ -86,23 +175,27 @@ export function deleteCalendarEvent(uid, version, client) {
86
175
  * `"accepted"`/`"tentative"` the whole updated `CalendarEvent` comes back, with only the calling
87
176
  * attendee's own `responseStatus` changed. On `"declined"`, the backend instead soft-deletes the
88
177
  * mailbox's own copy of the event and returns only `{ uid }` — the caller should treat a decline the
89
- * same as a delete rather than expecting an updated event back.
178
+ * same as a delete rather than expecting an updated event back. `current` (the event as this device shows it) names a protected event's
179
+ * reply by its real title (`invitationContentOf()`).
90
180
  */
91
- export function respondToEvent(uid, responseStatus, client) {
92
- return withClient(client, `/mail/calendar-events/${encodeURIComponent(uid)}/respond`, {
181
+ export async function respondToEvent(uid, responseStatus, client, current) {
182
+ const invitationContent = invitationContentOf(await fullyOpenedEvent(current));
183
+ const result = await withClient(client, `/mail/calendar-events/${encodeURIComponent(uid)}/respond`, {
93
184
  method: "POST",
94
- body: JSON.stringify({ responseStatus }),
185
+ body: JSON.stringify(invitationContent ? { responseStatus, invitationContent } : { responseStatus }),
95
186
  });
187
+ return "mailboxUid" in result ? opened(result) : result;
96
188
  }
97
189
  /**
98
190
  * Asks the organizer to change an event, as a guest (not the organizer) whose copy allows it: the title, location, description or time need
99
191
  * `guestsCanModify`, added guests need `guestsCanInviteOthers` (403 otherwise; 400 when nothing differs from what the event has, or a value is invalid).
100
192
  * 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
101
- * arrives back later as an ordinary update of the invitation.
193
+ * arrives back later as an ordinary update of the invitation. `invitationContent` (the event's real content, `invitationContentOf()`)
194
+ * names a protected event in the request by its real title.
102
195
  */
103
- export function requestEventChange(uid, request, client) {
196
+ export function requestEventChange(uid, request, client, invitationContent) {
104
197
  return withClient(client, `/mail/calendar-events/${encodeURIComponent(uid)}/request-change`, {
105
198
  method: "POST",
106
- body: JSON.stringify(request),
199
+ body: JSON.stringify(invitationContent ? { ...request, invitationContent } : request),
107
200
  });
108
201
  }
@@ -13,7 +13,19 @@
13
13
  */
14
14
  import { CalendarEvent, CalendarEventInput } from "./calendarApi.js";
15
15
  import { CalendarOccurrence } from "./recurrence.js";
16
- import { ApiClient } from "../util/api.js";
16
+ import { ApiClient, ApiRequestError } from "../util/api.js";
17
+ /** What `SealedOccurrenceError` says. */
18
+ export declare const SEALED_OCCURRENCE_MESSAGE = "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.";
19
+ /**
20
+ * One occurrence of an event made from an invitation that reached a zero-access mailbox (`sealedContent`) that `detachOccurrence()` can't
21
+ * split off without its content being stored readable: one moved to another calendar (restapi re-seals a detached occurrence's content
22
+ * 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
23
+ * unsealed (one from before restapi re-sealed such a create - the copy is deleted at once). An `ApiRequestError` (409), as
24
+ * `SealedMoveError` is, so the event dialog and the drag handler show its message.
25
+ */
26
+ export declare class SealedOccurrenceError extends ApiRequestError {
27
+ constructor();
28
+ }
17
29
  /** `saveEventSeries()`'s result: the saved master, plus whether its detached occurrences could be kept in
18
30
  * step with a rescheduled series. */
19
31
  export type SeriesSaveResult = CalendarEvent & {
@@ -47,18 +59,19 @@ export declare function saveEventSeries(occurrence: CalendarOccurrence, fields:
47
59
  * original start as its `recurrenceId` (the RFC5545 way of identifying a modified instance), and never
48
60
  * carries the series' `recurrenceRule` — even if `fields` includes one (e.g. the event modal's full
49
61
  * field set).
62
+ *
63
+ * A protected occurrence (zero-access) is copied from its opened content: one that can't be opened here - its mailbox is locked - holds
64
+ * only placeholders, which the new event would store as its real title, location and description. It throws `KeysLockedError` instead,
65
+ * before anything is written. One of an event made from a zero-access invitation (`sealedContent`) is sent opened too: restapi's create
66
+ * seals it again to the series' key (`prepareCreate()` finds the series by `icalUid` in the same mailbox), storing only placeholders, and
67
+ * the event returned is marked opened with the content sent. Moved to another calendar it throws `SealedOccurrenceError` before anything
68
+ * 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.
50
69
  */
51
70
  export declare function detachOccurrence(occurrence: CalendarOccurrence, fields: Partial<CalendarEventInput>, client?: ApiClient): Promise<CalendarEvent>;
52
71
  /** Deletes an entire recurring series (or a genuinely non-recurring event) outright. */
53
72
  export declare function deleteEventSeries(occurrence: CalendarOccurrence, client?: ApiClient): Promise<void>;
54
73
  /** Removes just this one occurrence from a recurring series, leaving the rest of the series intact. */
55
74
  export declare function deleteEventOccurrence(occurrence: CalendarOccurrence, client?: ApiClient): Promise<CalendarEvent>;
56
- /**
57
- * Moves `occurrence` by `deltaMs` (drag-to-move on a grid), preserving its original duration. A
58
- * recurring occurrence is always detached into its own standalone event — matching Outlook's own
59
- * drag behavior, which never silently reschedules an entire series from a single dragged instance;
60
- * moving the whole series is only ever done explicitly, via the event modal's "entire series" option.
61
- */
62
75
  export declare function moveOccurrence(occurrence: CalendarOccurrence, deltaMs: number, client?: ApiClient): Promise<CalendarEvent>;
63
76
  /**
64
77
  * Resizes `occurrence`'s end time (drag-to-resize on a week/day grid), keeping its start fixed and
@@ -15,13 +15,33 @@
15
15
  * fields, with `recurrenceId` set to that original occurrence's start for traceability — mirroring
16
16
  * how `CalendarEvent.recurrenceId` is documented to work.
17
17
  */
18
- import { createCalendarEvent, deleteCalendarEvent, getCalendarEvent, listCalendarEvents, updateCalendarEvent, } from "./calendarApi.js";
19
- import { fromEventWallClock, toEventWallClock } from "./recurrence.js";
18
+ import { createCalendarEvent, deleteCalendarEvent, fullyOpenedEvent, getCalendarEvent, listCalendarEvents, updateCalendarEvent, } from "./calendarApi.js";
19
+ import { fromEventWallClock, seriesEndDate, toEventWallClock } from "./recurrence.js";
20
+ import { ApiRequestError } from "../util/api.js";
21
+ import { KeysLockedError } from "../crypto/masterKey.js";
22
+ import { isOpenedRecord, isProtectedRecord } from "../zeroAccess/sealedRecords.js";
23
+ import { followMeetingKeyExpiry } from "../videoconf/protectedMeetings.js";
24
+ import { isSealedContentOpened, markSealedContentOpened } from "./protectedEvents.js";
20
25
  /** `client`, given by every function below, is an explicit `ApiClient` from `createApiClient()` (e.g. one
21
26
  * account of a multi-account app) to call instead of the default global `apiFetch()` - see `withClient()`'s
22
27
  * own doc comment in `util/api.ts`. Omitted (the default), every function here behaves exactly as before,
23
28
  * and threads the same `client` through to every `calendarApi.ts` call it makes internally. */
24
29
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
30
+ /** What `SealedOccurrenceError` says. */
31
+ export const SEALED_OCCURRENCE_MESSAGE = "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.";
32
+ /**
33
+ * One occurrence of an event made from an invitation that reached a zero-access mailbox (`sealedContent`) that `detachOccurrence()` can't
34
+ * split off without its content being stored readable: one moved to another calendar (restapi re-seals a detached occurrence's content
35
+ * 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
36
+ * unsealed (one from before restapi re-sealed such a create - the copy is deleted at once). An `ApiRequestError` (409), as
37
+ * `SealedMoveError` is, so the event dialog and the drag handler show its message.
38
+ */
39
+ export class SealedOccurrenceError extends ApiRequestError {
40
+ constructor() {
41
+ super(SEALED_OCCURRENCE_MESSAGE, 409, "sealed-occurrence");
42
+ this.name = "SealedOccurrenceError";
43
+ }
44
+ }
25
45
  function positiveModulo(value, modulus) {
26
46
  return ((value % modulus) + modulus) % modulus;
27
47
  }
@@ -31,7 +51,7 @@ function addException(event, occurrenceStart, client) {
31
51
  uid: event.uid,
32
52
  version: event.version,
33
53
  recurrenceRule: { ...rule, exceptions: [...rule.exceptions, occurrenceStart] },
34
- }, client);
54
+ }, client, event);
35
55
  }
36
56
  /**
37
57
  * Applies edited `fields` to every occurrence of a recurring event's series (the master record).
@@ -51,7 +71,7 @@ export async function saveEventSeries(occurrence, fields, client) {
51
71
  // master's existing (unchanged) instant onto a different wall clock, exactly as the doc comment above
52
72
  // promises - so all three fields, not just startDate, gate entry into the shift path below.
53
73
  if (fields.startDate === undefined && fields.timezone === undefined && fields.allDay === undefined) {
54
- return updateCalendarEvent(update, client);
74
+ return updateCalendarEvent(update, client, occurrence);
55
75
  }
56
76
  const master = await getCalendarEvent(occurrence.uid, client);
57
77
  const newTimezone = fields.timezone ?? master.timezone;
@@ -60,7 +80,7 @@ export async function saveEventSeries(occurrence, fields, client) {
60
80
  const newStartInstant = Date.parse(fields.startDate ?? master.startDate);
61
81
  const deltaWallMs = toEventWallClock(newStartInstant, newTimezone, newAllDay) - oldStartWall;
62
82
  if (deltaWallMs === 0 && newTimezone === master.timezone && newAllDay === master.allDay) {
63
- return updateCalendarEvent(update, client);
83
+ return updateCalendarEvent(update, client, occurrence);
64
84
  }
65
85
  // An occurrence is identified by its nominal wall-clock start (the rule's date + the master's time of day),
66
86
  // not by the instant's own wall time: one that fell in a DST gap was pushed forward (02:30 -> 03:30 EDT),
@@ -78,7 +98,7 @@ export async function saveEventSeries(occurrence, fields, client) {
78
98
  if (rule) {
79
99
  update.recurrenceRule = { ...rule, exceptions: rule.exceptions.map(shift) };
80
100
  }
81
- const saved = await updateCalendarEvent(update, client);
101
+ const saved = await updateCalendarEvent(update, client, occurrence);
82
102
  let detachedOccurrenceSyncFailed = false;
83
103
  try {
84
104
  const detached = (await listCalendarEvents(master.folderUid, client)).filter((event) => event.icalUid === master.icalUid && event.uid !== master.uid && event.recurrenceId);
@@ -86,7 +106,7 @@ export async function saveEventSeries(occurrence, fields, client) {
86
106
  try {
87
107
  // `recurrenceId` isn't part of `CalendarEventInput`, but restapi's update accepts it (the same
88
108
  // way `detachOccurrence()` sends it on create).
89
- await updateCalendarEvent({ uid: event.uid, version: event.version, recurrenceId: shift(event.recurrenceId) }, client);
109
+ await updateCalendarEvent({ uid: event.uid, version: event.version, recurrenceId: shift(event.recurrenceId) }, client, event);
90
110
  }
91
111
  catch {
92
112
  detachedOccurrenceSyncFailed = true;
@@ -109,8 +129,28 @@ export async function saveEventSeries(occurrence, fields, client) {
109
129
  * original start as its `recurrenceId` (the RFC5545 way of identifying a modified instance), and never
110
130
  * carries the series' `recurrenceRule` — even if `fields` includes one (e.g. the event modal's full
111
131
  * field set).
132
+ *
133
+ * A protected occurrence (zero-access) is copied from its opened content: one that can't be opened here - its mailbox is locked - holds
134
+ * only placeholders, which the new event would store as its real title, location and description. It throws `KeysLockedError` instead,
135
+ * before anything is written. One of an event made from a zero-access invitation (`sealedContent`) is sent opened too: restapi's create
136
+ * seals it again to the series' key (`prepareCreate()` finds the series by `icalUid` in the same mailbox), storing only placeholders, and
137
+ * the event returned is marked opened with the content sent. Moved to another calendar it throws `SealedOccurrenceError` before anything
138
+ * 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.
112
139
  */
113
140
  export async function detachOccurrence(occurrence, fields, client) {
141
+ occurrence = await fullyOpenedEvent(occurrence);
142
+ // Nor one of an event made from an invitation to a zero-access mailbox (`sealedContent`) whose content wasn't opened - asked of the
143
+ // mark, not of its title: an untitled one opens to an empty title, and a title can be anything.
144
+ if ((isProtectedRecord(occurrence) && !isOpenedRecord(occurrence)) || (occurrence.sealedContent && !isSealedContentOpened(occurrence))) {
145
+ throw new KeysLockedError("This event is protected and your mailbox is locked. Unlock it to change one occurrence of the event.");
146
+ }
147
+ const sealedContent = !!occurrence.sealedContent;
148
+ // Sealed again only in the series' own calendar (see `SealedOccurrenceError`).
149
+ if (sealedContent &&
150
+ ((fields.mailboxUid !== undefined && fields.mailboxUid !== occurrence.mailboxUid) ||
151
+ (fields.folderUid !== undefined && fields.folderUid !== occurrence.folderUid))) {
152
+ throw new SealedOccurrenceError();
153
+ }
114
154
  const originalStart = occurrence.recurrenceId;
115
155
  // `icalUid`/`recurrenceId` aren't part of `CalendarEventInput`, but `@rapidmx/restapi`'s
116
156
  // `CalendarEvent` model accepts both on create (and `createCalendarEvent` already sends `icalUid`).
@@ -143,6 +183,11 @@ export async function detachOccurrence(occurrence, fields, client) {
143
183
  recurrenceId: originalStart,
144
184
  };
145
185
  const created = await createCalendarEvent(input, client);
186
+ if (sealedContent && !created.sealedContent) {
187
+ // A server that stored the opened content readable: taken back at once, and the occurrence left as it was.
188
+ await deleteCalendarEvent(created.uid, created.version, client).catch(() => undefined);
189
+ throw new SealedOccurrenceError();
190
+ }
146
191
  try {
147
192
  await addException(occurrence, originalStart, client);
148
193
  }
@@ -150,7 +195,16 @@ export async function detachOccurrence(occurrence, fields, client) {
150
195
  await deleteCalendarEvent(created.uid, created.version, client).catch(() => undefined);
151
196
  throw err;
152
197
  }
153
- return created;
198
+ // Stored with placeholders: shown with the content it was given, as an opened event.
199
+ return sealedContent
200
+ ? markSealedContentOpened({
201
+ ...created,
202
+ title: input.title,
203
+ location: input.location,
204
+ description: input.description ?? null,
205
+ descriptionHtml: input.descriptionHtml ?? null,
206
+ })
207
+ : created;
154
208
  }
155
209
  /** Deletes an entire recurring series (or a genuinely non-recurring event) outright. */
156
210
  export function deleteEventSeries(occurrence, client) {
@@ -166,14 +220,21 @@ export function deleteEventOccurrence(occurrence, client) {
166
220
  * drag behavior, which never silently reschedules an entire series from a single dragged instance;
167
221
  * moving the whole series is only ever done explicitly, via the event modal's "entire series" option.
168
222
  */
223
+ /** 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
224
+ * 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. */
225
+ async function reschedule(occurrence, times, client) {
226
+ const saved = await updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...times }, client, occurrence);
227
+ if (saved.videoMeetingUid) {
228
+ await followMeetingKeyExpiry(saved.videoMeetingUid, seriesEndDate(saved), client);
229
+ }
230
+ return saved;
231
+ }
169
232
  export function moveOccurrence(occurrence, deltaMs, client) {
170
233
  const fields = {
171
234
  startDate: new Date(new Date(occurrence.startDate).getTime() + deltaMs).toISOString(),
172
235
  endDate: new Date(new Date(occurrence.endDate).getTime() + deltaMs).toISOString(),
173
236
  };
174
- return occurrence.isRecurringOccurrence
175
- ? detachOccurrence(occurrence, fields, client)
176
- : updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, ...fields }, client);
237
+ return occurrence.isRecurringOccurrence ? detachOccurrence(occurrence, fields, client) : reschedule(occurrence, fields, client);
177
238
  }
178
239
  /**
179
240
  * Resizes `occurrence`'s end time (drag-to-resize on a week/day grid), keeping its start fixed and
@@ -184,7 +245,5 @@ export function resizeOccurrenceEnd(occurrence, newEndDate, client) {
184
245
  const start = new Date(occurrence.startDate);
185
246
  const minEnd = new Date(start.getTime() + 15 * 60000);
186
247
  const endDate = (newEndDate.getTime() > minEnd.getTime() ? newEndDate : minEnd).toISOString();
187
- return occurrence.isRecurringOccurrence
188
- ? detachOccurrence(occurrence, { endDate }, client)
189
- : updateCalendarEvent({ uid: occurrence.uid, version: occurrence.version, endDate }, client);
248
+ return occurrence.isRecurringOccurrence ? detachOccurrence(occurrence, { endDate }, client) : reschedule(occurrence, { endDate }, client);
190
249
  }
@@ -4,7 +4,7 @@
4
4
  * iTIP REPLY to the organizer. The client only reads the state and asks for a change; every call returns the invitation as
5
5
  * it now stands, ready to draw. */
6
6
  import { ApiClient } from "../util/api.js";
7
- import type { EventVisibility, GuestPermissions } from "./calendarApi.js";
7
+ import { type CalendarEvent, type EventVisibility, type GuestPermissions } from "./calendarApi.js";
8
8
  /** What the reader can answer an invitation with. */
9
9
  export type InviteResponse = "accepted" | "tentative" | "declined";
10
10
  export interface InviteParticipant {
@@ -117,5 +117,19 @@ export interface ProposedTime {
117
117
  }
118
118
  /** Proposes a different time to the organizer of a `REQUEST` (an iTIP `COUNTER` sent by the server). Resolves with the invitation as it now stands. */
119
119
  export declare function proposeNewTime(messageUid: string, proposal: ProposedTime, client?: ApiClient): Promise<MessageInvite>;
120
- /** Accepts an attendee's proposed time (a `COUNTER`) for a meeting the reader organizes: the server moves the event and sends the attendees the update. Resolves with the invitation as it now stands. */
121
- export declare function acceptProposal(messageUid: string, client?: ApiClient): Promise<MessageInvite>;
120
+ /** Opens an event's `sealedContent` with the mailbox's unlocked keys (`apps/shared/calendar/sealedEvents.ts` `openSealedEvent()`), marking it
121
+ * opened, or returns it as it is when it can't. */
122
+ export type SealedContentOpener = (event: CalendarEvent) => Promise<CalendarEvent>;
123
+ /** What `acceptProposal()` refuses with, before sending anything, for a protected meeting this device can't open (its mailbox is locked). */
124
+ export declare 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.";
125
+ /**
126
+ * Accepts an attendee's proposed time (a `COUNTER`) for a meeting the reader organizes (`calendarEventUid`, the invitation's
127
+ * `calendarEventUid`): the server moves the event and sends the attendees the update. For a protected meeting - of a calendar zero-access
128
+ * encryption protects, or with a per-meeting key - the server can't compose the invitations by itself: this sends the decrypted title,
129
+ * location and description and the guests' link keys with it (`invitationContent`). Without them (the mailbox locked, or the meeting's key
130
+ * doesn't open here) the server answers 400 and nothing moves. A meeting made from a zero-access invitation (`sealedContent`) is opened
131
+ * with `openSealedContent` (the app passes `openSealedEvent()`); one that can't be is refused before anything is sent. A meeting with a
132
+ * video meeting has its key expiry moved to follow the new time (`followMeetingKeyExpiry()`, best effort). Resolves with the invitation as
133
+ * it now stands.
134
+ */
135
+ export declare function acceptProposal(messageUid: string, client?: ApiClient, calendarEventUid?: string, openSealedContent?: SealedContentOpener): Promise<MessageInvite>;
@@ -8,6 +8,11 @@
8
8
  * iTIP REPLY to the organizer. The client only reads the state and asks for a change; every call returns the invitation as
9
9
  * it now stands, ready to draw. */
10
10
  import { ApiRequestError, withClient } from "../util/api.js";
11
+ import { followMeetingKeyExpiry, meetingLinkSecrets } from "../videoconf/protectedMeetings.js";
12
+ import { isProtectedRecord } from "../zeroAccess/sealedRecords.js";
13
+ import { getCalendarEvent } from "./calendarApi.js";
14
+ import { invitationContentOf } from "./protectedEvents.js";
15
+ import { seriesEndDate } from "./recurrence.js";
11
16
  import { sealedInviteCalendar } from "./sealedInvites.js";
12
17
  /** The calendar file to send for `messageUid` - a zero-access message this device decrypted (`sealedInvites.ts`) - as the request body's
13
18
  * `ics`, or nothing for a message the server reads itself. */
@@ -66,11 +71,63 @@ export function proposeNewTime(messageUid, proposal, client) {
66
71
  body: JSON.stringify({ ...proposal, ...sealedSource(messageUid) }),
67
72
  });
68
73
  }
69
- /** Accepts an attendee's proposed time (a `COUNTER`) for a meeting the reader organizes: the server moves the event and sends the attendees the update. Resolves with the invitation as it now stands. */
70
- export function acceptProposal(messageUid, client) {
74
+ /** What `acceptProposal()` refuses with, before sending anything, for a protected meeting this device can't open (its mailbox is locked). */
75
+ 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.";
76
+ /**
77
+ * What the server needs to send the moved meeting's invitations itself (`invitationContent`): for a meeting of a protected calendar
78
+ * (`sealedFields`), or made from a zero-access invitation (`sealedContent`, opened with `openSealedContent`), its real title, location and
79
+ * description, and for a meeting protected by a per-meeting key every guest's link key
80
+ * (`meetingLinkSecrets`) - with the event as read. No content for a meeting that needs neither (the server's scheduling job invites for it),
81
+ * 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.
82
+ */
83
+ async function proposalInvitations(calendarEventUid, client, openSealedContent) {
84
+ if (!calendarEventUid) {
85
+ return {};
86
+ }
87
+ let event;
88
+ try {
89
+ event = await getCalendarEvent(calendarEventUid, client);
90
+ }
91
+ catch {
92
+ // Unreadable here: the server, which reads it, decides whether the proposal can be accepted without them.
93
+ return {};
94
+ }
95
+ // A meeting made from a zero-access invitation keeps its content sealed whole (`sealedContent`), which `getCalendarEvent()` doesn't
96
+ // open: opened here, it is invited for with its real title, location and description.
97
+ if (event.sealedContent && openSealedContent) {
98
+ event = await openSealedContent(event);
99
+ }
100
+ const content = invitationContentOf(event);
101
+ if ((isProtectedRecord(event) || event.sealedContent) && !content && event.encryptionOrigin !== "originated") {
102
+ throw new ApiRequestError(PROTECTED_PROPOSAL_MESSAGE, 400);
103
+ }
104
+ const secrets = await meetingLinkSecrets({ videoMeetingUid: event.videoMeetingUid, attendees: event.attendees, organizer: event.organizer }, client);
105
+ if (!content && !secrets) {
106
+ return { event };
107
+ }
108
+ return { event, invitationContent: { ...(content ?? {}), ...(secrets ? { meetingLinkSecrets: secrets } : {}) } };
109
+ }
110
+ /**
111
+ * Accepts an attendee's proposed time (a `COUNTER`) for a meeting the reader organizes (`calendarEventUid`, the invitation's
112
+ * `calendarEventUid`): the server moves the event and sends the attendees the update. For a protected meeting - of a calendar zero-access
113
+ * encryption protects, or with a per-meeting key - the server can't compose the invitations by itself: this sends the decrypted title,
114
+ * location and description and the guests' link keys with it (`invitationContent`). Without them (the mailbox locked, or the meeting's key
115
+ * doesn't open here) the server answers 400 and nothing moves. A meeting made from a zero-access invitation (`sealedContent`) is opened
116
+ * with `openSealedContent` (the app passes `openSealedEvent()`); one that can't be is refused before anything is sent. A meeting with a
117
+ * video meeting has its key expiry moved to follow the new time (`followMeetingKeyExpiry()`, best effort). Resolves with the invitation as
118
+ * it now stands.
119
+ */
120
+ export async function acceptProposal(messageUid, client, calendarEventUid, openSealedContent) {
71
121
  const source = sealedSource(messageUid);
72
- return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/accept-proposal`, {
122
+ const { event, invitationContent } = await proposalInvitations(calendarEventUid, client, openSealedContent);
123
+ const body = { ...source, ...(invitationContent ? { invitationContent } : {}) };
124
+ const accepted = await withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/accept-proposal`, {
73
125
  method: "POST",
74
- ...(source.ics !== undefined ? { body: JSON.stringify(source) } : {}),
126
+ ...(Object.keys(body).length > 0 ? { body: JSON.stringify(body) } : {}),
75
127
  });
128
+ // A `COUNTER`'s `startDate`/`endDate` are the proposed time, which the meeting now has.
129
+ if (event?.videoMeetingUid && accepted.startDate && accepted.endDate) {
130
+ await followMeetingKeyExpiry(event.videoMeetingUid, seriesEndDate({ ...event, startDate: accepted.startDate, endDate: accepted.endDate }), client);
131
+ }
132
+ return accepted;
76
133
  }