@rapidmx/web-client 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/apps/escrow/matters/[uid].tsx +4 -3
  2. package/apps/shared/calendar/calendarReminders.ts +35 -0
  3. package/apps/shared/calendar/sealedEvents.ts +113 -0
  4. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  5. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  6. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +89 -3
  7. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  8. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  9. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  10. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  11. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  12. package/apps/shared/components/calendar/eventForm.ts +2 -0
  13. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  14. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  15. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  16. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  17. package/apps/shared/components/contacts/contactCardData.ts +8 -4
  18. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  19. package/apps/shared/components/layout/AppShell.tsx +34 -5
  20. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +30 -1
  21. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +122 -0
  22. package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
  23. package/apps/shared/components/mail/ConversationList.tsx +1 -1
  24. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  25. package/apps/shared/components/mail/MessageDetailPane.tsx +59 -26
  26. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  27. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  28. package/apps/shared/components/mail/compose/draftResume.ts +28 -3
  29. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  30. package/apps/shared/components/mail/compose/quotedBody.ts +4 -3
  31. package/apps/shared/components/mail/invite/inviteStore.ts +17 -1
  32. package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
  33. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  34. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  35. package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
  36. package/apps/shared/components/settings/KeyExportSetting.tsx +173 -0
  37. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  38. package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
  39. package/apps/shared/components/settings/ZeroAccessSetting.tsx +128 -0
  40. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  41. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  42. package/apps/shared/mail/decryptedMessages.ts +11 -3
  43. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  44. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  45. package/apps/shared/mail/reportNotices.ts +3 -1
  46. package/apps/shared/mail/zeroAccessSource.ts +27 -0
  47. package/apps/shared/search/LocalIndexLifecycle.tsx +84 -24
  48. package/apps/shared/search/localIndexBuilder.ts +57 -20
  49. package/apps/shared/search/localIndexRpcClient.ts +96 -12
  50. package/apps/shared/search/localIndexSchema.ts +20 -4
  51. package/apps/shared/search/localIndexTransport.ts +6 -2
  52. package/apps/shared/search/localIndexWorker.ts +37 -18
  53. package/apps/shared/search/searchTier2.ts +5 -0
  54. package/apps/shared/search/zeroAccessIndexSync.ts +679 -0
  55. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  56. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  57. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  58. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  59. package/apps/www/_shell.tsx +2 -0
  60. package/apps/www/calendar/index.tsx +225 -61
  61. package/apps/www/contacts/index.tsx +67 -11
  62. package/apps/www/index.tsx +19 -3
  63. package/apps/www/settings/encryption/index.tsx +21 -1
  64. package/apps/www/settings/privacy/index.tsx +89 -39
  65. package/apps/www/tasks/index.tsx +24 -6
  66. package/dist/apps/escrow/matters/[uid].js +2 -2
  67. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  68. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  69. package/dist/apps/shared/calendar/sealedEvents.d.ts +14 -0
  70. package/dist/apps/shared/calendar/sealedEvents.js +85 -0
  71. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  72. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  73. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  74. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +25 -2
  75. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  76. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  77. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  78. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  79. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  80. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  81. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  82. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  83. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  84. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  85. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  86. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  87. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  88. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  89. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  90. package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
  91. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  92. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  93. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  94. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  95. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +27 -2
  96. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +17 -0
  97. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +98 -0
  98. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
  99. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
  100. package/dist/apps/shared/components/mail/ConversationList.js +1 -1
  101. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  102. package/dist/apps/shared/components/mail/MessageDetailPane.js +46 -21
  103. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  104. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  105. package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
  106. package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
  107. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  108. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  109. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +1 -1
  110. package/dist/apps/shared/components/mail/compose/quotedBody.js +4 -3
  111. package/dist/apps/shared/components/mail/invite/inviteStore.js +16 -1
  112. package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
  113. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  114. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  115. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  116. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  117. package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
  118. package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
  119. package/dist/apps/shared/components/settings/KeyExportSetting.js +90 -0
  120. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  121. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  122. package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
  123. package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
  124. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +16 -0
  125. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +80 -0
  126. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  127. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  128. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  129. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  130. package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
  131. package/dist/apps/shared/mail/decryptedMessages.js +11 -3
  132. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  133. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  134. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  135. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  136. package/dist/apps/shared/mail/reportNotices.js +3 -1
  137. package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
  138. package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
  139. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
  140. package/dist/apps/shared/search/LocalIndexLifecycle.js +74 -24
  141. package/dist/apps/shared/search/localIndexBuilder.d.ts +9 -1
  142. package/dist/apps/shared/search/localIndexBuilder.js +50 -17
  143. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  144. package/dist/apps/shared/search/localIndexRpcClient.js +129 -11
  145. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  146. package/dist/apps/shared/search/localIndexSchema.js +18 -4
  147. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -2
  148. package/dist/apps/shared/search/localIndexWorker.d.ts +11 -0
  149. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  150. package/dist/apps/shared/search/searchTier2.js +5 -0
  151. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +102 -0
  152. package/dist/apps/shared/search/zeroAccessIndexSync.js +544 -0
  153. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  154. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  155. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  156. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  157. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  158. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  159. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  160. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  161. package/dist/apps/www/_shell.d.ts +1 -1
  162. package/dist/apps/www/_shell.js +2 -2
  163. package/dist/apps/www/calendar/index.js +88 -20
  164. package/dist/apps/www/contacts/index.js +64 -11
  165. package/dist/apps/www/index.js +19 -3
  166. package/dist/apps/www/settings/encryption/index.js +16 -4
  167. package/dist/apps/www/settings/privacy/index.js +47 -6
  168. package/dist/apps/www/tasks/index.js +21 -7
  169. package/dist/lib/admin/domainsApi.d.ts +1 -1
  170. package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
  171. package/dist/lib/calendar/calendarApi.d.ts +52 -6
  172. package/dist/lib/calendar/calendarApi.js +110 -17
  173. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  174. package/dist/lib/calendar/calendarMutations.js +73 -14
  175. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  176. package/dist/lib/calendar/inviteApi.js +86 -7
  177. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  178. package/dist/lib/calendar/protectedEvents.js +113 -0
  179. package/dist/lib/calendar/recurrence.d.ts +2 -0
  180. package/dist/lib/calendar/recurrence.js +16 -0
  181. package/dist/lib/calendar/sealedInvites.d.ts +9 -0
  182. package/dist/lib/calendar/sealedInvites.js +65 -0
  183. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  184. package/dist/lib/contacts/contactsApi.js +23 -7
  185. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  186. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  187. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  188. package/dist/lib/crypto/composeSecurity.js +13 -5
  189. package/dist/lib/crypto/keyExport.d.ts +54 -0
  190. package/dist/lib/crypto/keyExport.js +161 -0
  191. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  192. package/dist/lib/crypto/keyPairMatch.js +65 -0
  193. package/dist/lib/crypto/keySession.d.ts +64 -3
  194. package/dist/lib/crypto/keySession.js +179 -12
  195. package/dist/lib/crypto/keyvaultApi.d.ts +129 -8
  196. package/dist/lib/crypto/keyvaultApi.js +45 -3
  197. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  198. package/dist/lib/crypto/meetingKeys.js +87 -0
  199. package/dist/lib/crypto/messageSecurity.d.ts +56 -3
  200. package/dist/lib/crypto/messageSecurity.js +201 -13
  201. package/dist/lib/crypto/mime.d.ts +5 -0
  202. package/dist/lib/crypto/mime.js +25 -0
  203. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  204. package/dist/lib/crypto/openPgpKey.js +89 -0
  205. package/dist/lib/crypto/openpgp.d.ts +47 -0
  206. package/dist/lib/crypto/openpgp.js +88 -0
  207. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  208. package/dist/lib/crypto/pgpMessage.js +271 -0
  209. package/dist/lib/crypto/sharedMailboxKeys.d.ts +62 -0
  210. package/dist/lib/crypto/sharedMailboxKeys.js +337 -0
  211. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  212. package/dist/lib/crypto/smimeMessage.js +11 -7
  213. package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
  214. package/dist/lib/crypto/zeroAccessKey.js +71 -0
  215. package/dist/lib/mail/conversationsApi.d.ts +6 -0
  216. package/dist/lib/mail/mailApi.d.ts +63 -2
  217. package/dist/lib/mail/mailApi.js +5 -1
  218. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  219. package/dist/lib/mail/mailboxImportApi.js +15 -0
  220. package/dist/lib/search/zeroAccessIndexApi.d.ts +85 -0
  221. package/dist/lib/search/zeroAccessIndexApi.js +35 -0
  222. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  223. package/dist/lib/tasks/tasksApi.js +10 -4
  224. package/dist/lib/util/clientPlatform.d.ts +14 -0
  225. package/dist/lib/util/clientPlatform.js +26 -0
  226. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  227. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  228. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  229. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  230. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  231. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  232. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  233. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  234. package/lib/admin/domainsApi.ts +1 -1
  235. package/lib/admin/escrowAccessRequestsApi.ts +6 -0
  236. package/lib/calendar/calendarApi.ts +143 -17
  237. package/lib/calendar/calendarMutations.ts +79 -12
  238. package/lib/calendar/inviteApi.ts +101 -8
  239. package/lib/calendar/protectedEvents.ts +143 -0
  240. package/lib/calendar/recurrence.ts +17 -0
  241. package/lib/calendar/sealedInvites.ts +73 -0
  242. package/lib/contacts/contactsApi.ts +43 -17
  243. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  244. package/lib/crypto/composeSecurity.ts +20 -5
  245. package/lib/crypto/keyExport.ts +229 -0
  246. package/lib/crypto/keyPairMatch.ts +69 -0
  247. package/lib/crypto/keySession.ts +216 -12
  248. package/lib/crypto/keyvaultApi.ts +174 -10
  249. package/lib/crypto/meetingKeys.ts +118 -0
  250. package/lib/crypto/messageSecurity.ts +284 -16
  251. package/lib/crypto/mime.ts +26 -0
  252. package/lib/crypto/openPgpKey.ts +118 -0
  253. package/lib/crypto/openpgp.ts +115 -0
  254. package/lib/crypto/pgpMessage.ts +336 -0
  255. package/lib/crypto/sharedMailboxKeys.ts +402 -0
  256. package/lib/crypto/smimeMessage.ts +11 -7
  257. package/lib/crypto/zeroAccessKey.ts +102 -0
  258. package/lib/mail/conversationsApi.ts +3 -0
  259. package/lib/mail/mailApi.ts +60 -3
  260. package/lib/mail/mailboxImportApi.ts +24 -0
  261. package/lib/search/zeroAccessIndexApi.ts +123 -0
  262. package/lib/tasks/tasksApi.ts +19 -5
  263. package/lib/util/clientPlatform.ts +50 -0
  264. package/lib/videoconf/protectedMeetings.ts +221 -0
  265. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  266. package/lib/zeroAccess/sealedMoves.ts +79 -0
  267. package/lib/zeroAccess/sealedRecords.ts +153 -0
  268. package/package.json +14 -5
@@ -7,8 +7,12 @@
7
7
  * expansion happens entirely client-side (see `apps/shared/lib/recurrence.ts`) — the backend stores/returns
8
8
  * `RecurrenceRule` as-is and does no RFC5545 expansion of its own. */
9
9
 
10
- import { ApiClient, withClient } from "../util/api.js";
10
+ import { ApiClient, ApiRequestError, withClient } from "../util/api.js";
11
11
  import { ListParams, buildQuery } from "../util/apiQuery.js";
12
+ import { INVALID_REQUEST_CODE, guardSealedMove } from "../zeroAccess/sealedMoves.js";
13
+ import { isOpenedRecord, isProtectedRecord, openSealedRecord, openSealedRecords } from "../zeroAccess/sealedRecords.js";
14
+ import { EVENT_CONTENT_FIELDS, type InvitationContent, invitationContentOf, isSealedContentOpened, markSealedContentOpened, protectedEventWrite } from "./protectedEvents.js";
15
+ import { meetingLinkSecrets } from "../videoconf/protectedMeetings.js";
12
16
 
13
17
  export type AttendeeRole = "required" | "optional" | "resource";
14
18
  export type AttendeeResponseStatus = "needsAction" | "accepted" | "declined" | "tentative";
@@ -135,6 +139,13 @@ export interface CalendarEvent {
135
139
  * `mailto` only). Still to be rendered through `sanitizeEventDescriptionHtml()` (`eventDescription.ts`), never trusted as it stands. `null` or absent:
136
140
  * the description, if any, is plain text. */
137
141
  descriptionHtml?: string | null;
142
+ /** Response-only. An event made from an invitation that reached a zero-access mailbox: its title, location and description are only
143
+ * sealed to the mailbox's zero-access key (`title` is `[...]`). `apps/shared/calendar/sealedEvents.ts` opens them. */
144
+ sealedContent?: { keyFingerprint: string; sealed: string };
145
+ /** Response-only. An event of a calendar zero-access encryption protects: its title, location and description, each sealed to the
146
+ * mailbox's zero-access key (the readable `title` is "Protected event"). Every function here returns events with them opened when the
147
+ * mailbox is unlocked (`lib/zeroAccess/sealedRecords.ts`); writes go through `protectedEvents.ts`. */
148
+ sealedFields?: Record<string, string> | null;
138
149
  /** Who may see the event's details - absent (or `null`, on a row stored before this existed) reads as `"default"`; see `visibilityOf()`. */
139
150
  visibility?: EventVisibility | null;
140
151
  /** Whether the guests may ask for the event to change (default `false`). Absent or `null` reads as the default; see `guestPermissionsOf()`. */
@@ -170,6 +181,22 @@ const EVENT_SORT = JSON.stringify({ uid: "ASC" });
170
181
  * `LIST_CALENDAR_EVENTS_MAX_PAGES` pages guards against looping forever should a server ever ignore
171
182
  * `page` and keep returning full pages.
172
183
  */
184
+ /** What a list opens of a protected event: what the calendar views draw. Each field is one decryption, and a calendar lists every event
185
+ * it has - the rest (the description) is opened by the view that shows or edits it, by a search, and by a save. */
186
+ export const EVENT_LIST_FIELDS = ["title", "location"] as const;
187
+
188
+ /** `event` with every protected field opened, when only some were (a listed event) - for a write, which sends only what changed and
189
+ * invites with the whole content. The title of one whose `sealedContent` was opened stays: a row can hold both, and its per-field copy
190
+ * (`sealedFields.title`) is then an older title the sealed content's replaced (renamed since) - or, sealed without one, the same title
191
+ * `openSealedEvents()` already shows. */
192
+ export async function fullyOpenedEvent<T extends CalendarEvent | undefined>(event: T): Promise<T> {
193
+ if (!event || !isProtectedRecord(event) || isOpenedRecord(event)) {
194
+ return event;
195
+ }
196
+ const full = await openSealedRecord(event);
197
+ return isSealedContentOpened(event) ? { ...full, title: event.title } : full;
198
+ }
199
+
173
200
  export async function listCalendarEvents(folderUid: string, client?: ApiClient): Promise<CalendarEvent[]> {
174
201
  const events: CalendarEvent[] = [];
175
202
  for (let page = 0; page < LIST_CALENDAR_EVENTS_MAX_PAGES; page++) {
@@ -182,11 +209,14 @@ export async function listCalendarEvents(folderUid: string, client?: ApiClient):
182
209
  break;
183
210
  }
184
211
  }
185
- return events;
212
+ return openSealedRecords(events, EVENT_LIST_FIELDS);
186
213
  }
187
214
 
188
- export function getCalendarEvent(uid: string, client?: ApiClient): Promise<CalendarEvent> {
189
- return withClient(client, `/mail/calendar-events/${encodeURIComponent(uid)}`);
215
+ /** An event the server returned, opened. */
216
+ const opened = (event: CalendarEvent): Promise<CalendarEvent> => openSealedRecord(event);
217
+
218
+ export async function getCalendarEvent(uid: string, client?: ApiClient): Promise<CalendarEvent> {
219
+ return opened(await withClient<CalendarEvent>(client, `/mail/calendar-events/${encodeURIComponent(uid)}`));
190
220
  }
191
221
 
192
222
  export interface CalendarEventInput {
@@ -218,10 +248,18 @@ export interface CalendarEventInput {
218
248
  guestsCanModify?: boolean;
219
249
  guestsCanInviteOthers?: boolean;
220
250
  guestsCanSeeGuestList?: boolean;
251
+ /**
252
+ * What the server sends a protected event's save-time invitations with (restapi `BaseCalendarEventRoute.takeInvitationContent()`).
253
+ * On a create, `{ defer: true }` holds them back for the save that follows - the event editor's, which attaches the video meeting and
254
+ * its link keys first - so attendees get one invitation, the one with the link. On an update, `{ defer: true }` sends none with this
255
+ * save either (they stay pending), and a content (`title`...) is what `updateCalendarEvent()` invites with when `current` couldn't be
256
+ * opened on this device - the content it opened wins otherwise.
257
+ */
258
+ invitationContent?: Partial<InvitationContent> & { defer?: boolean };
221
259
  }
222
260
 
223
- export function createCalendarEvent(input: CalendarEventInput, client?: ApiClient): Promise<CalendarEvent> {
224
- return withClient(client, "/mail/calendar-events", {
261
+ export async function createCalendarEvent(input: CalendarEventInput, client?: ApiClient): Promise<CalendarEvent> {
262
+ const created = await withClient<CalendarEvent>(client, "/mail/calendar-events", {
225
263
  method: "POST",
226
264
  body: JSON.stringify({
227
265
  allDay: false,
@@ -233,6 +271,7 @@ export function createCalendarEvent(input: CalendarEventInput, client?: ApiClien
233
271
  ...input,
234
272
  }),
235
273
  });
274
+ return opened(created);
236
275
  }
237
276
 
238
277
  export interface UpdateCalendarEventInput extends Partial<CalendarEventInput> {
@@ -240,10 +279,87 @@ export interface UpdateCalendarEventInput extends Partial<CalendarEventInput> {
240
279
  version: number;
241
280
  }
242
281
 
243
- export function updateCalendarEvent(input: UpdateCalendarEventInput, client?: ApiClient): Promise<CalendarEvent> {
244
- return withClient(client, `/mail/calendar-events/${encodeURIComponent(input.uid)}`, {
245
- method: "PUT",
246
- body: JSON.stringify(input),
282
+ /** Saves `input`. `current` is the event as this device shows it: for an event of a protected calendar, the write then sends only the
283
+ * content that changed, and the content the server invites with (`protectedEventWrite()`; `input.invitationContent` when `current` isn't
284
+ * opened, none with `{ defer: true }`). For an event whose video meeting is protected,
285
+ * every attendee's link key goes with it (`meetingLinkSecrets()`), for the server to add to their links in the invitations it sends. */
286
+ export function updateCalendarEvent(input: UpdateCalendarEventInput, client?: ApiClient, current?: CalendarEvent): Promise<CalendarEvent> {
287
+ // A move of a protected event to another mailbox is refused (`SealedMoveError`), before anything is opened or sent when `current` says so.
288
+ return guardSealedMove(current, input.mailboxUid, () => saveCalendarEvent(input, client, current));
289
+ }
290
+
291
+ /** The title restapi shows for an event whose title is sealed (its `PROTECTED_SUBJECT`). */
292
+ const PLACEHOLDER_TITLE = "[...]";
293
+
294
+ /** What `SealedEventContentError` says. */
295
+ export const SEALED_EVENT_CONTENT_MESSAGE =
296
+ "This event came from an invitation to a zero-access mailbox and its details are sealed, so its location or description can only be changed together with its title. Unlock your mailbox, open the event again and make the change there.";
297
+
298
+ /**
299
+ * restapi's refusal (409) of a location or description change to an event made from a zero-access invitation (`sealedContent`) sent
300
+ * with its placeholder title or none: resealing would lose the title. `saveCalendarEvent()` leaves out the placeholders of one it hasn't
301
+ * opened, so this comes back only when this device didn't know the event was sealed (it was filed sealed after it was loaded). An
302
+ * `ApiRequestError` (409), as `SealedMoveError` is, so the event dialog and the drag handler show its message.
303
+ */
304
+ export class SealedEventContentError extends ApiRequestError {
305
+ constructor() {
306
+ super(SEALED_EVENT_CONTENT_MESSAGE, 409, "sealed-event-content");
307
+ this.name = "SealedEventContentError";
308
+ }
309
+ }
310
+
311
+ /** Whether `err` is restapi's refusal of `body`, a write carrying a location or description without a real title. */
312
+ function isSealedContentTitleRefusal(err: unknown, body: Record<string, any>): boolean {
313
+ const hasValue = (field: string): boolean => body[field] !== undefined && body[field] !== null && body[field] !== "";
314
+ return (
315
+ err instanceof ApiRequestError &&
316
+ err.status === 409 &&
317
+ err.code === INVALID_REQUEST_CODE &&
318
+ (body.title === undefined || body.title === PLACEHOLDER_TITLE) &&
319
+ ["location", "description", "descriptionHtml"].some(hasValue)
320
+ );
321
+ }
322
+
323
+ async function saveCalendarEvent(input: UpdateCalendarEventInput, client: ApiClient | undefined, current: CalendarEvent | undefined): Promise<CalendarEvent> {
324
+ current = await fullyOpenedEvent(current);
325
+ const { invitationContent: given, ...changes } = input;
326
+ const body: Record<string, any> = protectedEventWrite(current, changes);
327
+ if (given?.defer) {
328
+ body.invitationContent = { defer: true };
329
+ } else if (given?.title && !body.invitationContent && isProtectedRecord(current)) {
330
+ body.invitationContent = { title: given.title, location: given.location, description: given.description, descriptionHtml: given.descriptionHtml };
331
+ }
332
+ // A save that defers its invitations sends no link keys either: they go with the save that invites.
333
+ const secrets = given?.defer
334
+ ? undefined
335
+ : await meetingLinkSecrets(
336
+ { videoMeetingUid: input.videoMeetingUid ?? current?.videoMeetingUid, attendees: input.attendees ?? current?.attendees, organizer: current?.organizer ?? input.organizer },
337
+ client,
338
+ );
339
+ if (secrets) {
340
+ body.invitationContent = { ...(body.invitationContent ?? {}), meetingLinkSecrets: secrets };
341
+ }
342
+ let updated: CalendarEvent;
343
+ try {
344
+ updated = await withClient<CalendarEvent>(client, `/mail/calendar-events/${encodeURIComponent(input.uid)}`, {
345
+ method: "PUT",
346
+ body: JSON.stringify(body),
347
+ });
348
+ } catch (err) {
349
+ throw isSealedContentTitleRefusal(err, body) ? new SealedEventContentError() : err;
350
+ }
351
+ if (!updated.sealedContent || !isSealedContentOpened(current)) {
352
+ return opened(updated);
353
+ }
354
+ // An event made from a zero-access invitation, opened here, comes back with its placeholders: shown with the content this save sealed
355
+ // again (`protectedEventWrite()` sends all of it, or none and the sealed content stays), so a save that follows still writes it whole.
356
+ const content = Object.fromEntries(EVENT_CONTENT_FIELDS.map((field) => [field, field in body ? body[field] : current![field]]));
357
+ return markSealedContentOpened({
358
+ ...(await opened(updated)),
359
+ title: content.title ?? "",
360
+ location: content.location ?? undefined,
361
+ description: content.description ?? null,
362
+ descriptionHtml: content.descriptionHtml ?? null,
247
363
  });
248
364
  }
249
365
 
@@ -262,17 +378,21 @@ export type AttendeeResponseInput = Exclude<AttendeeResponseStatus, "needsAction
262
378
  * `"accepted"`/`"tentative"` the whole updated `CalendarEvent` comes back, with only the calling
263
379
  * attendee's own `responseStatus` changed. On `"declined"`, the backend instead soft-deletes the
264
380
  * mailbox's own copy of the event and returns only `{ uid }` — the caller should treat a decline the
265
- * same as a delete rather than expecting an updated event back.
381
+ * same as a delete rather than expecting an updated event back. `current` (the event as this device shows it) names a protected event's
382
+ * reply by its real title (`invitationContentOf()`).
266
383
  */
267
- export function respondToEvent(
384
+ export async function respondToEvent(
268
385
  uid: string,
269
386
  responseStatus: AttendeeResponseInput,
270
387
  client?: ApiClient,
388
+ current?: CalendarEvent,
271
389
  ): Promise<CalendarEvent | { uid: string }> {
272
- return withClient(client, `/mail/calendar-events/${encodeURIComponent(uid)}/respond`, {
390
+ const invitationContent = invitationContentOf(await fullyOpenedEvent(current));
391
+ const result = await withClient<CalendarEvent | { uid: string }>(client, `/mail/calendar-events/${encodeURIComponent(uid)}/respond`, {
273
392
  method: "POST",
274
- body: JSON.stringify({ responseStatus }),
393
+ body: JSON.stringify(invitationContent ? { responseStatus, invitationContent } : { responseStatus }),
275
394
  });
395
+ return "mailboxUid" in result ? opened(result) : result;
276
396
  }
277
397
 
278
398
  /** A change a guest asks the organizer for (`requestEventChange()`): only what is to differ. */
@@ -301,11 +421,17 @@ export interface EventChangeRequestResult {
301
421
  * Asks the organizer to change an event, as a guest (not the organizer) whose copy allows it: the title, location, description or time need
302
422
  * `guestsCanModify`, added guests need `guestsCanInviteOthers` (403 otherwise; 400 when nothing differs from what the event has, or a value is invalid).
303
423
  * Nothing changes on this mailbox's own copy - the request is mailed to the organizer, and when the change is allowed it is applied there and
304
- * arrives back later as an ordinary update of the invitation.
424
+ * arrives back later as an ordinary update of the invitation. `invitationContent` (the event's real content, `invitationContentOf()`)
425
+ * names a protected event in the request by its real title.
305
426
  */
306
- export function requestEventChange(uid: string, request: EventChangeRequest, client?: ApiClient): Promise<EventChangeRequestResult> {
427
+ export function requestEventChange(
428
+ uid: string,
429
+ request: EventChangeRequest,
430
+ client?: ApiClient,
431
+ invitationContent?: InvitationContent,
432
+ ): Promise<EventChangeRequestResult> {
307
433
  return withClient(client, `/mail/calendar-events/${encodeURIComponent(uid)}/request-change`, {
308
434
  method: "POST",
309
- body: JSON.stringify(request),
435
+ body: JSON.stringify(invitationContent ? { ...request, invitationContent } : request),
310
436
  });
311
437
  }
@@ -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
- return created;
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,19 @@
9
9
  * it now stands, ready to draw. */
10
10
 
11
11
  import { ApiClient, ApiRequestError, withClient } from "../util/api.js";
12
- import type { EventVisibility, GuestPermissions } from "./calendarApi.js";
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";
17
+ import { sealedInviteCalendar } from "./sealedInvites.js";
18
+
19
+ /** The calendar file to send for `messageUid` - a zero-access message this device decrypted (`sealedInvites.ts`) - as the request body's
20
+ * `ics`, or nothing for a message the server reads itself. */
21
+ function sealedSource(messageUid: string): { ics?: string } {
22
+ const ics = sealedInviteCalendar(messageUid);
23
+ return ics !== undefined ? { ics } : {};
24
+ }
13
25
 
14
26
  /** What the reader can answer an invitation with. */
15
27
  export type InviteResponse = "accepted" | "tentative" | "declined";
@@ -104,8 +116,15 @@ export interface MessageInvite {
104
116
  * common, expected answer for a message that merely has an attachment - and rejects for any other failure.
105
117
  */
106
118
  export async function getMessageInvite(messageUid: string, client?: ApiClient): Promise<MessageInvite | null> {
119
+ const source = sealedSource(messageUid);
107
120
  try {
108
- return await withClient<MessageInvite>(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}`);
121
+ // A zero-access message's invitation is read from the calendar file decrypted here (`POST .../view`).
122
+ return source.ics !== undefined
123
+ ? await withClient<MessageInvite>(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/view`, {
124
+ method: "POST",
125
+ body: JSON.stringify(source),
126
+ })
127
+ : await withClient<MessageInvite>(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}`);
109
128
  } catch (err) {
110
129
  if (err instanceof ApiRequestError && err.status === 404) {
111
130
  return null;
@@ -122,13 +141,17 @@ export async function getMessageInvite(messageUid: string, client?: ApiClient):
122
141
  export function respondToMessageInvite(messageUid: string, responseStatus: InviteResponse, client?: ApiClient): Promise<MessageInvite> {
123
142
  return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/respond`, {
124
143
  method: "POST",
125
- body: JSON.stringify({ responseStatus }),
144
+ body: JSON.stringify({ responseStatus, ...sealedSource(messageUid) }),
126
145
  });
127
146
  }
128
147
 
129
148
  /** Takes a canceled meeting off the reader's calendar. Resolves with the invitation as it now stands. */
130
149
  export function removeMessageInvite(messageUid: string, client?: ApiClient): Promise<MessageInvite> {
131
- return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/remove`, { method: "POST" });
150
+ const source = sealedSource(messageUid);
151
+ return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/remove`, {
152
+ method: "POST",
153
+ ...(source.ics !== undefined ? { body: JSON.stringify(source) } : {}),
154
+ });
132
155
  }
133
156
 
134
157
  /** A different time to suggest to the organizer. */
@@ -144,11 +167,81 @@ export interface ProposedTime {
144
167
  export function proposeNewTime(messageUid: string, proposal: ProposedTime, client?: ApiClient): Promise<MessageInvite> {
145
168
  return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/propose`, {
146
169
  method: "POST",
147
- body: JSON.stringify(proposal),
170
+ body: JSON.stringify({ ...proposal, ...sealedSource(messageUid) }),
148
171
  });
149
172
  }
150
173
 
151
- /** 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. */
152
- export function acceptProposal(messageUid: string, client?: ApiClient): Promise<MessageInvite> {
153
- return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/accept-proposal`, { method: "POST" });
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> {
235
+ const source = sealedSource(messageUid);
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`, {
239
+ method: "POST",
240
+ ...(Object.keys(body).length > 0 ? { body: JSON.stringify(body) } : {}),
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;
154
247
  }