@rapidmx/web-client 0.36.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/apps/shared/calendar/calendarReminders.ts +35 -0
  2. package/apps/shared/calendar/sealedEvents.ts +42 -9
  3. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  5. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +29 -1
  6. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  7. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  8. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  9. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  10. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  11. package/apps/shared/components/calendar/eventForm.ts +2 -0
  12. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  13. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  14. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  15. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  16. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  17. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  18. package/apps/shared/components/layout/AppShell.tsx +34 -5
  19. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +11 -4
  20. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
  21. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  22. package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  24. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  25. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  26. package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
  27. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  28. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  29. package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
  30. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  31. package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
  32. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  34. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  35. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  36. package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
  37. package/apps/shared/search/localIndexBuilder.ts +35 -24
  38. package/apps/shared/search/localIndexRpcClient.ts +91 -11
  39. package/apps/shared/search/localIndexSchema.ts +18 -2
  40. package/apps/shared/search/localIndexTransport.ts +6 -3
  41. package/apps/shared/search/localIndexWorker.ts +35 -18
  42. package/apps/shared/search/searchTier2.ts +5 -0
  43. package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
  44. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  45. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  46. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  47. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  48. package/apps/www/_shell.tsx +2 -0
  49. package/apps/www/calendar/index.tsx +49 -5
  50. package/apps/www/contacts/index.tsx +67 -11
  51. package/apps/www/index.tsx +10 -1
  52. package/apps/www/settings/encryption/index.tsx +16 -1
  53. package/apps/www/settings/privacy/index.tsx +87 -38
  54. package/apps/www/tasks/index.tsx +24 -6
  55. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  56. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  57. package/dist/apps/shared/calendar/sealedEvents.d.ts +3 -2
  58. package/dist/apps/shared/calendar/sealedEvents.js +40 -9
  59. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  60. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  61. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  62. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +9 -2
  63. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  64. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  65. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  66. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  67. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  68. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  69. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  70. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  71. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  72. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  73. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  74. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  75. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  76. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  77. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  78. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  79. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  80. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  81. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  82. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  83. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -4
  84. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
  85. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
  86. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  87. package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
  88. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  89. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  90. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  91. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  92. package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -1
  93. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  94. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  95. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  96. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  97. package/dist/apps/shared/components/settings/KeyExportSetting.js +3 -1
  98. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  99. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  100. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +2 -0
  101. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
  102. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  103. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  104. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  105. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  106. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  107. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  108. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  109. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  110. package/dist/apps/shared/search/LocalIndexLifecycle.js +55 -33
  111. package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
  112. package/dist/apps/shared/search/localIndexBuilder.js +31 -20
  113. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  114. package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
  115. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  116. package/dist/apps/shared/search/localIndexSchema.js +16 -2
  117. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
  118. package/dist/apps/shared/search/localIndexWorker.d.ts +9 -0
  119. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  120. package/dist/apps/shared/search/searchTier2.js +5 -0
  121. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +37 -6
  122. package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
  123. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  124. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  125. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  126. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  127. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  128. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  129. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  130. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  131. package/dist/apps/www/_shell.d.ts +1 -1
  132. package/dist/apps/www/_shell.js +2 -2
  133. package/dist/apps/www/calendar/index.js +47 -5
  134. package/dist/apps/www/contacts/index.js +64 -11
  135. package/dist/apps/www/index.js +10 -1
  136. package/dist/apps/www/settings/encryption/index.js +14 -4
  137. package/dist/apps/www/settings/privacy/index.js +46 -5
  138. package/dist/apps/www/tasks/index.js +21 -7
  139. package/dist/lib/admin/domainsApi.d.ts +1 -1
  140. package/dist/lib/calendar/calendarApi.d.ts +46 -6
  141. package/dist/lib/calendar/calendarApi.js +110 -17
  142. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  143. package/dist/lib/calendar/calendarMutations.js +73 -14
  144. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  145. package/dist/lib/calendar/inviteApi.js +61 -4
  146. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  147. package/dist/lib/calendar/protectedEvents.js +113 -0
  148. package/dist/lib/calendar/recurrence.d.ts +2 -0
  149. package/dist/lib/calendar/recurrence.js +16 -0
  150. package/dist/lib/calendar/sealedInvites.d.ts +6 -8
  151. package/dist/lib/calendar/sealedInvites.js +43 -6
  152. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  153. package/dist/lib/contacts/contactsApi.js +23 -7
  154. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  155. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  156. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  157. package/dist/lib/crypto/composeSecurity.js +13 -5
  158. package/dist/lib/crypto/keyExport.js +2 -2
  159. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  160. package/dist/lib/crypto/keyPairMatch.js +65 -0
  161. package/dist/lib/crypto/keySession.d.ts +19 -2
  162. package/dist/lib/crypto/keySession.js +89 -13
  163. package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
  164. package/dist/lib/crypto/keyvaultApi.js +13 -3
  165. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  166. package/dist/lib/crypto/meetingKeys.js +87 -0
  167. package/dist/lib/crypto/messageSecurity.d.ts +26 -2
  168. package/dist/lib/crypto/messageSecurity.js +122 -10
  169. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  170. package/dist/lib/crypto/openPgpKey.js +89 -0
  171. package/dist/lib/crypto/openpgp.d.ts +47 -0
  172. package/dist/lib/crypto/openpgp.js +88 -0
  173. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  174. package/dist/lib/crypto/pgpMessage.js +271 -0
  175. package/dist/lib/crypto/sharedMailboxKeys.d.ts +15 -3
  176. package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
  177. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  178. package/dist/lib/crypto/smimeMessage.js +11 -7
  179. package/dist/lib/crypto/zeroAccessKey.js +5 -0
  180. package/dist/lib/mail/mailApi.d.ts +33 -0
  181. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  182. package/dist/lib/mail/mailboxImportApi.js +15 -0
  183. package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -0
  184. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  185. package/dist/lib/tasks/tasksApi.js +10 -4
  186. package/dist/lib/util/clientPlatform.d.ts +14 -0
  187. package/dist/lib/util/clientPlatform.js +26 -0
  188. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  189. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  190. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  191. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  192. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  193. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  194. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  195. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  196. package/lib/admin/domainsApi.ts +1 -1
  197. package/lib/calendar/calendarApi.ts +140 -17
  198. package/lib/calendar/calendarMutations.ts +79 -12
  199. package/lib/calendar/inviteApi.ts +75 -5
  200. package/lib/calendar/protectedEvents.ts +143 -0
  201. package/lib/calendar/recurrence.ts +17 -0
  202. package/lib/calendar/sealedInvites.ts +47 -7
  203. package/lib/contacts/contactsApi.ts +43 -17
  204. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  205. package/lib/crypto/composeSecurity.ts +20 -5
  206. package/lib/crypto/keyExport.ts +2 -2
  207. package/lib/crypto/keyPairMatch.ts +69 -0
  208. package/lib/crypto/keySession.ts +102 -12
  209. package/lib/crypto/keyvaultApi.ts +49 -7
  210. package/lib/crypto/meetingKeys.ts +118 -0
  211. package/lib/crypto/messageSecurity.ts +174 -13
  212. package/lib/crypto/openPgpKey.ts +118 -0
  213. package/lib/crypto/openpgp.ts +115 -0
  214. package/lib/crypto/pgpMessage.ts +336 -0
  215. package/lib/crypto/sharedMailboxKeys.ts +106 -28
  216. package/lib/crypto/smimeMessage.ts +11 -7
  217. package/lib/crypto/zeroAccessKey.ts +5 -0
  218. package/lib/mail/mailApi.ts +27 -0
  219. package/lib/mail/mailboxImportApi.ts +24 -0
  220. package/lib/search/zeroAccessIndexApi.ts +4 -0
  221. package/lib/tasks/tasksApi.ts +19 -5
  222. package/lib/util/clientPlatform.ts +50 -0
  223. package/lib/videoconf/protectedMeetings.ts +221 -0
  224. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  225. package/lib/zeroAccess/sealedMoves.ts +79 -0
  226. package/lib/zeroAccess/sealedRecords.ts +153 -0
  227. package/package.json +12 -3
@@ -11,6 +11,8 @@ import {
11
11
  Message,
12
12
  assembleDraft,
13
13
  assembleDraftRaw,
14
+ createDraft,
15
+ deleteMessage,
14
16
  forgetDraftImages,
15
17
  getMailbox,
16
18
  getMessage,
@@ -20,7 +22,7 @@ import {
20
22
  } from "../../../../lib/mail/mailApi.js";
21
23
  import { describeSendFailure } from "../../../../lib/mail/sendFailure.js";
22
24
  import { RecipientEncryptionStatus, resolveRecipientEncryption } from "../../../../lib/crypto/composeSecurity.js";
23
- import { getUnlockedKeys } from "../../../../lib/crypto/keySession.js";
25
+ import { getUnlockedKeys, type UnlockedKeys } from "../../../../lib/crypto/keySession.js";
24
26
  import { EncryptionPolicy, findActivePublicKey, getEncryptionPolicy, lookupKeys } from "../../../../lib/crypto/keyvaultApi.js";
25
27
  import { fromBase64 } from "../../../../lib/crypto/encoding.js";
26
28
  import type { ProtectedHeaders } from "../../../../lib/crypto/smimeMessage.js";
@@ -31,6 +33,7 @@ import { beginPendingSend, finishPendingSend, setPendingStage } from "./pendingS
31
33
  import { failOutgoing, forgetOutgoing, markOutgoingSending, markOutgoingSent, trackOutgoing } from "./outgoingReplies.js";
32
34
  import { openComposeFromOutside, requestUnlockFromOutside } from "./composeBridge.js";
33
35
  import { OutboxCountTracker, sendState } from "./sendState.js";
36
+ import { notifySendFailure } from "./sendOutcomes.js";
34
37
 
35
38
  /** Everything the background send needs, captured when Send is clicked: the window it came from is gone by the time it runs. */
36
39
  export interface SendRequest {
@@ -67,8 +70,25 @@ export interface SendRequest {
67
70
  * show the message as soon as it is sent (see `outgoingReplies.ts`). Absent for a new message, and for a draft opened again, which the draft's own
68
71
  * `inReplyTo`/`references` stand in for. */
69
72
  threading?: DraftThreading;
73
+ /** The copies of this message (`assemble()`: one per encryption format) already built but not handed over yet, after an attempt that
74
+ * failed part-way: a retry hands over just these - never one that already went, and without building them again. */
75
+ pendingCopies?: Message[];
76
+ /** Some copies already went (`pendingCopies` holds only the rest): every later failure says so, and never offers "Open draft", which
77
+ * would send those recipients a second copy - kept across any number of retries. */
78
+ partial?: boolean;
79
+ /** The uids of every copy of this message, when it is more than one (one per encryption format, each delivered only to its own
80
+ * recipients): the request is retained under each, and a failure the server reports later for one of them offers no "Open draft",
81
+ * which would send the other copy's recipients a second message. */
82
+ copyUids?: string[];
83
+ /** What has become of each of those copies, by uid: one map shared by every request made from this one (a retry's, the one retained), so
84
+ * how any copy went is known to all of them. */
85
+ copyFates?: Map<string, CopyFate>;
70
86
  }
71
87
 
88
+ /** One copy of a message sent as several (`SendRequest.copyFates`): `unsent` - not handed over (or its hand-over failed), `pending` - handed
89
+ * over, how it went still to be reported, `sent`, or `failed` - the server reported it couldn't relay it. */
90
+ export type CopyFate = "unsent" | "pending" | "sent" | "failed";
91
+
72
92
  /** 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. */
73
93
  export const CONTEXT_GRACE_MS = 4_000;
74
94
 
@@ -85,21 +105,77 @@ export function registerOutboxCounter(tracker: (mailboxUid: string) => OutboxCou
85
105
 
86
106
  const RETAINED_LIMIT = 25;
87
107
 
88
- /** 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. */
108
+ /** 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
109
+ * reports later. A message sent as several copies (`SendRequest.copyUids`) is held under each copy still to go, from just before it is handed
110
+ * over - how the first went can be reported while the next is still being handed over. */
89
111
  export function retainedRequest(draftUid: string): SendRequest | undefined {
90
112
  return sendState.retained.get(draftUid);
91
113
  }
92
114
 
93
- /** 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. */
94
- export function forgetRetainedRequest(draftUid: string): void {
115
+ /** Stops holding the request of a message sent as several copies under copy `uid` - never another message's request held there since. */
116
+ function release(fates: Map<string, CopyFate>, uid: string): void {
117
+ if (sendState.retained.get(uid)?.copyFates === fates) {
118
+ sendState.retained.delete(uid);
119
+ }
120
+ }
121
+
122
+ /** 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
123
+ * message is confirmed now - always, for a message sent as one. */
124
+ function copyWent(fates: Map<string, CopyFate> | undefined, uid: string): boolean {
125
+ if (!fates) {
126
+ return true;
127
+ }
128
+ release(fates, uid);
129
+ if (fates.get(uid) === "sent") {
130
+ return false;
131
+ }
132
+ fates.set(uid, "sent");
133
+ return [...fates.values()].every((fate) => fate === "sent");
134
+ }
135
+
136
+ /** 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
137
+ * neither is wanted again. Whether the message has now gone in full - the last of its copies to go. */
138
+ export function forgetRetainedRequest(draftUid: string): boolean {
139
+ const request = sendState.retained.get(draftUid);
95
140
  sendState.retained.delete(draftUid);
96
141
  forgetDraftImages(draftUid);
142
+ return copyWent(request?.copyFates, draftUid);
143
+ }
144
+
145
+ /** Records that the server couldn't relay copy `uid` of the message `request` sent as several copies (nothing for a message sent as one). */
146
+ export function copyFailed(request: SendRequest | undefined, uid: string): void {
147
+ request?.copyFates?.set(uid, "failed");
148
+ }
149
+
150
+ /** 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
151
+ * finds the request held under it. Nothing for a message sent as one. */
152
+ export function copyRetried(request: SendRequest | undefined, uid: string): void {
153
+ if (request?.copyFates) {
154
+ request.copyFates.set(uid, "pending");
155
+ retain(request, [uid]);
156
+ }
157
+ }
158
+
159
+ /** 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:
160
+ * nobody has the message. `undefined` while one has gone or may, and for a message sent as one. */
161
+ export function unsentCopies(request: SendRequest | undefined): string[] | undefined {
162
+ const fates = request?.copyFates;
163
+ return fates && [...fates.values()].every((fate) => fate === "failed" || fate === "unsent") ? [...fates.keys()] : undefined;
164
+ }
165
+
166
+ /** Stops holding a message sent as several copies under any of them: it is being written again. */
167
+ export function forgetCopies(fates: Map<string, CopyFate>): void {
168
+ for (const uid of fates.keys()) {
169
+ release(fates, uid);
170
+ }
97
171
  }
98
172
 
99
- function retain(request: SendRequest): void {
100
- sendState.retained.delete(request.draft.uid);
101
- sendState.retained.set(request.draft.uid, request);
102
- if (sendState.retained.size > RETAINED_LIMIT) {
173
+ function retain(request: SendRequest, uids: string[]): void {
174
+ for (const uid of uids) {
175
+ sendState.retained.delete(uid);
176
+ sendState.retained.set(uid, request);
177
+ }
178
+ while (sendState.retained.size > RETAINED_LIMIT) {
103
179
  sendState.retained.delete(sendState.retained.keys().next().value!);
104
180
  }
105
181
  }
@@ -117,6 +193,12 @@ export function readSendKeys(mailboxUid: string, signEnabled: boolean, mailbox:
117
193
  };
118
194
  }
119
195
 
196
+ /** 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
197
+ * mailbox being locked: `decideSend()` then asks for the unlock rather than settling for another format, or none. */
198
+ export function senderCanUseOpenPgp(unlocked: UnlockedKeys | undefined): boolean {
199
+ return !unlocked || !!unlocked.openPgpKeys?.length;
200
+ }
201
+
120
202
  /** Whether this sender could encrypt at all: an encryption key unlocked here, enrolled on the mailbox, or offered earlier this session. */
121
203
  export function senderHasEncryptionKey(request: Pick<SendRequest, "mailbox" | "offeredEncrypt">, canEncryptSelf: boolean): boolean {
122
204
  return canEncryptSelf || request.offeredEncrypt || !!findActivePublicKey(request.mailbox?.keys ?? [], "encrypt");
@@ -153,9 +235,14 @@ interface Failure {
153
235
  message: string;
154
236
  details: string[];
155
237
  block?: SendBlock;
238
+ /** Some copies went (their recipients have it); the others are the attempt's `pendingCopies`. */
239
+ partial?: boolean;
156
240
  }
157
241
 
158
- type Outcome = { kind: "queued" | "sent" | "scheduled" } | { kind: "failed"; failure: Failure };
242
+ /** `copyUids`, `copyFates`: every copy of the message and what became of each, when there is more than one (`SendRequest.copyUids`). "sent"
243
+ * for a message sent as several copies: the last of them went in this attempt, with nothing else to confirm it. */
244
+ type Copies = { copyUids?: string[]; copyFates?: Map<string, CopyFate> };
245
+ type Outcome = ({ kind: "queued" | "sent" | "scheduled" } & Copies) | ({ kind: "failed"; failure: Failure; pendingCopies?: Message[] } & Copies);
159
246
 
160
247
  const OPEN_POLICY: EncryptionPolicy = { encryptSameOrg: "optional", encryptFederated: "optional", encryptExternal: "optional" };
161
248
 
@@ -182,7 +269,11 @@ async function completeContext(request: SendRequest, client: ApiClient | undefin
182
269
  return { mailbox, policy };
183
270
  }
184
271
 
185
- /** Builds the message's final content into the draft (plain, signed, or signed and encrypted in this browser) and returns the stored message. */
272
+ /** Builds the message's final content into the draft (plain, signed, or signed and encrypted in this browser) and returns the stored
273
+ * message(s) to send: one, or - for an encrypted message whose recipients need both formats (restapi `specs/end-to-end_encryption.md`
274
+ * *OpenPGP Interoperability*) - an S/MIME copy (this draft) and an OpenPGP copy (a second draft), each listing every recipient but
275
+ * delivered only to the recipients of its own format (`deliverTo`). Never a plaintext copy. The second draft is added to `created` as soon as
276
+ * it exists, so an attempt that fails before handing it over can remove it. */
186
277
  async function assemble(
187
278
  request: SendRequest,
188
279
  mailbox: Mailbox | undefined,
@@ -190,10 +281,11 @@ async function assemble(
190
281
  statuses: (RecipientEncryptionStatus | undefined)[],
191
282
  withSignature: boolean,
192
283
  client: ApiClient | undefined,
193
- ): Promise<Message> {
284
+ created: Message[],
285
+ ): Promise<Message[]> {
194
286
  const { draft, to, cc, bcc, subject, html } = request;
195
287
  if (mode === "plain") {
196
- return assembleDraft(draft.uid, { to, cc, bcc, subject, html, intent: "send" }, client);
288
+ return [await assembleDraft(draft.uid, { to, cc, bcc, subject, html, intent: "send" }, client)];
197
289
  }
198
290
  const keys = readSendKeys(request.mailboxUid, request.signEnabled, mailbox);
199
291
  const own = mailbox!;
@@ -216,18 +308,57 @@ async function assemble(
216
308
  );
217
309
  const encrypt = mode === "encrypt";
218
310
  const outerHeaders = encrypt ? applyBaselineOuterHeaders(protectedHeaders) : protectedHeaders;
219
- const mimePart = encrypt
220
- ? await buildEncryptedMessage(
221
- bodyContentType,
222
- html,
223
- protectedHeaders,
224
- outerHeaders,
225
- [keys.unlocked!.encryptionCertDer!, ...statuses.map((status) => fromBase64(status!.encryptCert!.publicKey))],
226
- signing,
227
- )
228
- : await buildSignedOnlyMessage(bodyContentType, html, protectedHeaders, signing!.certDer, signing!.privateKey);
229
- const rawMime = assembleOutboundMime(outerHeaders, mimePart);
230
- return assembleDraftRaw(draft.uid, { to, cc, bcc, subject: outerHeaders.subject, rawMime, intent: "send" }, client);
311
+ if (!encrypt) {
312
+ const signedPart = await buildSignedOnlyMessage(bodyContentType, html, protectedHeaders, signing!.certDer, signing!.privateKey);
313
+ return [await assembleDraftRaw(draft.uid, { to, cc, bcc, subject: outerHeaders.subject, rawMime: assembleOutboundMime(outerHeaders, signedPart), intent: "send" }, client)];
314
+ }
315
+ const smime = statuses.filter((status) => status!.format !== "openpgp");
316
+ const pgp = statuses.filter((status) => status!.format === "openpgp");
317
+ const deliverTo = (group: (RecipientEncryptionStatus | undefined)[]) => (smime.length > 0 && pgp.length > 0 ? { deliverTo: group.map((status) => status!.address) } : {});
318
+ const sent: Message[] = [];
319
+ if (smime.length > 0) {
320
+ const part = await buildEncryptedMessage(
321
+ bodyContentType,
322
+ html,
323
+ protectedHeaders,
324
+ outerHeaders,
325
+ [keys.unlocked!.encryptionCertDer!, ...smime.map((status) => fromBase64(status!.encryptCert!.publicKey))],
326
+ signing,
327
+ );
328
+ const rawMime = assembleOutboundMime(outerHeaders, part);
329
+ sent.push(await assembleDraftRaw(draft.uid, { to, cc, bcc, subject: outerHeaders.subject, rawMime, intent: "send", ...deliverTo(smime) }, client));
330
+ }
331
+ if (pgp.length > 0) {
332
+ // OpenPGP.js and the PGP/MIME builder load only for a message that has OpenPGP recipients.
333
+ const [{ buildPgpEncryptedMessage, readKeysBase64 }, { armorPublicKey }] = await Promise.all([
334
+ import("../../../../lib/crypto/pgpMessage.js"),
335
+ import("../../../../lib/crypto/openpgp.js"),
336
+ ]);
337
+ const ownKey = keys.unlocked!.openPgpKeys![0].privateKey;
338
+ const recipientKeys = await readKeysBase64(pgp.map((status) => status!.openPgpKey!.publicKey));
339
+ // Its own Message-ID: a second message, even though it is the same one for its readers.
340
+ const pgpHeaders: ProtectedHeaders = { ...protectedHeaders, messageId: `<${crypto.randomUUID()}@${domain}>` };
341
+ const pgpOuter = applyBaselineOuterHeaders(pgpHeaders);
342
+ // Signed with the sender's OpenPGP key (what an OpenPGP reader can check) whenever the message is signed, and carrying the
343
+ // sender's public key, so a correspondent without it can verify and reply encrypted.
344
+ const part = await buildPgpEncryptedMessage(
345
+ bodyContentType,
346
+ html,
347
+ pgpHeaders,
348
+ pgpOuter,
349
+ [ownKey.toPublic(), ...recipientKeys],
350
+ withSignature ? ownKey : undefined,
351
+ armorPublicKey(ownKey.toPublic()),
352
+ );
353
+ const rawMime = assembleOutboundMime(pgpOuter, part);
354
+ let target = draft;
355
+ if (smime.length > 0) {
356
+ target = await createDraft(request.mailboxUid, draft.folderUid, undefined, client);
357
+ created.push(target);
358
+ }
359
+ sent.push(await assembleDraftRaw(target.uid, { to, cc, bcc, subject: pgpOuter.subject, rawMime, intent: "send", ...deliverTo(pgp) }, client));
360
+ }
361
+ return sent;
231
362
  }
232
363
 
233
364
  function failureOf(err: unknown, fallback: string): Failure {
@@ -235,21 +366,144 @@ function failureOf(err: unknown, fallback: string): Failure {
235
366
  return { message: failure.message, details: failure.lines };
236
367
  }
237
368
 
238
- /** 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?". */
239
- async function alreadyAccepted(request: SendRequest, client: ApiClient | undefined): Promise<boolean> {
369
+ /** Whether `fresh`, `copy` as the server holds it now, was accepted (out of Drafts, or leased). */
370
+ function isAccepted(copy: Message, fresh: Message): boolean {
371
+ return fresh.folderUid !== copy.folderUid || !!fresh.scheduledSendLeaseExpiresAt || !!fresh.scheduledSendRelayedAt;
372
+ }
373
+
374
+ /** Whether the server already holds `copy` as accepted - the answer to "did that request that never returned actually land?". */
375
+ async function accepted(copy: Message, client: ApiClient | undefined): Promise<boolean> {
240
376
  try {
241
- const fresh = await getMessage(request.draft.uid, client);
242
- return fresh.folderUid !== request.draft.folderUid || !!fresh.scheduledSendLeaseExpiresAt || !!fresh.scheduledSendRelayedAt;
377
+ return isAccepted(copy, await getMessage(copy.uid, client));
243
378
  } catch {
244
379
  return false;
245
380
  }
246
381
  }
247
382
 
383
+ /** 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
384
+ * holds it as accepted after all. Best effort: never rejects. */
385
+ async function discardCopy(copy: Message, client: ApiClient | undefined): Promise<void> {
386
+ try {
387
+ const fresh = await getMessage(copy.uid, client);
388
+ if (!isAccepted(copy, fresh)) {
389
+ await deleteMessage(fresh.uid, fresh.version, client);
390
+ }
391
+ } catch {
392
+ // Left in Drafts, where it can be deleted by hand.
393
+ }
394
+ }
395
+
396
+ /** Whether the server already holds every copy of `request`'s message still to go (its draft, when none was built yet) as accepted. */
397
+ async function alreadyAccepted(request: SendRequest, client: ApiClient | undefined): Promise<boolean> {
398
+ for (const copy of request.pendingCopies ?? [request.draft]) {
399
+ if (!(await accepted(copy, client))) {
400
+ return false;
401
+ }
402
+ }
403
+ return true;
404
+ }
405
+
406
+ /** Hands `copies` over to the server - queued, or scheduled - one after the other. One that fails stops the rest: the failure carries the
407
+ * 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). */
408
+ async function handOver(request: SendRequest, copies: Message[], client: ApiClient | undefined): Promise<Outcome> {
409
+ // A retry hands over only what was left: the request already names every copy.
410
+ const copyUids: string[] | undefined = request.copyUids ?? (copies.length > 1 ? copies.map((copy) => copy.uid) : undefined);
411
+ const copyFates = request.copyFates ?? (copyUids && new Map(copyUids.map((uid): [string, CopyFate] => [uid, "unsent"])));
412
+ // 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
413
+ // is still on its way to the server, and must find the others - a failure that offered "Open draft" would send their recipients the
414
+ // message again, a success not counted would leave the message never confirmed.
415
+ if (copyFates && !request.scheduledSendTime) {
416
+ for (const copy of copies) {
417
+ copyFates.set(copy.uid, "pending");
418
+ }
419
+ retain({ ...request, copyUids, copyFates }, copies.map((copy) => copy.uid));
420
+ }
421
+ let queued = false;
422
+ let completed = false;
423
+ for (let index = 0; index < copies.length; index++) {
424
+ const copy = copies[index];
425
+ try {
426
+ if (request.scheduledSendTime) {
427
+ await sendMessage(copy.uid, { scheduledSendTime: request.scheduledSendTime }, client);
428
+ } else {
429
+ const handed = await queueMessageSend(copy.uid, client);
430
+ queued = handed.queued || queued;
431
+ // A server with no queue relayed it before answering: one of several copies has gone already.
432
+ if (!handed.queued && copyFates) {
433
+ completed = copyWent(copyFates, copy.uid) || completed;
434
+ }
435
+ }
436
+ } catch (err) {
437
+ // A request that never came back may still have landed: never offer to send it twice if it did.
438
+ if (!(err instanceof ApiRequestError) && (await accepted(copy, client))) {
439
+ queued = true;
440
+ continue;
441
+ }
442
+ const failure = failureOf(err, request.scheduledSendTime ? "Could not schedule this message." : "Could not send this message.");
443
+ // This copy and the ones after it weren't handed over: nothing of them is to be reported.
444
+ if (copyFates) {
445
+ for (const left of copies.slice(index)) {
446
+ copyFates.set(left.uid, "unsent");
447
+ release(copyFates, left.uid);
448
+ }
449
+ }
450
+ // 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
451
+ // way: nobody has the message - the whole message's failure, not some recipients'.
452
+ const fates = [...(copyFates?.values() ?? [])];
453
+ const nobodyHasIt = fates.includes("failed") && fates.every((fate) => fate === "failed" || fate === "unsent");
454
+ // A copy that went in this attempt, or in an earlier one this retries.
455
+ const partial = !nobodyHasIt && (index > 0 || !!request.partial);
456
+ return {
457
+ kind: "failed",
458
+ failure: partial
459
+ ? { ...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}` }
460
+ : failure,
461
+ // Nothing went of a single copy: a retry decides and assembles again from the request, like the first attempt (the stored
462
+ // draft may have been opened and autosaved plain since). Several copies are kept even when none went, so a mixed-format
463
+ // retry doesn't create another OpenPGP draft.
464
+ pendingCopies: partial || copies.length > 1 ? copies.slice(index) : undefined,
465
+ copyUids,
466
+ copyFates,
467
+ };
468
+ }
469
+ }
470
+ if (request.scheduledSendTime) {
471
+ return { kind: "scheduled", copyUids, copyFates };
472
+ }
473
+ // 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
474
+ // to go confirms it.
475
+ return { kind: (copyFates ? completed : !queued) ? "sent" : "queued", copyUids, copyFates };
476
+ }
477
+
248
478
  /** The whole client-side path of one send: settle, decide, assemble, hand over. Never rejects. `client` is the explicit `ApiClient` (from
249
479
  * `useApiClient()`) the window that built `request` was rendered under, `undefined` for the default global cookie-based session. */
250
480
  async function attempt(request: SendRequest, client: ApiClient | undefined): Promise<Outcome> {
481
+ // The copies' drafts `assemble()` created (the OpenPGP one's): an attempt that fails before handing them over deletes them - its Retry
482
+ // assembles the message again, creating its own, and one left behind would reach its recipients a second time if sent from Drafts.
483
+ const created: Message[] = [];
251
484
  try {
252
485
  await request.saved?.catch(() => undefined);
486
+ // 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
487
+ // 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
488
+ // it would never go - it is left out, and what fails now is a failure of only some of the recipients.
489
+ if (request.pendingCopies?.length) {
490
+ setPendingStage(request.draft.uid, "queuing");
491
+ const remaining: Message[] = [];
492
+ let completed = false;
493
+ for (const copy of request.pendingCopies) {
494
+ if (!(await accepted(copy, client))) {
495
+ remaining.push(copy);
496
+ } else {
497
+ // How it went has most likely been reported already, to nothing waiting for it: it isn't waited for now.
498
+ completed = copyWent(request.copyFates, copy.uid) || completed;
499
+ }
500
+ }
501
+ if (remaining.length === 0) {
502
+ const { copyUids, copyFates } = request;
503
+ return { kind: request.scheduledSendTime ? "scheduled" : completed ? "sent" : "queued", copyUids, copyFates };
504
+ }
505
+ return await handOver(remaining.length < request.pendingCopies.length ? { ...request, partial: true } : request, remaining, client);
506
+ }
253
507
  const all = [...request.to, ...request.cc, ...request.bcc];
254
508
  let mailbox = request.mailbox;
255
509
  let policy = request.policy;
@@ -272,9 +526,16 @@ async function attempt(request: SendRequest, client: ApiClient | undefined): Pro
272
526
  ),
273
527
  ),
274
528
  );
529
+ const ownOpenPgp = senderCanUseOpenPgp(keys.unlocked);
275
530
  statuses = all.map((recipient, index) => {
276
531
  const found = lookups[index];
277
- return found ? resolveRecipientEncryption(own.primarySmtpAddress, ownPrefersMutual, policy ?? OPEN_POLICY, recipient.address, found.lookup) : undefined;
532
+ // Without an OpenPGP key of the sender's, a recipient with both formats gets S/MIME, whatever they prefer.
533
+ const status = found
534
+ ? resolveRecipientEncryption(own.primarySmtpAddress, ownPrefersMutual, policy ?? OPEN_POLICY, recipient.address, found.lookup, ownOpenPgp)
535
+ : undefined;
536
+ // An OpenPGP copy is encrypted to the sender too, with their own OpenPGP key: a recipient with only an OpenPGP key can't
537
+ // be encrypted to while the unlocked session has none.
538
+ return status?.format === "openpgp" && !ownOpenPgp ? { ...status, canEncrypt: false, autoEncrypt: false } : status;
278
539
  });
279
540
  }
280
541
  lookupsComplete = true;
@@ -288,24 +549,24 @@ async function attempt(request: SendRequest, client: ApiClient | undefined): Pro
288
549
  return { kind: "failed", failure: { message: decision.message, details: [] } };
289
550
  }
290
551
 
291
- const assembled = await assemble(request, mailbox, decision.action, statuses, decision.action === "sign" || (decision.action === "encrypt" && decision.sign), client);
292
- const withReceipt = request.requestReceipt ? await setMessageRequestReceipt(assembled, true, client) : assembled;
552
+ const assembled = await assemble(
553
+ request,
554
+ mailbox,
555
+ decision.action,
556
+ statuses,
557
+ decision.action === "sign" || (decision.action === "encrypt" && decision.sign),
558
+ client,
559
+ created,
560
+ );
561
+ const copies = request.requestReceipt ? await Promise.all(assembled.map((copy) => setMessageRequestReceipt(copy, true, client))) : assembled;
293
562
  setPendingStage(request.draft.uid, "queuing");
294
- try {
295
- if (request.scheduledSendTime) {
296
- await sendMessage(withReceipt.uid, { scheduledSendTime: request.scheduledSendTime }, client);
297
- return { kind: "scheduled" };
298
- }
299
- const result = await queueMessageSend(withReceipt.uid, client);
300
- return { kind: result.queued ? "queued" : "sent" };
301
- } catch (err) {
302
- // A request that never came back may still have landed: never offer to send it twice if it did.
303
- if (!(err instanceof ApiRequestError) && (await alreadyAccepted(request, client))) {
304
- return { kind: request.scheduledSendTime ? "scheduled" : "queued" };
305
- }
306
- throw err;
307
- }
563
+ // Handed over from here: a copy the hand-over doesn't deliver is its failure's to retry.
564
+ created.length = 0;
565
+ return await handOver(request, copies, client);
308
566
  } catch (err) {
567
+ for (const copy of created) {
568
+ await discardCopy(copy, client);
569
+ }
309
570
  return { kind: "failed", failure: failureOf(err, request.scheduledSendTime ? "Could not schedule this message." : "Could not send this message.") };
310
571
  }
311
572
  }
@@ -338,9 +599,11 @@ export function openDraftFromRequest(request: SendRequest): boolean {
338
599
  encryptRequested: request.encryptRequested,
339
600
  },
340
601
  });
341
- // Being edited again, it is no longer a message on its way in the conversation.
602
+ // Being edited again, it is no longer a message on its way in the conversation - and the failure's Retry, wherever it was clicked
603
+ // from, must not hand over the draft the window is now editing (and autosaving).
342
604
  if (opened) {
343
605
  forgetOutgoing(request.draft.uid);
606
+ dismiss(failureNotificationId(request.draft.uid));
344
607
  }
345
608
  return opened;
346
609
  }
@@ -349,9 +612,32 @@ function failureNotificationId(draftUid: string): string {
349
612
  return `send-failed:${draftUid}`;
350
613
  }
351
614
 
615
+ /** "Open draft" for a message sent as several copies none of which went, each still a draft (`unsentCopies()`): the message is written again in
616
+ * 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,
617
+ * and a copy left in Drafts would reach its recipients a second time if ever sent from there. */
618
+ function openDraftDroppingCopies(request: SendRequest, client: ApiClient | undefined): void {
619
+ if (!openDraftFromRequest(request)) {
620
+ return;
621
+ }
622
+ forgetCopies(request.copyFates!);
623
+ for (const copy of request.pendingCopies!.filter((copy) => copy.uid !== request.draft.uid)) {
624
+ forgetOutgoing(copy.uid);
625
+ void discardCopy(copy, client);
626
+ }
627
+ }
628
+
352
629
  /** The sticky "This message wasn't sent" pop-up for a send that failed on this side, with what can be done about it. */
353
630
  function notifyFailure(request: SendRequest, failure: Failure, client: ApiClient | undefined): void {
354
631
  const uid = request.draft.uid;
632
+ // 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
633
+ // 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
634
+ // showed, whose Retry hands every copy over again - the failed one from Outbox too - and whose "Open draft" writes the message again.
635
+ const copies = unsentCopies(request);
636
+ if (copies && [...request.copyFates!.values()].includes("failed")) {
637
+ const recipients = [...request.to, ...request.cc, ...request.bcc].map((recipient) => recipient.address);
638
+ notifySendFailure({ uid, mailboxUid: request.mailboxUid, subject: request.subject, recipients }, { message: failure.message, lines: failure.details }, request, client);
639
+ return;
640
+ }
355
641
  const actions: NotificationAction[] = [];
356
642
  const { block } = failure;
357
643
  if (block?.keysLocked && request.mailbox?.keys?.length) {
@@ -370,7 +656,10 @@ function notifyFailure(request: SendRequest, failure: Failure, client: ApiClient
370
656
  } else {
371
657
  actions.push({ label: "Retry", onClick: () => void retrySend(request, client) });
372
658
  }
373
- actions.push({ label: "Open draft", onClick: () => void openDraftFromRequest(request) });
659
+ // Part of it went: editing and sending the draft again would send those recipients a second copy.
660
+ if (!failure.partial) {
661
+ actions.push({ label: "Open draft", onClick: () => void (copies ? openDraftDroppingCopies(request, client) : openDraftFromRequest(request)) });
662
+ }
374
663
  notify({
375
664
  id: failureNotificationId(uid),
376
665
  kind: "error",
@@ -417,15 +706,25 @@ export function startSend(request: SendRequest, client?: ApiClient): boolean {
417
706
  finishPendingSend(uid);
418
707
  if (outcome.kind === "failed") {
419
708
  tracker?.revert();
420
- notifyFailure(request, outcome.failure, client);
709
+ notifyFailure(
710
+ outcome.pendingCopies
711
+ ? { ...request, pendingCopies: outcome.pendingCopies, partial: outcome.failure.partial, copyUids: outcome.copyUids, copyFates: outcome.copyFates }
712
+ : request,
713
+ outcome.failure,
714
+ client,
715
+ );
421
716
  return;
422
717
  }
423
718
  tracker?.settle();
424
719
  if (outcome.kind === "queued") {
425
- retain(request);
720
+ // A message sent as several copies is held under each already, from before it was handed over.
721
+ if (!outcome.copyFates) {
722
+ retain(request, [uid]);
723
+ }
426
724
  sendState.queuedListener?.(request.mailboxUid);
427
725
  } else if (outcome.kind === "sent") {
428
- // A server with no queue relayed it before answering: the Sent Items copy exists already.
726
+ // A server with no queue relayed it before answering (or, of several copies, the last of them went in this attempt): the Sent
727
+ // Items copy exists already.
429
728
  markOutgoingSent(uid);
430
729
  forgetDraftImages(uid);
431
730
  notifySent();