@rapidmx/web-client 0.36.0 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -3,7 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import { ApiRequestError } from "../../../../lib/util/api.js";
6
- import { assembleDraft, assembleDraftRaw, forgetDraftImages, getMailbox, getMessage, queueMessageSend, sendMessage, setMessageRequestReceipt, } from "../../../../lib/mail/mailApi.js";
6
+ import { assembleDraft, assembleDraftRaw, createDraft, deleteMessage, forgetDraftImages, getMailbox, getMessage, queueMessageSend, sendMessage, setMessageRequestReceipt, } from "../../../../lib/mail/mailApi.js";
7
7
  import { describeSendFailure } from "../../../../lib/mail/sendFailure.js";
8
8
  import { resolveRecipientEncryption } from "../../../../lib/crypto/composeSecurity.js";
9
9
  import { getUnlockedKeys } from "../../../../lib/crypto/keySession.js";
@@ -16,6 +16,7 @@ import { beginPendingSend, finishPendingSend, setPendingStage } from "./pendingS
16
16
  import { failOutgoing, forgetOutgoing, markOutgoingSending, markOutgoingSent, trackOutgoing } from "./outgoingReplies.js";
17
17
  import { openComposeFromOutside, requestUnlockFromOutside } from "./composeBridge.js";
18
18
  import { sendState } from "./sendState.js";
19
+ import { notifySendFailure } from "./sendOutcomes.js";
19
20
  /** How long a background send waits for the mailbox / encryption policy it did not have, before it goes ahead treating them as unknown. Nobody waits for it. */
20
21
  export const CONTEXT_GRACE_MS = 4000;
21
22
  export function registerOutboxCounter(tracker, onQueued) {
@@ -29,19 +30,69 @@ export function registerOutboxCounter(tracker, onQueued) {
29
30
  };
30
31
  }
31
32
  const RETAINED_LIMIT = 25;
32
- /** The request a message was queued from, while this tab still holds it: what lets "Open draft" and "Retry" work on a failure the server reports later. */
33
+ /** The request a message was queued from, while this tab still holds it: what lets "Open draft" and "Retry" work on a failure the server
34
+ * reports later. A message sent as several copies (`SendRequest.copyUids`) is held under each copy still to go, from just before it is handed
35
+ * over - how the first went can be reported while the next is still being handed over. */
33
36
  export function retainedRequest(draftUid) {
34
37
  return sendState.retained.get(draftUid);
35
38
  }
36
- /** Drops the request a message was queued from, and the pictures remembered as attached to its draft: it has gone, so neither is wanted again. */
39
+ /** Stops holding the request of a message sent as several copies under copy `uid` - never another message's request held there since. */
40
+ function release(fates, uid) {
41
+ if (sendState.retained.get(uid)?.copyFates === fates) {
42
+ sendState.retained.delete(uid);
43
+ }
44
+ }
45
+ /** Records that copy `uid` of a message sent as several (`fates`) has gone. Whether it was the last of them to go, each counted once, so the
46
+ * message is confirmed now - always, for a message sent as one. */
47
+ function copyWent(fates, uid) {
48
+ if (!fates) {
49
+ return true;
50
+ }
51
+ release(fates, uid);
52
+ if (fates.get(uid) === "sent") {
53
+ return false;
54
+ }
55
+ fates.set(uid, "sent");
56
+ return [...fates.values()].every((fate) => fate === "sent");
57
+ }
58
+ /** Drops the request a message (or one of its copies) was queued from, and the pictures remembered as attached to its draft: it has gone, so
59
+ * neither is wanted again. Whether the message has now gone in full - the last of its copies to go. */
37
60
  export function forgetRetainedRequest(draftUid) {
61
+ const request = sendState.retained.get(draftUid);
38
62
  sendState.retained.delete(draftUid);
39
63
  forgetDraftImages(draftUid);
64
+ return copyWent(request?.copyFates, draftUid);
65
+ }
66
+ /** Records that the server couldn't relay copy `uid` of the message `request` sent as several copies (nothing for a message sent as one). */
67
+ export function copyFailed(request, uid) {
68
+ request?.copyFates?.set(uid, "failed");
69
+ }
70
+ /** Copy `uid` of the message `request` sent as several copies is handed over again (a Retry): how it goes is to be reported once more, and
71
+ * finds the request held under it. Nothing for a message sent as one. */
72
+ export function copyRetried(request, uid) {
73
+ if (request?.copyFates) {
74
+ request.copyFates.set(uid, "pending");
75
+ retain(request, [uid]);
76
+ }
40
77
  }
41
- function retain(request) {
42
- sendState.retained.delete(request.draft.uid);
43
- sendState.retained.set(request.draft.uid, request);
44
- if (sendState.retained.size > RETAINED_LIMIT) {
78
+ /** Every copy of the message `request` sent as several, when none of them has gone nor may still go - each failed, or was never handed over:
79
+ * nobody has the message. `undefined` while one has gone or may, and for a message sent as one. */
80
+ export function unsentCopies(request) {
81
+ const fates = request?.copyFates;
82
+ return fates && [...fates.values()].every((fate) => fate === "failed" || fate === "unsent") ? [...fates.keys()] : undefined;
83
+ }
84
+ /** Stops holding a message sent as several copies under any of them: it is being written again. */
85
+ export function forgetCopies(fates) {
86
+ for (const uid of fates.keys()) {
87
+ release(fates, uid);
88
+ }
89
+ }
90
+ function retain(request, uids) {
91
+ for (const uid of uids) {
92
+ sendState.retained.delete(uid);
93
+ sendState.retained.set(uid, request);
94
+ }
95
+ while (sendState.retained.size > RETAINED_LIMIT) {
45
96
  sendState.retained.delete(sendState.retained.keys().next().value);
46
97
  }
47
98
  }
@@ -56,6 +107,11 @@ export function readSendKeys(mailboxUid, signEnabled, mailbox) {
56
107
  canEncryptSelf: !!unlocked?.encryptionPrivateKey && !!unlocked.encryptionCertDer,
57
108
  };
58
109
  }
110
+ /** Whether the sender can make an OpenPGP copy (it is encrypted to them too, with their own OpenPGP key) - or can't be told yet, the
111
+ * mailbox being locked: `decideSend()` then asks for the unlock rather than settling for another format, or none. */
112
+ export function senderCanUseOpenPgp(unlocked) {
113
+ return !unlocked || !!unlocked.openPgpKeys?.length;
114
+ }
59
115
  /** Whether this sender could encrypt at all: an encryption key unlocked here, enrolled on the mailbox, or offered earlier this session. */
60
116
  export function senderHasEncryptionKey(request, canEncryptSelf) {
61
117
  return canEncryptSelf || request.offeredEncrypt || !!findActivePublicKey(request.mailbox?.keys ?? [], "encrypt");
@@ -102,11 +158,15 @@ async function completeContext(request, client) {
102
158
  }
103
159
  return { mailbox, policy };
104
160
  }
105
- /** Builds the message's final content into the draft (plain, signed, or signed and encrypted in this browser) and returns the stored message. */
106
- async function assemble(request, mailbox, mode, statuses, withSignature, client) {
161
+ /** Builds the message's final content into the draft (plain, signed, or signed and encrypted in this browser) and returns the stored
162
+ * message(s) to send: one, or - for an encrypted message whose recipients need both formats (restapi `specs/end-to-end_encryption.md`
163
+ * *OpenPGP Interoperability*) - an S/MIME copy (this draft) and an OpenPGP copy (a second draft), each listing every recipient but
164
+ * delivered only to the recipients of its own format (`deliverTo`). Never a plaintext copy. The second draft is added to `created` as soon as
165
+ * it exists, so an attempt that fails before handing it over can remove it. */
166
+ async function assemble(request, mailbox, mode, statuses, withSignature, client, created) {
107
167
  const { draft, to, cc, bcc, subject, html } = request;
108
168
  if (mode === "plain") {
109
- return assembleDraft(draft.uid, { to, cc, bcc, subject, html, intent: "send" }, client);
169
+ return [await assembleDraft(draft.uid, { to, cc, bcc, subject, html, intent: "send" }, client)];
110
170
  }
111
171
  const keys = readSendKeys(request.mailboxUid, request.signEnabled, mailbox);
112
172
  const own = mailbox;
@@ -127,31 +187,185 @@ async function assemble(request, mailbox, mode, statuses, withSignature, client)
127
187
  const { applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } = await import("../../../../lib/crypto/smimeMessage.js");
128
188
  const encrypt = mode === "encrypt";
129
189
  const outerHeaders = encrypt ? applyBaselineOuterHeaders(protectedHeaders) : protectedHeaders;
130
- const mimePart = encrypt
131
- ? await buildEncryptedMessage(bodyContentType, html, protectedHeaders, outerHeaders, [keys.unlocked.encryptionCertDer, ...statuses.map((status) => fromBase64(status.encryptCert.publicKey))], signing)
132
- : await buildSignedOnlyMessage(bodyContentType, html, protectedHeaders, signing.certDer, signing.privateKey);
133
- const rawMime = assembleOutboundMime(outerHeaders, mimePart);
134
- return assembleDraftRaw(draft.uid, { to, cc, bcc, subject: outerHeaders.subject, rawMime, intent: "send" }, client);
190
+ if (!encrypt) {
191
+ const signedPart = await buildSignedOnlyMessage(bodyContentType, html, protectedHeaders, signing.certDer, signing.privateKey);
192
+ return [await assembleDraftRaw(draft.uid, { to, cc, bcc, subject: outerHeaders.subject, rawMime: assembleOutboundMime(outerHeaders, signedPart), intent: "send" }, client)];
193
+ }
194
+ const smime = statuses.filter((status) => status.format !== "openpgp");
195
+ const pgp = statuses.filter((status) => status.format === "openpgp");
196
+ const deliverTo = (group) => (smime.length > 0 && pgp.length > 0 ? { deliverTo: group.map((status) => status.address) } : {});
197
+ const sent = [];
198
+ if (smime.length > 0) {
199
+ const part = await buildEncryptedMessage(bodyContentType, html, protectedHeaders, outerHeaders, [keys.unlocked.encryptionCertDer, ...smime.map((status) => fromBase64(status.encryptCert.publicKey))], signing);
200
+ const rawMime = assembleOutboundMime(outerHeaders, part);
201
+ sent.push(await assembleDraftRaw(draft.uid, { to, cc, bcc, subject: outerHeaders.subject, rawMime, intent: "send", ...deliverTo(smime) }, client));
202
+ }
203
+ if (pgp.length > 0) {
204
+ // OpenPGP.js and the PGP/MIME builder load only for a message that has OpenPGP recipients.
205
+ const [{ buildPgpEncryptedMessage, readKeysBase64 }, { armorPublicKey }] = await Promise.all([
206
+ import("../../../../lib/crypto/pgpMessage.js"),
207
+ import("../../../../lib/crypto/openpgp.js"),
208
+ ]);
209
+ const ownKey = keys.unlocked.openPgpKeys[0].privateKey;
210
+ const recipientKeys = await readKeysBase64(pgp.map((status) => status.openPgpKey.publicKey));
211
+ // Its own Message-ID: a second message, even though it is the same one for its readers.
212
+ const pgpHeaders = { ...protectedHeaders, messageId: `<${crypto.randomUUID()}@${domain}>` };
213
+ const pgpOuter = applyBaselineOuterHeaders(pgpHeaders);
214
+ // Signed with the sender's OpenPGP key (what an OpenPGP reader can check) whenever the message is signed, and carrying the
215
+ // sender's public key, so a correspondent without it can verify and reply encrypted.
216
+ const part = await buildPgpEncryptedMessage(bodyContentType, html, pgpHeaders, pgpOuter, [ownKey.toPublic(), ...recipientKeys], withSignature ? ownKey : undefined, armorPublicKey(ownKey.toPublic()));
217
+ const rawMime = assembleOutboundMime(pgpOuter, part);
218
+ let target = draft;
219
+ if (smime.length > 0) {
220
+ target = await createDraft(request.mailboxUid, draft.folderUid, undefined, client);
221
+ created.push(target);
222
+ }
223
+ sent.push(await assembleDraftRaw(target.uid, { to, cc, bcc, subject: pgpOuter.subject, rawMime, intent: "send", ...deliverTo(pgp) }, client));
224
+ }
225
+ return sent;
135
226
  }
136
227
  function failureOf(err, fallback) {
137
228
  const failure = describeSendFailure(err, fallback);
138
229
  return { message: failure.message, details: failure.lines };
139
230
  }
140
- /** Whether the server already holds `request`'s message as accepted (out of Drafts, or leased) - the answer to "did that request that never returned actually land?". */
141
- async function alreadyAccepted(request, client) {
231
+ /** Whether `fresh`, `copy` as the server holds it now, was accepted (out of Drafts, or leased). */
232
+ function isAccepted(copy, fresh) {
233
+ return fresh.folderUid !== copy.folderUid || !!fresh.scheduledSendLeaseExpiresAt || !!fresh.scheduledSendRelayedAt;
234
+ }
235
+ /** Whether the server already holds `copy` as accepted - the answer to "did that request that never returned actually land?". */
236
+ async function accepted(copy, client) {
142
237
  try {
143
- const fresh = await getMessage(request.draft.uid, client);
144
- return fresh.folderUid !== request.draft.folderUid || !!fresh.scheduledSendLeaseExpiresAt || !!fresh.scheduledSendRelayedAt;
238
+ return isAccepted(copy, await getMessage(copy.uid, client));
145
239
  }
146
240
  catch {
147
241
  return false;
148
242
  }
149
243
  }
244
+ /** Deletes the draft of a copy of a message sent as several (the OpenPGP one's) that is not to be handed over any more - unless the server
245
+ * holds it as accepted after all. Best effort: never rejects. */
246
+ async function discardCopy(copy, client) {
247
+ try {
248
+ const fresh = await getMessage(copy.uid, client);
249
+ if (!isAccepted(copy, fresh)) {
250
+ await deleteMessage(fresh.uid, fresh.version, client);
251
+ }
252
+ }
253
+ catch {
254
+ // Left in Drafts, where it can be deleted by hand.
255
+ }
256
+ }
257
+ /** Whether the server already holds every copy of `request`'s message still to go (its draft, when none was built yet) as accepted. */
258
+ async function alreadyAccepted(request, client) {
259
+ for (const copy of request.pendingCopies ?? [request.draft]) {
260
+ if (!(await accepted(copy, client))) {
261
+ return false;
262
+ }
263
+ }
264
+ return true;
265
+ }
266
+ /** Hands `copies` over to the server - queued, or scheduled - one after the other. One that fails stops the rest: the failure carries the
267
+ * copies still to go, so a retry never sends one twice (an encrypted message's other copy may already be on its way to its recipients). */
268
+ async function handOver(request, copies, client) {
269
+ // A retry hands over only what was left: the request already names every copy.
270
+ const copyUids = request.copyUids ?? (copies.length > 1 ? copies.map((copy) => copy.uid) : undefined);
271
+ const copyFates = request.copyFates ?? (copyUids && new Map(copyUids.map((uid) => [uid, "unsent"])));
272
+ // An immediate send of several copies is held under each before any is handed over: how the first went can be reported while the next
273
+ // is still on its way to the server, and must find the others - a failure that offered "Open draft" would send their recipients the
274
+ // message again, a success not counted would leave the message never confirmed.
275
+ if (copyFates && !request.scheduledSendTime) {
276
+ for (const copy of copies) {
277
+ copyFates.set(copy.uid, "pending");
278
+ }
279
+ retain({ ...request, copyUids, copyFates }, copies.map((copy) => copy.uid));
280
+ }
281
+ let queued = false;
282
+ let completed = false;
283
+ for (let index = 0; index < copies.length; index++) {
284
+ const copy = copies[index];
285
+ try {
286
+ if (request.scheduledSendTime) {
287
+ await sendMessage(copy.uid, { scheduledSendTime: request.scheduledSendTime }, client);
288
+ }
289
+ else {
290
+ const handed = await queueMessageSend(copy.uid, client);
291
+ queued = handed.queued || queued;
292
+ // A server with no queue relayed it before answering: one of several copies has gone already.
293
+ if (!handed.queued && copyFates) {
294
+ completed = copyWent(copyFates, copy.uid) || completed;
295
+ }
296
+ }
297
+ }
298
+ catch (err) {
299
+ // A request that never came back may still have landed: never offer to send it twice if it did.
300
+ if (!(err instanceof ApiRequestError) && (await accepted(copy, client))) {
301
+ queued = true;
302
+ continue;
303
+ }
304
+ const failure = failureOf(err, request.scheduledSendTime ? "Could not schedule this message." : "Could not send this message.");
305
+ // This copy and the ones after it weren't handed over: nothing of them is to be reported.
306
+ if (copyFates) {
307
+ for (const left of copies.slice(index)) {
308
+ copyFates.set(left.uid, "unsent");
309
+ release(copyFates, left.uid);
310
+ }
311
+ }
312
+ // The server has failed a copy handed over earlier (its report came while this one was on its way), and none is sent or on its
313
+ // way: nobody has the message - the whole message's failure, not some recipients'.
314
+ const fates = [...(copyFates?.values() ?? [])];
315
+ const nobodyHasIt = fates.includes("failed") && fates.every((fate) => fate === "failed" || fate === "unsent");
316
+ // A copy that went in this attempt, or in an earlier one this retries.
317
+ const partial = !nobodyHasIt && (index > 0 || !!request.partial);
318
+ return {
319
+ kind: "failed",
320
+ failure: partial
321
+ ? { ...failure, partial, message: `Only some of its recipients ${request.scheduledSendTime ? "will get it" : "were sent it"} - the copy for the others didn't go. ${failure.message}` }
322
+ : failure,
323
+ // Nothing went of a single copy: a retry decides and assembles again from the request, like the first attempt (the stored
324
+ // draft may have been opened and autosaved plain since). Several copies are kept even when none went, so a mixed-format
325
+ // retry doesn't create another OpenPGP draft.
326
+ pendingCopies: partial || copies.length > 1 ? copies.slice(index) : undefined,
327
+ copyUids,
328
+ copyFates,
329
+ };
330
+ }
331
+ }
332
+ if (request.scheduledSendTime) {
333
+ return { kind: "scheduled", copyUids, copyFates };
334
+ }
335
+ // Several copies: the message has gone only when this hand-over saw the last of them go - otherwise the server's report for the last copy
336
+ // to go confirms it.
337
+ return { kind: (copyFates ? completed : !queued) ? "sent" : "queued", copyUids, copyFates };
338
+ }
150
339
  /** The whole client-side path of one send: settle, decide, assemble, hand over. Never rejects. `client` is the explicit `ApiClient` (from
151
340
  * `useApiClient()`) the window that built `request` was rendered under, `undefined` for the default global cookie-based session. */
152
341
  async function attempt(request, client) {
342
+ // The copies' drafts `assemble()` created (the OpenPGP one's): an attempt that fails before handing them over deletes them - its Retry
343
+ // assembles the message again, creating its own, and one left behind would reach its recipients a second time if sent from Drafts.
344
+ const created = [];
153
345
  try {
154
346
  await request.saved?.catch(() => undefined);
347
+ // Built already by the attempt that failed part-way: only what didn't go is handed over. A copy whose request seemed to fail but
348
+ // landed (its answer was lost, and so was the check after it) has gone since: handed over again it is refused, and the copies after
349
+ // it would never go - it is left out, and what fails now is a failure of only some of the recipients.
350
+ if (request.pendingCopies?.length) {
351
+ setPendingStage(request.draft.uid, "queuing");
352
+ const remaining = [];
353
+ let completed = false;
354
+ for (const copy of request.pendingCopies) {
355
+ if (!(await accepted(copy, client))) {
356
+ remaining.push(copy);
357
+ }
358
+ else {
359
+ // How it went has most likely been reported already, to nothing waiting for it: it isn't waited for now.
360
+ completed = copyWent(request.copyFates, copy.uid) || completed;
361
+ }
362
+ }
363
+ if (remaining.length === 0) {
364
+ const { copyUids, copyFates } = request;
365
+ return { kind: request.scheduledSendTime ? "scheduled" : completed ? "sent" : "queued", copyUids, copyFates };
366
+ }
367
+ return await handOver(remaining.length < request.pendingCopies.length ? { ...request, partial: true } : request, remaining, client);
368
+ }
155
369
  const all = [...request.to, ...request.cc, ...request.bcc];
156
370
  let mailbox = request.mailbox;
157
371
  let policy = request.policy;
@@ -166,9 +380,16 @@ async function attempt(request, client) {
166
380
  const own = mailbox;
167
381
  const ownPrefersMutual = own.encryptPreference?.preferEncrypt === "mutual";
168
382
  const lookups = await Promise.all(all.map((recipient) => lookupKeys(request.mailboxUid, recipient.address, client).then((lookup) => ({ lookup }), () => null)));
383
+ const ownOpenPgp = senderCanUseOpenPgp(keys.unlocked);
169
384
  statuses = all.map((recipient, index) => {
170
385
  const found = lookups[index];
171
- return found ? resolveRecipientEncryption(own.primarySmtpAddress, ownPrefersMutual, policy ?? OPEN_POLICY, recipient.address, found.lookup) : undefined;
386
+ // Without an OpenPGP key of the sender's, a recipient with both formats gets S/MIME, whatever they prefer.
387
+ const status = found
388
+ ? resolveRecipientEncryption(own.primarySmtpAddress, ownPrefersMutual, policy ?? OPEN_POLICY, recipient.address, found.lookup, ownOpenPgp)
389
+ : undefined;
390
+ // An OpenPGP copy is encrypted to the sender too, with their own OpenPGP key: a recipient with only an OpenPGP key can't
391
+ // be encrypted to while the unlocked session has none.
392
+ return status?.format === "openpgp" && !ownOpenPgp ? { ...status, canEncrypt: false, autoEncrypt: false } : status;
172
393
  });
173
394
  }
174
395
  lookupsComplete = true;
@@ -180,26 +401,17 @@ async function attempt(request, client) {
180
401
  if (decision.action === "rejected") {
181
402
  return { kind: "failed", failure: { message: decision.message, details: [] } };
182
403
  }
183
- const assembled = await assemble(request, mailbox, decision.action, statuses, decision.action === "sign" || (decision.action === "encrypt" && decision.sign), client);
184
- const withReceipt = request.requestReceipt ? await setMessageRequestReceipt(assembled, true, client) : assembled;
404
+ const assembled = await assemble(request, mailbox, decision.action, statuses, decision.action === "sign" || (decision.action === "encrypt" && decision.sign), client, created);
405
+ const copies = request.requestReceipt ? await Promise.all(assembled.map((copy) => setMessageRequestReceipt(copy, true, client))) : assembled;
185
406
  setPendingStage(request.draft.uid, "queuing");
186
- try {
187
- if (request.scheduledSendTime) {
188
- await sendMessage(withReceipt.uid, { scheduledSendTime: request.scheduledSendTime }, client);
189
- return { kind: "scheduled" };
190
- }
191
- const result = await queueMessageSend(withReceipt.uid, client);
192
- return { kind: result.queued ? "queued" : "sent" };
193
- }
194
- catch (err) {
195
- // A request that never came back may still have landed: never offer to send it twice if it did.
196
- if (!(err instanceof ApiRequestError) && (await alreadyAccepted(request, client))) {
197
- return { kind: request.scheduledSendTime ? "scheduled" : "queued" };
198
- }
199
- throw err;
200
- }
407
+ // Handed over from here: a copy the hand-over doesn't deliver is its failure's to retry.
408
+ created.length = 0;
409
+ return await handOver(request, copies, client);
201
410
  }
202
411
  catch (err) {
412
+ for (const copy of created) {
413
+ await discardCopy(copy, client);
414
+ }
203
415
  return { kind: "failed", failure: failureOf(err, request.scheduledSendTime ? "Could not schedule this message." : "Could not send this message.") };
204
416
  }
205
417
  }
@@ -229,18 +441,42 @@ export function openDraftFromRequest(request) {
229
441
  encryptRequested: request.encryptRequested,
230
442
  },
231
443
  });
232
- // Being edited again, it is no longer a message on its way in the conversation.
444
+ // Being edited again, it is no longer a message on its way in the conversation - and the failure's Retry, wherever it was clicked
445
+ // from, must not hand over the draft the window is now editing (and autosaving).
233
446
  if (opened) {
234
447
  forgetOutgoing(request.draft.uid);
448
+ dismiss(failureNotificationId(request.draft.uid));
235
449
  }
236
450
  return opened;
237
451
  }
238
452
  function failureNotificationId(draftUid) {
239
453
  return `send-failed:${draftUid}`;
240
454
  }
455
+ /** "Open draft" for a message sent as several copies none of which went, each still a draft (`unsentCopies()`): the message is written again in
456
+ * its own draft, and the other copies' drafts (the OpenPGP one's) are deleted, best effort - it is encrypted afresh when it is sent again,
457
+ * and a copy left in Drafts would reach its recipients a second time if ever sent from there. */
458
+ function openDraftDroppingCopies(request, client) {
459
+ if (!openDraftFromRequest(request)) {
460
+ return;
461
+ }
462
+ forgetCopies(request.copyFates);
463
+ for (const copy of request.pendingCopies.filter((copy) => copy.uid !== request.draft.uid)) {
464
+ forgetOutgoing(copy.uid);
465
+ void discardCopy(copy, client);
466
+ }
467
+ }
241
468
  /** The sticky "This message wasn't sent" pop-up for a send that failed on this side, with what can be done about it. */
242
469
  function notifyFailure(request, failure, client) {
243
470
  const uid = request.draft.uid;
471
+ // A message sent as several copies, none of which went: each was never handed over, or the server failed it (its report came while the
472
+ // next was being handed over). Then it is the whole message's failure as the server reports one: one pop-up, replacing the one its report
473
+ // showed, whose Retry hands every copy over again - the failed one from Outbox too - and whose "Open draft" writes the message again.
474
+ const copies = unsentCopies(request);
475
+ if (copies && [...request.copyFates.values()].includes("failed")) {
476
+ const recipients = [...request.to, ...request.cc, ...request.bcc].map((recipient) => recipient.address);
477
+ notifySendFailure({ uid, mailboxUid: request.mailboxUid, subject: request.subject, recipients }, { message: failure.message, lines: failure.details }, request, client);
478
+ return;
479
+ }
244
480
  const actions = [];
245
481
  const { block } = failure;
246
482
  if (block?.keysLocked && request.mailbox?.keys?.length) {
@@ -257,7 +493,10 @@ function notifyFailure(request, failure, client) {
257
493
  else {
258
494
  actions.push({ label: "Retry", onClick: () => void retrySend(request, client) });
259
495
  }
260
- actions.push({ label: "Open draft", onClick: () => void openDraftFromRequest(request) });
496
+ // Part of it went: editing and sending the draft again would send those recipients a second copy.
497
+ if (!failure.partial) {
498
+ actions.push({ label: "Open draft", onClick: () => void (copies ? openDraftDroppingCopies(request, client) : openDraftFromRequest(request)) });
499
+ }
261
500
  notify({
262
501
  id: failureNotificationId(uid),
263
502
  kind: "error",
@@ -301,16 +540,22 @@ export function startSend(request, client) {
301
540
  finishPendingSend(uid);
302
541
  if (outcome.kind === "failed") {
303
542
  tracker?.revert();
304
- notifyFailure(request, outcome.failure, client);
543
+ notifyFailure(outcome.pendingCopies
544
+ ? { ...request, pendingCopies: outcome.pendingCopies, partial: outcome.failure.partial, copyUids: outcome.copyUids, copyFates: outcome.copyFates }
545
+ : request, outcome.failure, client);
305
546
  return;
306
547
  }
307
548
  tracker?.settle();
308
549
  if (outcome.kind === "queued") {
309
- retain(request);
550
+ // A message sent as several copies is held under each already, from before it was handed over.
551
+ if (!outcome.copyFates) {
552
+ retain(request, [uid]);
553
+ }
310
554
  sendState.queuedListener?.(request.mailboxUid);
311
555
  }
312
556
  else if (outcome.kind === "sent") {
313
- // A server with no queue relayed it before answering: the Sent Items copy exists already.
557
+ // A server with no queue relayed it before answering (or, of several copies, the last of them went in this attempt): the Sent
558
+ // Items copy exists already.
314
559
  markOutgoingSent(uid);
315
560
  forgetDraftImages(uid);
316
561
  notifySent();
@@ -1,6 +1,18 @@
1
1
  import { SendEvent } from "../../../../lib/mail/sendEvents.js";
2
2
  import type { ApiClient } from "../../../../lib/util/api.js";
3
+ import { SendRequest } from "./sendJob.js";
4
+ /** The message a failure is about: as the server reports it (a `SendEvent`), or as this tab sent it. */
5
+ export type FailedSend = Pick<SendEvent, "uid" | "mailboxUid" | "subject" | "recipients">;
3
6
  /** Shows what a send event means to the user. `client` is the explicit `ApiClient` (from `useApiClient()`) whichever code observed this push
4
7
  * event was rendered under - `undefined` (the default) for every existing caller, which keeps using the default global cookie-based session
5
8
  * exactly as before. */
6
9
  export declare function handleSendEvent(event: SendEvent, client?: ApiClient): void;
10
+ /**
11
+ * The sticky "This message wasn't sent" for a message that failed, `failure` saying why, with what can be done about it - for one the server
12
+ * failed to relay, and for a message sent as several copies (`SendRequest.copyUids`) of which this tab couldn't hand over the rest after the
13
+ * server had failed one (`sendJob.ts`'s `notifyFailure()`). `request`: the one it was queued from, when this tab holds it.
14
+ */
15
+ export declare function notifySendFailure(event: FailedSend, failure: {
16
+ message: string;
17
+ lines: string[];
18
+ }, request: SendRequest | undefined, client: ApiClient | undefined): void;