@rapidmx/web-client 0.34.0 → 0.36.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 (148) hide show
  1. package/apps/escrow/matters/[uid].tsx +4 -3
  2. package/apps/shared/calendar/calendarLiveUpdates.ts +2 -5
  3. package/apps/shared/calendar/sealedEvents.ts +80 -0
  4. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +61 -3
  5. package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -1
  6. package/apps/shared/components/contacts/contactCardData.ts +8 -4
  7. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +23 -1
  8. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +91 -0
  9. package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
  10. package/apps/shared/components/mail/ConversationList.tsx +1 -1
  11. package/apps/shared/components/mail/MessageDetailPane.tsx +91 -20
  12. package/apps/shared/components/mail/compose/draftResume.ts +28 -3
  13. package/apps/shared/components/mail/compose/quotedBody.ts +31 -15
  14. package/apps/shared/components/mail/invite/inviteStore.ts +7 -1
  15. package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
  16. package/apps/shared/components/mail/reading/MessageBody.tsx +4 -1
  17. package/apps/shared/components/mail/reading/bodyContent.ts +27 -17
  18. package/apps/shared/components/mail/reading/conversationPrint.ts +7 -5
  19. package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
  20. package/apps/shared/components/settings/KeyExportSetting.tsx +170 -0
  21. package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
  22. package/apps/shared/components/settings/ZeroAccessSetting.tsx +117 -0
  23. package/apps/shared/live/useFolderLiveUpdates.ts +59 -0
  24. package/apps/shared/mail/decryptedMessages.ts +11 -3
  25. package/apps/shared/mail/outbox/sendJob.ts +2 -2
  26. package/apps/shared/mail/outbox/sendOutcomes.ts +1 -3
  27. package/apps/shared/mail/reportNotices.ts +3 -1
  28. package/apps/shared/mail/zeroAccessSource.ts +27 -0
  29. package/apps/shared/search/LocalIndexLifecycle.tsx +31 -0
  30. package/apps/shared/search/localIndexBuilder.ts +27 -1
  31. package/apps/shared/search/localIndexRpcClient.ts +8 -4
  32. package/apps/shared/search/localIndexSchema.ts +2 -2
  33. package/apps/shared/search/localIndexTransport.ts +2 -1
  34. package/apps/shared/search/localIndexWorker.ts +6 -4
  35. package/apps/shared/search/zeroAccessIndexSync.ts +487 -0
  36. package/apps/www/calendar/index.tsx +180 -58
  37. package/apps/www/contacts/index.tsx +58 -2
  38. package/apps/www/index.tsx +10 -3
  39. package/apps/www/settings/encryption/index.tsx +5 -0
  40. package/apps/www/settings/privacy/index.tsx +2 -1
  41. package/apps/www/tasks/index.tsx +42 -0
  42. package/dist/apps/escrow/matters/[uid].js +2 -2
  43. package/dist/apps/shared/calendar/calendarLiveUpdates.js +2 -5
  44. package/dist/apps/shared/calendar/sealedEvents.d.ts +13 -0
  45. package/dist/apps/shared/calendar/sealedEvents.js +54 -0
  46. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +18 -2
  47. package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +3 -1
  48. package/dist/apps/shared/components/contacts/ContactsSidebar.js +2 -2
  49. package/dist/apps/shared/components/contacts/contactCardData.d.ts +2 -2
  50. package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
  51. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +21 -2
  52. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +15 -0
  53. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +72 -0
  54. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
  55. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
  56. package/dist/apps/shared/components/mail/ConversationList.js +1 -1
  57. package/dist/apps/shared/components/mail/MessageDetailPane.js +73 -17
  58. package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
  59. package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
  60. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +4 -3
  61. package/dist/apps/shared/components/mail/compose/quotedBody.js +28 -15
  62. package/dist/apps/shared/components/mail/invite/inviteStore.js +6 -1
  63. package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
  64. package/dist/apps/shared/components/mail/reading/MessageBody.js +4 -1
  65. package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +4 -2
  66. package/dist/apps/shared/components/mail/reading/bodyContent.js +27 -16
  67. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +3 -2
  68. package/dist/apps/shared/components/mail/reading/conversationPrint.js +7 -5
  69. package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
  70. package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
  71. package/dist/apps/shared/components/settings/KeyExportSetting.js +88 -0
  72. package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
  73. package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
  74. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +14 -0
  75. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +71 -0
  76. package/dist/apps/shared/live/useFolderLiveUpdates.d.ts +19 -0
  77. package/dist/apps/shared/live/useFolderLiveUpdates.js +41 -0
  78. package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
  79. package/dist/apps/shared/mail/decryptedMessages.js +11 -3
  80. package/dist/apps/shared/mail/outbox/sendJob.js +2 -2
  81. package/dist/apps/shared/mail/outbox/sendOutcomes.js +1 -3
  82. package/dist/apps/shared/mail/reportNotices.js +3 -1
  83. package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
  84. package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
  85. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
  86. package/dist/apps/shared/search/LocalIndexLifecycle.js +28 -0
  87. package/dist/apps/shared/search/localIndexBuilder.d.ts +5 -0
  88. package/dist/apps/shared/search/localIndexBuilder.js +24 -2
  89. package/dist/apps/shared/search/localIndexRpcClient.d.ts +1 -1
  90. package/dist/apps/shared/search/localIndexRpcClient.js +6 -4
  91. package/dist/apps/shared/search/localIndexSchema.d.ts +1 -1
  92. package/dist/apps/shared/search/localIndexSchema.js +2 -2
  93. package/dist/apps/shared/search/localIndexTransport.d.ts +2 -1
  94. package/dist/apps/shared/search/localIndexWorker.d.ts +2 -0
  95. package/dist/apps/shared/search/localIndexWorker.js +4 -4
  96. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +71 -0
  97. package/dist/apps/shared/search/zeroAccessIndexSync.js +382 -0
  98. package/dist/apps/www/calendar/index.js +48 -22
  99. package/dist/apps/www/contacts/index.js +49 -3
  100. package/dist/apps/www/index.js +10 -3
  101. package/dist/apps/www/settings/encryption/index.js +3 -1
  102. package/dist/apps/www/settings/privacy/index.js +1 -1
  103. package/dist/apps/www/tasks/index.js +29 -1
  104. package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
  105. package/dist/lib/calendar/calendarApi.d.ts +6 -0
  106. package/dist/lib/calendar/inviteApi.js +27 -5
  107. package/dist/lib/calendar/sealedInvites.d.ts +11 -0
  108. package/dist/lib/calendar/sealedInvites.js +28 -0
  109. package/dist/lib/crypto/keyExport.d.ts +54 -0
  110. package/dist/lib/crypto/keyExport.js +161 -0
  111. package/dist/lib/crypto/keySession.d.ts +45 -1
  112. package/dist/lib/crypto/keySession.js +93 -2
  113. package/dist/lib/crypto/keyvaultApi.d.ts +88 -3
  114. package/dist/lib/crypto/keyvaultApi.js +32 -0
  115. package/dist/lib/crypto/messageSecurity.d.ts +30 -1
  116. package/dist/lib/crypto/messageSecurity.js +80 -4
  117. package/dist/lib/crypto/mime.d.ts +5 -0
  118. package/dist/lib/crypto/mime.js +25 -0
  119. package/dist/lib/crypto/sharedMailboxKeys.d.ts +50 -0
  120. package/dist/lib/crypto/sharedMailboxKeys.js +264 -0
  121. package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
  122. package/dist/lib/crypto/zeroAccessKey.js +66 -0
  123. package/dist/lib/mail/conversationsApi.d.ts +6 -0
  124. package/dist/lib/mail/mailApi.d.ts +30 -2
  125. package/dist/lib/mail/mailApi.js +5 -1
  126. package/dist/lib/mail/pushClient.d.ts +10 -3
  127. package/dist/lib/mail/pushClient.js +18 -4
  128. package/dist/lib/search/zeroAccessIndexApi.d.ts +81 -0
  129. package/dist/lib/search/zeroAccessIndexApi.js +35 -0
  130. package/dist/lib/tasks/tasksApi.d.ts +1 -0
  131. package/dist/lib/tasks/tasksApi.js +3 -0
  132. package/lib/admin/escrowAccessRequestsApi.ts +6 -0
  133. package/lib/calendar/calendarApi.ts +3 -0
  134. package/lib/calendar/inviteApi.ts +28 -5
  135. package/lib/calendar/sealedInvites.ts +33 -0
  136. package/lib/crypto/keyExport.ts +229 -0
  137. package/lib/crypto/keySession.ts +117 -3
  138. package/lib/crypto/keyvaultApi.ts +125 -3
  139. package/lib/crypto/messageSecurity.ts +110 -3
  140. package/lib/crypto/mime.ts +26 -0
  141. package/lib/crypto/sharedMailboxKeys.ts +324 -0
  142. package/lib/crypto/zeroAccessKey.ts +97 -0
  143. package/lib/mail/conversationsApi.ts +3 -0
  144. package/lib/mail/mailApi.ts +33 -3
  145. package/lib/mail/pushClient.ts +18 -4
  146. package/lib/search/zeroAccessIndexApi.ts +119 -0
  147. package/lib/tasks/tasksApi.ts +4 -0
  148. package/package.json +3 -3
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { ApiClient, ApiRequestError } from "../util/api.js";
12
12
  import { ListParams } from "../util/apiQuery.js";
13
- import type { EncryptionPreference, PublicKey } from "../crypto/keyvaultApi.js";
13
+ import type { EncryptionPreference, PublicKey, ZeroAccessKey } from "../crypto/keyvaultApi.js";
14
14
  import type { ResolvedPrincipal } from "./mailboxAccessApi.js";
15
15
  export type { ListParams };
16
16
  export type { EncryptionPreference, PublicKey };
@@ -95,6 +95,12 @@ export interface Mailbox {
95
95
  * per the spec's own `PublicKey` doc comment. Absent/empty means no keys enrolled yet
96
96
  * (`KeyEnrollmentGate` handles that state). */
97
97
  keys?: PublicKey[];
98
+ /** The mailbox's zero-access certificates, managed by the server (never written by a client). Read them from
99
+ * `KeyVault.zeroAccessKeys` to unlock; here only so a settings page can tell whether one is enrolled. */
100
+ zeroAccessKeys?: ZeroAccessKey[];
101
+ /** "Enable zero-access encryption": unset means on. Only the mailbox's owner can change it - restapi drops anybody
102
+ * else's value. Has no effect while the server's `EncryptionPolicy.allowZeroAccess` is off. */
103
+ zeroAccessEnabled?: boolean;
98
104
  /** How the caller reaches this mailbox: `"owner"` (theirs) or `"delegate"` (shared with them). Set by the plain list and read of
99
105
  * mailboxes - not in an administration-scope answer - so a client can label the shared ones; see `isSharedWithMe()`. */
100
106
  accessRole?: "owner" | "delegate";
@@ -215,6 +221,8 @@ export interface UpdateMailboxInput {
215
221
  autoSendReceiptsInternal?: boolean;
216
222
  autoSendReceiptsFederated?: boolean;
217
223
  autoSendReceiptsExternal?: boolean;
224
+ /** Owner-only server-side (anybody else's value is dropped) - see `Mailbox.zeroAccessEnabled`. */
225
+ zeroAccessEnabled?: boolean;
218
226
  /** Trusted-caller-only server-side. `null` (or `""`) unassigns the mailbox's escrow scope; the
219
227
  * referenced `EscrowScope` must exist otherwise. See `Mailbox.escrowScopeId`. */
220
228
  escrowScopeId?: string | null;
@@ -424,6 +432,13 @@ export interface Message {
424
432
  * stored body, not from a flag this API exposes. Always present on a real `Message` (defaults
425
433
  * `false` server-side), same optional-for-old-fixtures caveat as `Mailbox.oofEnabled`. */
426
434
  encrypted?: boolean;
435
+ /** Set when the server sealed this message at rest on arrival (zero-access, restapi `specs/search.md` §3): its stored body is an
436
+ * S/MIME message only the mailbox's zero-access key opens, `subject` is `[...]` and `bodyPreview` empty - the real ones are in
437
+ * `sealedSummary`. */
438
+ zeroAccess?: {
439
+ keyFingerprint: string;
440
+ sealedSummary: string;
441
+ };
427
442
  /** The raw RFC 2369/8058 `List-Unsubscribe` header value, if present — computed server-side by
428
443
  * `@rapidmx/restapi`'s `ScanPipeline` from the stored MIME, the strongest single bulk-mail signal it
429
444
  * captures. Used client-side to detect list traffic when replying, per
@@ -560,7 +575,7 @@ export type MessageReportKind = "junk" | "phishing" | "not_junk";
560
575
  /** Why the server did not teach its spam filter from a report: `encrypted` (it has no plaintext to teach), `disabled` (learning is switched off),
561
576
  * `unsupported` (no spam engine, or one that cannot learn), `too_large` (over the size it reads) or `failed` (the engine could not be reached,
562
577
  * refused, or the message could not be read). The report itself still happened. */
563
- export type MessageLearnSkipped = "encrypted" | "disabled" | "unsupported" | "too_large" | "failed";
578
+ export type MessageLearnSkipped = "encrypted" | "zero_access" | "disabled" | "unsupported" | "too_large" | "failed";
564
579
  /** What `reportMessage()` answers. */
565
580
  export interface MessageReportResult {
566
581
  uid: string;
@@ -581,6 +596,9 @@ export interface ReportMessageOptions {
581
596
  /** With `not_junk` only (else a 400): also add the message's From address to the mailbox's safe senders. Needs full access to the mailbox (403 otherwise,
582
597
  * before anything is done). */
583
598
  alwaysTrustSender?: boolean;
599
+ /** For a zero-access message only (else a 400): its original source, base64-encoded, decrypted here - the server holds only the sealed
600
+ * copy, so this is what its spam filter learns from (never stored). Without it the server skips learning (`learnSkipped: "zero_access"`). */
601
+ rawMessage?: string;
584
602
  }
585
603
  /**
586
604
  * Reports a message as junk, phishing or not junk - `POST /mail/messages/:uid/report`. The SERVER moves it (`junk` and `phishing` to the mailbox's Junk Email,
@@ -708,7 +726,15 @@ export interface AssembleDraftInput {
708
726
  bcc?: ComposeRecipientInput[];
709
727
  subject?: string;
710
728
  html: string;
729
+ /** See `AssembleIntent`. */
730
+ intent?: AssembleIntent;
711
731
  }
732
+ /**
733
+ * Why a draft is being assembled (server `ComposeIntent`). `"save"`, the default, keeps an edit: in a zero-access mailbox the server
734
+ * stores it sealed, which it can't send. `"send"` is the assemble right before `sendMessage()`/`queueMessageSend()`: stored readable
735
+ * until it is relayed, then sealed as the Sent Items copy.
736
+ */
737
+ export type AssembleIntent = "save" | "send";
712
738
  /** What a draft records about the thread it belongs to - see `createDraft()`. */
713
739
  export interface DraftThreading {
714
740
  /** The `messageId` of the message being replied to (`Message.inReplyTo`). */
@@ -774,6 +800,8 @@ export interface AssembleDraftRawInput {
774
800
  * `crypto/smimeMessage.ts`'s `buildSignedOnlyMessage()`/`buildEncryptedMessage()`, combined with
775
801
  * the outer envelope headers (`From`/`To`/`Cc`/`Date`/`Message-ID`/`MIME-Version`) by the caller. */
776
802
  rawMime: string;
803
+ /** See `AssembleIntent`. */
804
+ intent?: AssembleIntent;
777
805
  }
778
806
  /**
779
807
  * Stores an already-signed/encrypted draft's raw MIME source as its `bodyBlobKey`, completely
@@ -274,7 +274,11 @@ export function moveMessage(message, folderUid, client) {
274
274
  export function reportMessage(uid, kind, options = {}, client) {
275
275
  return withClient(client, `/mail/messages/${encodeURIComponent(uid)}/report`, {
276
276
  method: "POST",
277
- body: JSON.stringify({ kind, ...(options.alwaysTrustSender ? { alwaysTrustSender: true } : {}) }),
277
+ body: JSON.stringify({
278
+ kind,
279
+ ...(options.alwaysTrustSender ? { alwaysTrustSender: true } : {}),
280
+ ...(options.rawMessage ? { rawMessage: options.rawMessage } : {}),
281
+ }),
278
282
  });
279
283
  }
280
284
  /**
@@ -28,6 +28,8 @@ export type PushStatus = "idle" | "connecting" | "open" | "reconnecting" | "clos
28
28
  * caller orders its channels most important first.
29
29
  */
30
30
  export declare const PUSH_MAX_CHANNELS = 40;
31
+ /** The group `setChannels()` sets when no other is named: Mail's. */
32
+ export declare const DEFAULT_CHANNEL_GROUP = "mail";
31
33
  /** The first reconnect waits about this long, and each further failure doubles it ... */
32
34
  export declare const PUSH_BACKOFF_BASE_MS = 1000;
33
35
  /** ... up to this. */
@@ -55,6 +57,8 @@ export declare class PushClient {
55
57
  private current;
56
58
  /** What the caller wants to hear about, most important first. */
57
59
  private desired;
60
+ /** The channels each caller asked for (`setChannels()`'s `group`), merged into `desired`. */
61
+ private groups;
58
62
  /** What this socket is subscribed to (including whatever the server put it on at connect). */
59
63
  private subscribed;
60
64
  /** The channels this client itself asked for and was granted - the only ones it may unsubscribe. */
@@ -73,10 +77,13 @@ export declare class PushClient {
73
77
  */
74
78
  close(): void;
75
79
  /**
76
- * Sets the channels (folder and mailbox uids) to receive events for. Deduplicated and cut to `PUSH_MAX_CHANNELS`; on
77
- * an open socket the difference is subscribed/unsubscribed at once, otherwise it is sent when the socket opens.
80
+ * Sets the channels (folder and mailbox uids) one caller wants events for: Mail's, in the default group, or a page's
81
+ * own (the Calendar's calendars - Mail subscribes to mail folders only). Every group's channels are wanted together,
82
+ * each page's own group ahead of the default one, since a page's are what is on screen; deduplicated and cut to
83
+ * `PUSH_MAX_CHANNELS`. An empty list drops the group. On an open socket the difference is subscribed/unsubscribed at
84
+ * once, otherwise it is sent when the socket opens.
78
85
  */
79
- setChannels(channels: readonly string[]): void;
86
+ setChannels(channels: readonly string[], group?: string): void;
80
87
  /** Calls `listener` with every event; returns the function that stops it. */
81
88
  onEvent(listener: (event: PushEvent) => void): () => void;
82
89
  /** Calls `listener` whenever `status` changes; returns the function that stops it. */
@@ -37,6 +37,8 @@ const SOCKET_OPEN = 1;
37
37
  * caller orders its channels most important first.
38
38
  */
39
39
  export const PUSH_MAX_CHANNELS = 40;
40
+ /** The group `setChannels()` sets when no other is named: Mail's. */
41
+ export const DEFAULT_CHANNEL_GROUP = "mail";
40
42
  /** The first reconnect waits about this long, and each further failure doubles it ... */
41
43
  export const PUSH_BACKOFF_BASE_MS = 1000;
42
44
  /** ... up to this. */
@@ -61,6 +63,8 @@ export class PushClient {
61
63
  this.current = "idle";
62
64
  /** What the caller wants to hear about, most important first. */
63
65
  this.desired = [];
66
+ /** The channels each caller asked for (`setChannels()`'s `group`), merged into `desired`. */
67
+ this.groups = new Map();
64
68
  /** What this socket is subscribed to (including whatever the server put it on at connect). */
65
69
  this.subscribed = new Set();
66
70
  /** The channels this client itself asked for and was granted - the only ones it may unsubscribe. */
@@ -107,11 +111,21 @@ export class PushClient {
107
111
  this.setStatus("closed");
108
112
  }
109
113
  /**
110
- * Sets the channels (folder and mailbox uids) to receive events for. Deduplicated and cut to `PUSH_MAX_CHANNELS`; on
111
- * an open socket the difference is subscribed/unsubscribed at once, otherwise it is sent when the socket opens.
114
+ * Sets the channels (folder and mailbox uids) one caller wants events for: Mail's, in the default group, or a page's
115
+ * own (the Calendar's calendars - Mail subscribes to mail folders only). Every group's channels are wanted together,
116
+ * each page's own group ahead of the default one, since a page's are what is on screen; deduplicated and cut to
117
+ * `PUSH_MAX_CHANNELS`. An empty list drops the group. On an open socket the difference is subscribed/unsubscribed at
118
+ * once, otherwise it is sent when the socket opens.
112
119
  */
113
- setChannels(channels) {
114
- this.desired = [...new Set(channels)].slice(0, PUSH_MAX_CHANNELS);
120
+ setChannels(channels, group = DEFAULT_CHANNEL_GROUP) {
121
+ if (channels.length > 0) {
122
+ this.groups.set(group, [...channels]);
123
+ }
124
+ else {
125
+ this.groups.delete(group);
126
+ }
127
+ const ordered = [...this.groups.entries()].sort(([a], [b]) => Number(a === DEFAULT_CHANNEL_GROUP) - Number(b === DEFAULT_CHANNEL_GROUP));
128
+ this.desired = [...new Set(ordered.flatMap(([, list]) => list))].slice(0, PUSH_MAX_CHANNELS);
115
129
  if (this.ready) {
116
130
  this.syncSubscriptions();
117
131
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Typed wrappers over `@rapidmx/restapi`'s zero-access search index (`/mail/mailboxes/:id/search-index`, `specs/search.md` §7-8): the
3
+ * sealed index entries the server writes at delivery and the weekly segments clients compact them into. Everything here is
4
+ * ciphertext encrypted to the mailbox's zero-access key - `apps/shared/search/zeroAccessIndexSync.ts` decrypts and imports it.
5
+ */
6
+ import { type ApiClient } from "../util/api.js";
7
+ export type SegmentOrigin = "zero-access" | "client";
8
+ /** A compacted segment as the manifest lists it. */
9
+ export interface IndexSegment {
10
+ weekId: string;
11
+ generation: number;
12
+ origin: SegmentOrigin;
13
+ bytes: number;
14
+ etag: string;
15
+ schemaVersion: number;
16
+ keyFingerprint: string;
17
+ entryCount: number;
18
+ }
19
+ /** A week's entries not yet compacted. */
20
+ export interface IndexWeekEntries {
21
+ weekId: string;
22
+ pendingEntries: number;
23
+ latestEntryAt: string;
24
+ /** The week has closed and its grace period has passed: a client may compact it. */
25
+ compactable: boolean;
26
+ }
27
+ export interface SegmentManifest {
28
+ mailboxUid: string;
29
+ segments: IndexSegment[];
30
+ weeks: IndexWeekEntries[];
31
+ }
32
+ /** One pending entry: `sealed` is base64 CMS `EnvelopedData` of a gzipped JSON `ZeroAccessIndexDocument`. */
33
+ export interface SealedIndexEntry {
34
+ uid: string;
35
+ weekId: string;
36
+ messageUid: string;
37
+ createdAt: string;
38
+ keyFingerprint: string;
39
+ sealed: string;
40
+ }
41
+ export interface SealedIndexEntryPage {
42
+ entries: SealedIndexEntry[];
43
+ next?: string;
44
+ }
45
+ /** What a sealed entry (and each document of a segment) decrypts to - restapi's `ZeroAccessIndexDocument`. */
46
+ export interface ZeroAccessIndexDocument {
47
+ v: 1;
48
+ messageUid: string;
49
+ subject: string;
50
+ body: string;
51
+ attachmentText: string[];
52
+ attachmentNames: string[];
53
+ deferredAttachments: string[];
54
+ from: string;
55
+ to: string[];
56
+ cc: string[];
57
+ participants: string[];
58
+ dateForSort: number;
59
+ }
60
+ /** `PUT .../segments/:weekId`. */
61
+ export interface SegmentUpload {
62
+ expectedGeneration: number;
63
+ /** Base64 of the sealed file. */
64
+ segment: string;
65
+ keyFingerprint: string;
66
+ schemaVersion: number;
67
+ origin?: SegmentOrigin;
68
+ foldedEntryUids?: string[];
69
+ }
70
+ export declare function getSearchIndexManifest(mailboxUid: string, client?: ApiClient): Promise<SegmentManifest>;
71
+ /** One page of pending entries: of `week` when given, written at or after `since` when given, after uid `after`. */
72
+ export declare function listSearchIndexEntries(mailboxUid: string, options?: {
73
+ week?: string;
74
+ since?: string;
75
+ after?: string;
76
+ limit?: number;
77
+ }, client?: ApiClient): Promise<SealedIndexEntryPage>;
78
+ /** The sealed file of a week's segment generation. */
79
+ export declare function getSearchIndexSegment(mailboxUid: string, segment: Pick<IndexSegment, "weekId" | "generation" | "origin">, client?: ApiClient): Promise<Uint8Array>;
80
+ /** Stores a compaction. Rejects with a `409` when the week's segment changed since `upload.expectedGeneration` was read. */
81
+ export declare function putSearchIndexSegment(mailboxUid: string, weekId: string, upload: SegmentUpload, client?: ApiClient): Promise<IndexSegment>;
@@ -0,0 +1,35 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Typed wrappers over `@rapidmx/restapi`'s zero-access search index (`/mail/mailboxes/:id/search-index`, `specs/search.md` §7-8): the
7
+ * sealed index entries the server writes at delivery and the weekly segments clients compact them into. Everything here is
8
+ * ciphertext encrypted to the mailbox's zero-access key - `apps/shared/search/zeroAccessIndexSync.ts` decrypts and imports it.
9
+ */
10
+ import { withClient, withClientBlob } from "../util/api.js";
11
+ const base = (mailboxUid) => `/mail/mailboxes/${encodeURIComponent(mailboxUid)}/search-index`;
12
+ export function getSearchIndexManifest(mailboxUid, client) {
13
+ return withClient(client, `${base(mailboxUid)}/manifest`);
14
+ }
15
+ /** One page of pending entries: of `week` when given, written at or after `since` when given, after uid `after`. */
16
+ export function listSearchIndexEntries(mailboxUid, options = {}, client) {
17
+ const query = new URLSearchParams();
18
+ for (const [key, value] of Object.entries(options)) {
19
+ if (value !== undefined) {
20
+ query.set(key, String(value));
21
+ }
22
+ }
23
+ const search = query.toString();
24
+ return withClient(client, `${base(mailboxUid)}/entries${search ? `?${search}` : ""}`);
25
+ }
26
+ /** The sealed file of a week's segment generation. */
27
+ export async function getSearchIndexSegment(mailboxUid, segment, client) {
28
+ const query = `generation=${segment.generation}${segment.origin === "client" ? "&origin=client" : ""}`;
29
+ const blob = await withClientBlob(client, `${base(mailboxUid)}/segments/${encodeURIComponent(segment.weekId)}?${query}`, "Could not download a search index segment.");
30
+ return new Uint8Array(await blob.arrayBuffer());
31
+ }
32
+ /** Stores a compaction. Rejects with a `409` when the week's segment changed since `upload.expectedGeneration` was read. */
33
+ export function putSearchIndexSegment(mailboxUid, weekId, upload, client) {
34
+ return withClient(client, `${base(mailboxUid)}/segments/${encodeURIComponent(weekId)}`, { method: "PUT", body: JSON.stringify(upload) });
35
+ }
@@ -30,6 +30,7 @@ export interface Task {
30
30
  * own doc comment in `util/api.ts`. Omitted (the default), every function here behaves exactly as before. */
31
31
  /** Lists a folder's tasks, soonest due date first (tasks with no due date sort last). */
32
32
  export declare function listTasks(folderUid: string, params?: ListParams, client?: ApiClient): Promise<Task[]>;
33
+ export declare function getTask(uid: string, client?: ApiClient): Promise<Task>;
33
34
  export interface CreateTaskInput {
34
35
  mailboxUid: string;
35
36
  folderUid: string;
@@ -13,6 +13,9 @@ import { SETTINGS_LIST_LIMIT, buildQuery } from "../util/apiQuery.js";
13
13
  export function listTasks(folderUid, params = {}, client) {
14
14
  return withClient(client, `/mail/tasks?${buildQuery(params, { folderUid, sort: JSON.stringify({ dueDate: "ASC", uid: "ASC" }) })}`);
15
15
  }
16
+ export function getTask(uid, client) {
17
+ return withClient(client, `/mail/tasks/${encodeURIComponent(uid)}`);
18
+ }
16
19
  export function createTask(input, client) {
17
20
  return withClient(client, "/mail/tasks", {
18
21
  method: "POST",
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { ApiClient, withClient } from "../util/api.js";
16
16
  import { ListParams, RequestListParams, buildQuery } from "../util/apiQuery.js";
17
+ import type { PublicKey, WrappedPrivateKey } from "../crypto/keyvaultApi.js";
17
18
 
18
19
  export type { ListParams, RequestListParams };
19
20
 
@@ -65,6 +66,11 @@ export interface MasterKeyWrap {
65
66
  * tooling, per `specs/end-to-end_encryption.md`'s "this server never holds the scope's private key". */
66
67
  export interface EscrowAccessMaterial {
67
68
  masterKeyWraps: MasterKeyWrap[];
69
+ /** The mailbox's wrapped private keys that read mail (zero-access and encryption keys, never signing), sealed under its master key. Absent
70
+ * from a server that predates releasing them. */
71
+ wrappedKeys?: WrappedPrivateKey[];
72
+ /** The certificate of each of `wrappedKeys`, matched by fingerprint. */
73
+ certificates?: PublicKey[];
68
74
  }
69
75
 
70
76
  /** `client`, given by every function below, is an explicit `ApiClient` from `createApiClient()` (e.g. one
@@ -135,6 +135,9 @@ export interface CalendarEvent {
135
135
  * `mailto` only). Still to be rendered through `sanitizeEventDescriptionHtml()` (`eventDescription.ts`), never trusted as it stands. `null` or absent:
136
136
  * the description, if any, is plain text. */
137
137
  descriptionHtml?: string | null;
138
+ /** Response-only. An event made from an invitation that reached a zero-access mailbox: its title, location and description are only
139
+ * sealed to the mailbox's zero-access key (`title` is `[...]`). `apps/shared/calendar/sealedEvents.ts` opens them. */
140
+ sealedContent?: { keyFingerprint: string; sealed: string };
138
141
  /** Who may see the event's details - absent (or `null`, on a row stored before this existed) reads as `"default"`; see `visibilityOf()`. */
139
142
  visibility?: EventVisibility | null;
140
143
  /** Whether the guests may ask for the event to change (default `false`). Absent or `null` reads as the default; see `guestPermissionsOf()`. */
@@ -10,6 +10,14 @@
10
10
 
11
11
  import { ApiClient, ApiRequestError, withClient } from "../util/api.js";
12
12
  import type { EventVisibility, GuestPermissions } from "./calendarApi.js";
13
+ import { sealedInviteCalendar } from "./sealedInvites.js";
14
+
15
+ /** The calendar file to send for `messageUid` - a zero-access message this device decrypted (`sealedInvites.ts`) - as the request body's
16
+ * `ics`, or nothing for a message the server reads itself. */
17
+ function sealedSource(messageUid: string): { ics?: string } {
18
+ const ics = sealedInviteCalendar(messageUid);
19
+ return ics !== undefined ? { ics } : {};
20
+ }
13
21
 
14
22
  /** What the reader can answer an invitation with. */
15
23
  export type InviteResponse = "accepted" | "tentative" | "declined";
@@ -104,8 +112,15 @@ export interface MessageInvite {
104
112
  * common, expected answer for a message that merely has an attachment - and rejects for any other failure.
105
113
  */
106
114
  export async function getMessageInvite(messageUid: string, client?: ApiClient): Promise<MessageInvite | null> {
115
+ const source = sealedSource(messageUid);
107
116
  try {
108
- return await withClient<MessageInvite>(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}`);
117
+ // A zero-access message's invitation is read from the calendar file decrypted here (`POST .../view`).
118
+ return source.ics !== undefined
119
+ ? await withClient<MessageInvite>(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/view`, {
120
+ method: "POST",
121
+ body: JSON.stringify(source),
122
+ })
123
+ : await withClient<MessageInvite>(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}`);
109
124
  } catch (err) {
110
125
  if (err instanceof ApiRequestError && err.status === 404) {
111
126
  return null;
@@ -122,13 +137,17 @@ export async function getMessageInvite(messageUid: string, client?: ApiClient):
122
137
  export function respondToMessageInvite(messageUid: string, responseStatus: InviteResponse, client?: ApiClient): Promise<MessageInvite> {
123
138
  return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/respond`, {
124
139
  method: "POST",
125
- body: JSON.stringify({ responseStatus }),
140
+ body: JSON.stringify({ responseStatus, ...sealedSource(messageUid) }),
126
141
  });
127
142
  }
128
143
 
129
144
  /** Takes a canceled meeting off the reader's calendar. Resolves with the invitation as it now stands. */
130
145
  export function removeMessageInvite(messageUid: string, client?: ApiClient): Promise<MessageInvite> {
131
- return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/remove`, { method: "POST" });
146
+ const source = sealedSource(messageUid);
147
+ return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/remove`, {
148
+ method: "POST",
149
+ ...(source.ics !== undefined ? { body: JSON.stringify(source) } : {}),
150
+ });
132
151
  }
133
152
 
134
153
  /** A different time to suggest to the organizer. */
@@ -144,11 +163,15 @@ export interface ProposedTime {
144
163
  export function proposeNewTime(messageUid: string, proposal: ProposedTime, client?: ApiClient): Promise<MessageInvite> {
145
164
  return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/propose`, {
146
165
  method: "POST",
147
- body: JSON.stringify(proposal),
166
+ body: JSON.stringify({ ...proposal, ...sealedSource(messageUid) }),
148
167
  });
149
168
  }
150
169
 
151
170
  /** 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
171
  export function acceptProposal(messageUid: string, client?: ApiClient): Promise<MessageInvite> {
153
- return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/accept-proposal`, { method: "POST" });
172
+ const source = sealedSource(messageUid);
173
+ return withClient(client, `/mail/calendar-events/invite/${encodeURIComponent(messageUid)}/accept-proposal`, {
174
+ method: "POST",
175
+ ...(source.ics !== undefined ? { body: JSON.stringify(source) } : {}),
176
+ });
154
177
  }
@@ -0,0 +1,33 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * The calendar files of zero-access messages, as decrypted on this device (`MessageSecurityResult.calendar`). The server holds only
7
+ * the sealed copy of such a message, so every invitation call for it (`inviteApi.ts`) sends the file along. Kept in memory for the
8
+ * messages read most recently, and cleared on sign-out (`clearSealedInvites()`).
9
+ */
10
+
11
+ /** How many messages' calendar files are kept. */
12
+ const MAX_SEALED_INVITES = 100;
13
+
14
+ const calendars: Map<string, string> = new Map();
15
+
16
+ /** Remembers the decrypted calendar file of zero-access message `messageUid`. */
17
+ export function rememberSealedInvite(messageUid: string, ics: string): void {
18
+ calendars.delete(messageUid);
19
+ calendars.set(messageUid, ics);
20
+ while (calendars.size > MAX_SEALED_INVITES) {
21
+ calendars.delete(calendars.keys().next().value!);
22
+ }
23
+ }
24
+
25
+ /** The decrypted calendar file of zero-access message `messageUid`, when this device has read it. */
26
+ export function sealedInviteCalendar(messageUid: string): string | undefined {
27
+ return calendars.get(messageUid);
28
+ }
29
+
30
+ /** Forgets every remembered calendar file (sign-out). */
31
+ export function clearSealedInvites(): void {
32
+ calendars.clear();
33
+ }