@rapidmx/web-client 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/apps/escrow/matters/[uid].tsx +4 -3
  2. package/apps/shared/calendar/calendarReminders.ts +35 -0
  3. package/apps/shared/calendar/sealedEvents.ts +113 -0
  4. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  5. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  6. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +89 -3
  7. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  8. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  9. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  10. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  11. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  12. package/apps/shared/components/calendar/eventForm.ts +2 -0
  13. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  14. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  15. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  16. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  17. package/apps/shared/components/contacts/contactCardData.ts +8 -4
  18. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  19. package/apps/shared/components/layout/AppShell.tsx +34 -5
  20. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +30 -1
  21. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +122 -0
  22. package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
  23. package/apps/shared/components/mail/ConversationList.tsx +1 -1
  24. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  25. package/apps/shared/components/mail/MessageDetailPane.tsx +59 -26
  26. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  27. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  28. package/apps/shared/components/mail/compose/draftResume.ts +28 -3
  29. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  30. package/apps/shared/components/mail/compose/quotedBody.ts +4 -3
  31. package/apps/shared/components/mail/invite/inviteStore.ts +17 -1
  32. package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
  33. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  34. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  35. package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
  36. package/apps/shared/components/settings/KeyExportSetting.tsx +173 -0
  37. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  38. package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
  39. package/apps/shared/components/settings/ZeroAccessSetting.tsx +128 -0
  40. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  41. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  42. package/apps/shared/mail/decryptedMessages.ts +11 -3
  43. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  44. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  45. package/apps/shared/mail/reportNotices.ts +3 -1
  46. package/apps/shared/mail/zeroAccessSource.ts +27 -0
  47. package/apps/shared/search/LocalIndexLifecycle.tsx +84 -24
  48. package/apps/shared/search/localIndexBuilder.ts +57 -20
  49. package/apps/shared/search/localIndexRpcClient.ts +96 -12
  50. package/apps/shared/search/localIndexSchema.ts +20 -4
  51. package/apps/shared/search/localIndexTransport.ts +6 -2
  52. package/apps/shared/search/localIndexWorker.ts +37 -18
  53. package/apps/shared/search/searchTier2.ts +5 -0
  54. package/apps/shared/search/zeroAccessIndexSync.ts +679 -0
  55. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  56. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  57. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  58. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  59. package/apps/www/_shell.tsx +2 -0
  60. package/apps/www/calendar/index.tsx +225 -61
  61. package/apps/www/contacts/index.tsx +67 -11
  62. package/apps/www/index.tsx +19 -3
  63. package/apps/www/settings/encryption/index.tsx +21 -1
  64. package/apps/www/settings/privacy/index.tsx +89 -39
  65. package/apps/www/tasks/index.tsx +24 -6
  66. package/dist/apps/escrow/matters/[uid].js +2 -2
  67. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  68. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  69. package/dist/apps/shared/calendar/sealedEvents.d.ts +14 -0
  70. package/dist/apps/shared/calendar/sealedEvents.js +85 -0
  71. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  72. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  73. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  74. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +25 -2
  75. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  76. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  77. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  78. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  79. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  80. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  81. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  82. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  83. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  84. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  85. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  86. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  87. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  88. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  89. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  90. package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
  91. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  92. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  93. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  94. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  95. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +27 -2
  96. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +17 -0
  97. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +98 -0
  98. package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
  99. package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
  100. package/dist/apps/shared/components/mail/ConversationList.js +1 -1
  101. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  102. package/dist/apps/shared/components/mail/MessageDetailPane.js +46 -21
  103. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  104. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  105. package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
  106. package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
  107. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  108. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  109. package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +1 -1
  110. package/dist/apps/shared/components/mail/compose/quotedBody.js +4 -3
  111. package/dist/apps/shared/components/mail/invite/inviteStore.js +16 -1
  112. package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
  113. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  114. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  115. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  116. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  117. package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
  118. package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
  119. package/dist/apps/shared/components/settings/KeyExportSetting.js +90 -0
  120. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  121. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  122. package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
  123. package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
  124. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +16 -0
  125. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +80 -0
  126. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  127. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  128. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  129. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  130. package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
  131. package/dist/apps/shared/mail/decryptedMessages.js +11 -3
  132. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  133. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  134. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  135. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  136. package/dist/apps/shared/mail/reportNotices.js +3 -1
  137. package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
  138. package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
  139. package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
  140. package/dist/apps/shared/search/LocalIndexLifecycle.js +74 -24
  141. package/dist/apps/shared/search/localIndexBuilder.d.ts +9 -1
  142. package/dist/apps/shared/search/localIndexBuilder.js +50 -17
  143. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  144. package/dist/apps/shared/search/localIndexRpcClient.js +129 -11
  145. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  146. package/dist/apps/shared/search/localIndexSchema.js +18 -4
  147. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -2
  148. package/dist/apps/shared/search/localIndexWorker.d.ts +11 -0
  149. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  150. package/dist/apps/shared/search/searchTier2.js +5 -0
  151. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +102 -0
  152. package/dist/apps/shared/search/zeroAccessIndexSync.js +544 -0
  153. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  154. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  155. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  156. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  157. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  158. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  159. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  160. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  161. package/dist/apps/www/_shell.d.ts +1 -1
  162. package/dist/apps/www/_shell.js +2 -2
  163. package/dist/apps/www/calendar/index.js +88 -20
  164. package/dist/apps/www/contacts/index.js +64 -11
  165. package/dist/apps/www/index.js +19 -3
  166. package/dist/apps/www/settings/encryption/index.js +16 -4
  167. package/dist/apps/www/settings/privacy/index.js +47 -6
  168. package/dist/apps/www/tasks/index.js +21 -7
  169. package/dist/lib/admin/domainsApi.d.ts +1 -1
  170. package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
  171. package/dist/lib/calendar/calendarApi.d.ts +52 -6
  172. package/dist/lib/calendar/calendarApi.js +110 -17
  173. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  174. package/dist/lib/calendar/calendarMutations.js +73 -14
  175. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  176. package/dist/lib/calendar/inviteApi.js +86 -7
  177. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  178. package/dist/lib/calendar/protectedEvents.js +113 -0
  179. package/dist/lib/calendar/recurrence.d.ts +2 -0
  180. package/dist/lib/calendar/recurrence.js +16 -0
  181. package/dist/lib/calendar/sealedInvites.d.ts +9 -0
  182. package/dist/lib/calendar/sealedInvites.js +65 -0
  183. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  184. package/dist/lib/contacts/contactsApi.js +23 -7
  185. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  186. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  187. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  188. package/dist/lib/crypto/composeSecurity.js +13 -5
  189. package/dist/lib/crypto/keyExport.d.ts +54 -0
  190. package/dist/lib/crypto/keyExport.js +161 -0
  191. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  192. package/dist/lib/crypto/keyPairMatch.js +65 -0
  193. package/dist/lib/crypto/keySession.d.ts +64 -3
  194. package/dist/lib/crypto/keySession.js +179 -12
  195. package/dist/lib/crypto/keyvaultApi.d.ts +129 -8
  196. package/dist/lib/crypto/keyvaultApi.js +45 -3
  197. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  198. package/dist/lib/crypto/meetingKeys.js +87 -0
  199. package/dist/lib/crypto/messageSecurity.d.ts +56 -3
  200. package/dist/lib/crypto/messageSecurity.js +201 -13
  201. package/dist/lib/crypto/mime.d.ts +5 -0
  202. package/dist/lib/crypto/mime.js +25 -0
  203. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  204. package/dist/lib/crypto/openPgpKey.js +89 -0
  205. package/dist/lib/crypto/openpgp.d.ts +47 -0
  206. package/dist/lib/crypto/openpgp.js +88 -0
  207. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  208. package/dist/lib/crypto/pgpMessage.js +271 -0
  209. package/dist/lib/crypto/sharedMailboxKeys.d.ts +62 -0
  210. package/dist/lib/crypto/sharedMailboxKeys.js +337 -0
  211. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  212. package/dist/lib/crypto/smimeMessage.js +11 -7
  213. package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
  214. package/dist/lib/crypto/zeroAccessKey.js +71 -0
  215. package/dist/lib/mail/conversationsApi.d.ts +6 -0
  216. package/dist/lib/mail/mailApi.d.ts +63 -2
  217. package/dist/lib/mail/mailApi.js +5 -1
  218. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  219. package/dist/lib/mail/mailboxImportApi.js +15 -0
  220. package/dist/lib/search/zeroAccessIndexApi.d.ts +85 -0
  221. package/dist/lib/search/zeroAccessIndexApi.js +35 -0
  222. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  223. package/dist/lib/tasks/tasksApi.js +10 -4
  224. package/dist/lib/util/clientPlatform.d.ts +14 -0
  225. package/dist/lib/util/clientPlatform.js +26 -0
  226. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  227. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  228. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  229. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  230. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  231. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  232. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  233. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  234. package/lib/admin/domainsApi.ts +1 -1
  235. package/lib/admin/escrowAccessRequestsApi.ts +6 -0
  236. package/lib/calendar/calendarApi.ts +143 -17
  237. package/lib/calendar/calendarMutations.ts +79 -12
  238. package/lib/calendar/inviteApi.ts +101 -8
  239. package/lib/calendar/protectedEvents.ts +143 -0
  240. package/lib/calendar/recurrence.ts +17 -0
  241. package/lib/calendar/sealedInvites.ts +73 -0
  242. package/lib/contacts/contactsApi.ts +43 -17
  243. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  244. package/lib/crypto/composeSecurity.ts +20 -5
  245. package/lib/crypto/keyExport.ts +229 -0
  246. package/lib/crypto/keyPairMatch.ts +69 -0
  247. package/lib/crypto/keySession.ts +216 -12
  248. package/lib/crypto/keyvaultApi.ts +174 -10
  249. package/lib/crypto/meetingKeys.ts +118 -0
  250. package/lib/crypto/messageSecurity.ts +284 -16
  251. package/lib/crypto/mime.ts +26 -0
  252. package/lib/crypto/openPgpKey.ts +118 -0
  253. package/lib/crypto/openpgp.ts +115 -0
  254. package/lib/crypto/pgpMessage.ts +336 -0
  255. package/lib/crypto/sharedMailboxKeys.ts +402 -0
  256. package/lib/crypto/smimeMessage.ts +11 -7
  257. package/lib/crypto/zeroAccessKey.ts +102 -0
  258. package/lib/mail/conversationsApi.ts +3 -0
  259. package/lib/mail/mailApi.ts +60 -3
  260. package/lib/mail/mailboxImportApi.ts +24 -0
  261. package/lib/search/zeroAccessIndexApi.ts +123 -0
  262. package/lib/tasks/tasksApi.ts +19 -5
  263. package/lib/util/clientPlatform.ts +50 -0
  264. package/lib/videoconf/protectedMeetings.ts +221 -0
  265. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  266. package/lib/zeroAccess/sealedMoves.ts +79 -0
  267. package/lib/zeroAccess/sealedRecords.ts +153 -0
  268. package/package.json +14 -5
@@ -7,6 +7,7 @@ import React, { useId, useState } from "react";
7
7
  import { HiOutlineCalendarDays, HiOutlineCheck } from "react-icons/hi2";
8
8
  import { acceptProposal, proposeNewTime, removeMessageInvite, respondToMessageInvite, } from "../../../../lib/calendar/inviteApi.js";
9
9
  import { guestPermissionsOf } from "../../../../lib/calendar/calendarApi.js";
10
+ import { openSealedEvent } from "../../calendar/sealedEvents.js";
10
11
  import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
11
12
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
12
13
  import Button from "../../../../lib/components/buttons/Button.js";
@@ -134,7 +135,7 @@ export default function InviteCard({ messageUid, headingLevel = 2 }) {
134
135
  return (_jsxs("section", { "aria-labelledby": headingId, className: "rounded-sm border border-border bg-surface-alt px-3 py-3 flex flex-col gap-2 text-sm text-text", children: [_jsxs(Heading, { id: headingId, className: "flex items-center gap-2 text-sm font-semibold", children: [_jsx(HiOutlineCalendarDays, { size: 16, "aria-hidden": "true", className: "shrink-0" }), changeRequest ? CHANGE_REQUEST_HEADING : (HEADINGS[method] ?? "Calendar event")] }), isReply && (_jsxs("p", { className: "break-words", children: [senderNode, " ", (sender?.responseStatus && REPLY_VERBS[sender.responseStatus]) ?? "responded", "."] })), isCounter && !changeRequest && (_jsxs("p", { className: "break-words", children: [senderNode, " proposed a new time", when ? _jsxs(_Fragment, { children: [": ", when] }) : "."] })), changeRequest && (_jsx("p", { className: "break-words", children: changeRequest.applied ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-semibold", children: senderName }), "\u2019s change was applied."] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-semibold", children: senderName }), " requested a change."] })) })), _jsx("p", { className: "font-medium break-words", children: title }), !isReply && (_jsxs("dl", { className: "grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1", children: [when && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "When" }), _jsxs("dd", { className: "break-words", children: [when, invite.recurring && _jsx("span", { className: "text-text-muted", children: " (Repeats)" })] })] })), invite.location && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Where" }), _jsx("dd", { className: "break-words", children: invite.location })] })), hasDescription && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Description" }), _jsx("dd", { className: "break-words", children: _jsx(EventDescriptionView, { html: invite.descriptionHtml, text: invite.description }) })] })), invite.organizer && !isCounter && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Organizer" }), _jsx("dd", { className: "break-words", children: _jsx(ParticipantLink, { participant: invite.organizer, children: formatMailAddress(invite.organizer) }) })] })), attendees && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Attendees" }), _jsx("dd", { className: "break-words", children: attendees })] })), visibility && !isCounter && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Visibility" }), _jsx("dd", { className: "break-words", children: visibility })] }))] })), isReply && when && _jsx("p", { className: "text-text-muted break-words", children: when }), conflicts && (_jsxs("p", { className: "text-danger break-words", children: [_jsx("span", { className: "font-medium", children: "Conflicts with:" }), " ", conflicts] })), invite.guestPermissions && !isReply && !isCounter && method !== "CANCEL" && (_jsx("p", { className: "text-xs text-text-muted break-words", children: describeGuestPermissions(permissions) })), invite.isOrganizer && !isReply && !isCounter && _jsx("p", { className: "text-text-muted", children: "You are the organizer of this meeting." }), method === "CANCEL" && _jsx("p", { className: "font-medium text-danger", children: "This meeting was canceled." }), invite.response && !invite.isOrganizer && !isReply && !isCounter && (_jsx("p", { role: "status", className: `font-medium ${ANSWER_CLASS[invite.response]}`, children: ANSWER_TEXT[invite.response] })), isCounter && invite.response === "accepted" && !changeRequest?.applied && (_jsx("p", { role: "status", className: "font-medium text-success", children: "You accepted the proposed time." })), proposed && (_jsx("p", { role: "status", className: "font-medium text-text", children: "Your proposed time was sent to the organizer." })), requested && (_jsx("p", { role: "status", className: "font-medium text-text", children: "Your change was sent to the organizer." })), invite.outdated && _jsx("p", { className: "text-xs text-text-muted", children: "A newer version of this meeting is already on your calendar." }), hasActions && (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [showResponses && (_jsx("div", { role: "group", "aria-label": "Respond to this meeting", "aria-busy": busy || undefined, className: "flex flex-wrap gap-2", children: RESPONSES.map(({ response, label, failure }) => {
135
136
  const current = invite.response === response;
136
137
  return (_jsxs(Button, { type: "button", variant: "secondary", className: ["!w-auto", current ? "!bg-primary/10 !border-primary disabled:!opacity-100" : ""].join(" "), "aria-pressed": current, "aria-busy": pending === response || undefined, loading: pending === response, disabled: busy || current, onClick: () => void run(response, failure, () => respondToMessageInvite(messageUid, response, client)), children: [current && _jsx(HiOutlineCheck, { size: 14, "aria-hidden": "true" }), label] }, response));
137
- }) })), invite.canPropose && !proposing && (_jsx(Button, { type: "button", variant: "text", disabled: busy, onClick: () => setProposing(true), children: "Propose new time" })), canRequest && !requesting && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy, onClick: () => setRequesting(true), children: invite.canRequestChange ? "Request a change" : "Add guests" })), invite.canAcceptProposal && invite.response !== "accepted" && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "acceptProposal" || undefined, loading: pending === "acceptProposal", disabled: busy, onClick: () => void run("acceptProposal", "Couldn't accept the proposed time", () => acceptProposal(messageUid, client)), children: "Accept proposal" })), invite.canAdd && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "add" || undefined, loading: pending === "add", disabled: busy,
138
+ }) })), invite.canPropose && !proposing && (_jsx(Button, { type: "button", variant: "text", disabled: busy, onClick: () => setProposing(true), children: "Propose new time" })), canRequest && !requesting && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy, onClick: () => setRequesting(true), children: invite.canRequestChange ? "Request a change" : "Add guests" })), invite.canAcceptProposal && invite.response !== "accepted" && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "acceptProposal" || undefined, loading: pending === "acceptProposal", disabled: busy, onClick: () => void run("acceptProposal", "Couldn't accept the proposed time", () => acceptProposal(messageUid, client, invite.calendarEventUid, openSealedEvent)), children: "Accept proposal" })), invite.canAdd && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "add" || undefined, loading: pending === "add", disabled: busy,
138
139
  // A published event has no organizer to answer, so adding it is accepting it: the server sends no reply.
139
140
  onClick: () => void run("add", "Couldn't add this event to your calendar", () => respondToMessageInvite(messageUid, "accepted", client)), children: "Add to calendar" })), invite.canRemove && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "remove" || undefined, loading: pending === "remove", disabled: busy, onClick: () => void run("remove", "Couldn't remove this meeting from your calendar", () => removeMessageInvite(messageUid, client)), children: "Remove from calendar" })), invite.onCalendar && invite.calendarEventUid && (_jsx("a", { href: calendarHref(invite), className: "text-sm font-medium text-primary-dark hover:underline", children: "Open in Calendar" }))] })), requesting && (_jsx("div", { className: "rounded-md bg-surface p-3", children: _jsx(RequestChangeForm, { subject: {
140
141
  uid: invite.calendarEventUid,
@@ -147,6 +148,15 @@ export default function InviteCard({ messageUid, headingLevel = 2 }) {
147
148
  description: invite.description,
148
149
  descriptionHtml: invite.descriptionHtml,
149
150
  guestAddresses: invite.attendees.map((attendee) => attendee.address),
151
+ // The invitation's own content: what a protected copy of the event is named by.
152
+ invitationContent: invite.summary
153
+ ? {
154
+ title: invite.summary,
155
+ location: invite.location ?? undefined,
156
+ description: invite.description ?? undefined,
157
+ descriptionHtml: invite.descriptionHtml ?? undefined,
158
+ }
159
+ : undefined,
150
160
  }, canChange: !!invite.canRequestChange, canInvite: !!invite.canRequestInvite, onSent: () => {
151
161
  setRequesting(false);
152
162
  setRequested(true);
@@ -12,11 +12,12 @@ import MoveToFolderDialog from "./MoveToFolderDialog.js";
12
12
  import InviteCard from "./InviteCard.js";
13
13
  import { isCalendarAttachment } from "./invite/inviteFormat.js";
14
14
  import { useMessageInvite } from "./invite/inviteStore.js";
15
+ import { rememberSealedInvite } from "../../../../lib/calendar/sealedInvites.js";
15
16
  import { getUnlockedKeys, subscribeKeySession } from "../../../../lib/crypto/keySession.js";
16
17
  import { extractAddresses } from "../../../../lib/crypto/mime.js";
17
18
  import { SignerKeyConflictError, signingKeyFingerprints, trustSigner } from "../../../../lib/crypto/keyvaultApi.js";
18
19
  import { isLikelyMailingList } from "../../../../lib/crypto/composeSecurity.js";
19
- import { clearPinnedSignerCache, getPinnedSignerFingerprints, getSignerKeyState } from "./pinnedSigners.js";
20
+ import { clearPinnedSignerCache, getPinnedSigners, getSignerKeyState } from "./pinnedSigners.js";
20
21
  import { currentVerificationSeal, getVaultGeneration, sendVerificationSeal } from "./verificationSeals.js";
21
22
  import { getMyMailboxAccess } from "../../../../lib/mail/mailboxAccessApi.js";
22
23
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
@@ -78,6 +79,9 @@ const SECURITY_INDICATOR = {
78
79
  // Verified when first opened (a verification seal), but not live: muted, never the green verified pill. A signer key
79
80
  // later reported compromised switches it to `LATER_COMPROMISED_INDICATOR_CLASS`.
80
81
  verified_at_first_open: { label: "Verified when first opened", className: "bg-surface-alt text-text-muted" },
82
+ // Sealed by the server on arrival (zero-access): distinct from "Encrypted" - the server held this message in the clear while it
83
+ // delivered it - and never worded as end-to-end, per the spec's indicator table.
84
+ protected_at_rest: { label: "Protected at rest", className: "bg-primary/5 text-text" },
81
85
  };
82
86
  /** The amber `verified_at_first_open` badge for a signer key since reported compromised. */
83
87
  const LATER_COMPROMISED_INDICATOR_CLASS = "bg-warning/15 text-text";
@@ -189,11 +193,14 @@ function IconAction({ icon, label, onClick, disabled, reason, busy, onPrefetch,
189
193
  const env = useKeyEnvironment();
190
194
  return (_jsx("button", { type: "button", "aria-label": label, title: reason ?? (shortcut ? withHint(label, shortcut, env) : label), "aria-keyshortcuts": shortcut ? ariaKeyShortcuts(shortcut, env) : undefined, "aria-busy": busy || undefined, disabled: disabled, onClick: onClick, onPointerEnter: onPrefetch, onFocus: onPrefetch, className: ICON_BUTTON_CLASS, children: icon }));
191
195
  }
192
- function SecurityIndicator({ security }) {
193
- const { label, className } = SECURITY_INDICATOR[security.state];
196
+ function SecurityIndicator({ security, zeroAccess }) {
197
+ // A zero-access message this device can't open yet (keys locked) evaluates as an encrypted one it has no key for: it is still
198
+ // labelled for what it is.
199
+ const state = zeroAccess && security.state === "encrypted" && security.decryptError !== undefined ? "protected_at_rest" : security.state;
200
+ const { label, className } = SECURITY_INDICATOR[state];
194
201
  if (security.state !== "verified_at_first_open") {
195
202
  // Every encrypted state carries a small lock, so the badge reads at a glance; the words are unchanged.
196
- const encrypted = security.state.startsWith("encrypted");
203
+ const encrypted = state.startsWith("encrypted") || state === "protected_at_rest";
197
204
  return (_jsxs("span", { className: `inline-flex items-center gap-1 text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${className}`, children: [encrypted && _jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), label] }));
198
205
  }
199
206
  return (_jsxs("span", { className: `inline-flex items-center gap-1 text-xs font-medium shrink-0 py-1 px-2.5 rounded-pill ${security.laterCompromised ? LATER_COMPROMISED_INDICATOR_CLASS : className}`, children: [security.laterCompromised ? (_jsx(HiOutlineExclamationTriangle, { size: 12, "aria-hidden": "true" })) : (_jsx(HiOutlineCheck, { size: 12, "aria-hidden": "true" })), label] }));
@@ -256,7 +263,7 @@ export default function MessageDetailPane(props) {
256
263
  * surfaced as an attachment - so a message with neither flag can't carry a signature to verify, and
257
264
  * fetching its full raw source (attachments included) would be pure waste. */
258
265
  function needsRawSecurityEvaluation(message) {
259
- return !!message.encrypted || message.hasAttachments;
266
+ return !!message.encrypted || !!message.zeroAccess || message.hasAttachments;
260
267
  }
261
268
  function MessageDetailContent({ message: messageProp, attachments, backHref, isSentItems, onRecalled, isOutbox, draftsFolderUid, onScheduledSendCanceled, folders, onMoved, onFolderCreated, onReceiptHandled, onArchived, onChanged, labels, onLabelsChanged, onLabelCreated, inThread, threadSubject, threadHeader, footer, shortcuts, }) {
262
269
  // A copy re-read from the server after an Outbox action was refused (409/403) or a send lease ran out - see
@@ -362,7 +369,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
362
369
  // mailbox's own signing keys when it sent the message itself. A failed lookup means no pins, which can only
363
370
  // ever make a signature "signer not verified", never verified.
364
371
  const senderAddress = message.from.address;
365
- const pinsPromise = getPinnedSignerFingerprints(message.mailboxUid, senderAddress, client).then((fingerprints) => ({ loaded: true, fingerprints }), () => ({ loaded: false, fingerprints: [] }));
372
+ const pinsPromise = getPinnedSigners(message.mailboxUid, senderAddress, client).then((signers) => ({ loaded: true, ...signers }), () => ({ loaded: false, fingerprints: [], openPgpKeys: [] }));
366
373
  // Verification seals (see `verificationSeals.ts`) need unlocked keys and the vault's current master key
367
374
  // generation; without either the message is evaluated exactly as before, with no seal. The sender's key records
368
375
  // are loaded alongside, so a seal can say when the signer key was later revoked as compromised.
@@ -385,7 +392,8 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
385
392
  const ownPins = signingKeyFingerprints(ownKeys);
386
393
  const [contactPins, sealContext] = await Promise.all([pinsPromise, sealContextPromise]);
387
394
  const pins = [...new Set([...contactPins.fingerprints, ...ownPins])];
388
- const pinned = pins.length > 0 ? pins : undefined;
395
+ // An OpenPGP signature is checked against the sender's pinned OpenPGP certificates themselves (it names only a key ID).
396
+ const pinned = contactPins.openPgpKeys.length > 0 ? { fingerprints: pins, openPgpKeys: contactPins.openPgpKeys } : pins.length > 0 ? pins : undefined;
389
397
  // The raw content is a byte string - only ever handed to evaluateMessageSecurity(), never shown. Keys
390
398
  // are read after the pin lookup's await, so a lock that happened meanwhile is honored.
391
399
  const unlocked = getUnlockedKeys(message.mailboxUid);
@@ -422,7 +430,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
422
430
  const viaAlias = await messageSecurity.evaluateMessageSecurity(rawMime, unlocked, pinned, alias);
423
431
  result = { ...result, notAddressedToReader: viaAlias.notAddressedToReader };
424
432
  }
425
- const unpinned = contactPins.loaded && pins.length === 0;
433
+ const unpinned = contactPins.loaded && pins.length === 0 && contactPins.openPgpKeys.length === 0;
426
434
  const keyChanged = signerKeyChanged(result);
427
435
  const [keyState, access] = await Promise.all([
428
436
  keyChanged || (unpinned && UNVERIFIED_SIGNER_STATES.has(result.state)) ? loadKeyState() : undefined,
@@ -437,13 +445,13 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
437
445
  })
438
446
  .catch(() => {
439
447
  if (!cancelled) {
440
- setSecurity(message.encrypted ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" });
448
+ setSecurity(message.encrypted || message.zeroAccess ? { state: "encrypted", decryptError: ENCRYPTED_LOAD_ERROR } : { state: "unprotected" });
441
449
  }
442
450
  });
443
451
  return () => {
444
452
  cancelled = true;
445
453
  };
446
- }, [message.uid, message.mailboxUid, message.from.address, message.encrypted, rawEvaluationNeeded, unlockRefresh, readerAddressesKey, client]);
454
+ }, [message.uid, message.mailboxUid, message.from.address, message.encrypted, message.zeroAccess?.keyFingerprint, rawEvaluationNeeded, unlockRefresh, readerAddressesKey, client]);
447
455
  // Whether the body is the locked state right now (an encrypted message this device holds no unlocked keys for), for the subscription below.
448
456
  const lockedRef = useRef(false);
449
457
  lockedRef.current = security?.decryptError !== undefined && !getUnlockedKeys(message.mailboxUid);
@@ -519,11 +527,11 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
519
527
  // Only ever invoked from the "Trust this signer" dialog, which only opens for an unverified-signer result carrying
520
528
  // `signerCertificate`. The pin goes on the contact in the mailbox the message belongs to; the evaluation is then
521
529
  // re-run against a fresh pinned-signer lookup, so the badge turns verified.
522
- async function handleTrustSigner(address, certificate) {
530
+ async function handleTrustSigner(address, certificate, type) {
523
531
  setTrusting(true);
524
532
  setTrustError(null);
525
533
  try {
526
- await trustSigner(message.mailboxUid, { address, certificate }, client);
534
+ await trustSigner(message.mailboxUid, { address, certificate, ...(type ? { type } : {}) }, client);
527
535
  clearPinnedSignerCache();
528
536
  setTrustConfirmOpen(false);
529
537
  setUnlockRefresh((n) => n + 1);
@@ -813,7 +821,11 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
813
821
  // A decrypted message whose signature failed still only has these (the server list is just `smime.p7m`); they're
814
822
  // listed with a warning. A signed-only failure never carries recovered attachments.
815
823
  const innerAttachments = security?.attachments !== undefined &&
816
- (verifiedOrSealed || security.state === "encrypted" || security.state === "encrypted_unverified_signer" || security.state === "signature_failed")
824
+ (verifiedOrSealed ||
825
+ security.zeroAccess ||
826
+ security.state === "encrypted" ||
827
+ security.state === "encrypted_unverified_signer" ||
828
+ security.state === "signature_failed")
817
829
  ? security.attachments
818
830
  : undefined;
819
831
  // Any state with a valid signature shows the address actually signed for (the protected From when the message
@@ -835,7 +847,10 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
835
847
  ? security.signerCertificate
836
848
  : undefined;
837
849
  const keyChanged = security && signerKeyChanged(security) ? security : undefined;
838
- const pinnedSignerKey = senderKeyState?.pinned[0];
850
+ // An OpenPGP signer's key is pinned as the contact's OpenPGP certificate, not a signing certificate.
851
+ const openPgpSigner = keyChanged?.signerKeyType === "openpgp";
852
+ const pinnedSignerKey = openPgpSigner ? senderKeyState?.openPgp?.[0] : senderKeyState?.pinned[0];
853
+ const pinnedSignerSince = openPgpSigner ? senderKeyState?.openPgpPinnedSince : senderKeyState?.pinnedSince;
839
854
  const recordedConflict = keyChanged && sameFingerprint(senderKeyState?.conflict?.observedKey.fingerprint, keyChanged.signerFingerprint)
840
855
  ? senderKeyState.conflict
841
856
  : undefined;
@@ -863,7 +878,17 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
863
878
  // Only a message the server can read and that carries a calendar file is worth asking about an invitation. An encrypted message is out: the
864
879
  // server holds only its ciphertext (the calendar file is inside it), so there is nothing for it to find. Drafts and Outbox hold messages
865
880
  // that are being written or sent, not received invitations.
866
- const mayHoldInvite = !message.encrypted && !inOutbox && message.folderUid !== draftsFolderUid && attachments.some(isCalendarAttachment);
881
+ // A zero-access message's calendar file is inside the sealed copy: the server reads the invitation from the one decrypted here.
882
+ const sealedCalendar = security?.zeroAccess ? security.calendar : undefined;
883
+ // Registered while rendering, not in an effect: the invitation lookup below must find it the moment it is enabled. Writing the same
884
+ // file for the same message again is a no-op.
885
+ if (sealedCalendar !== undefined) {
886
+ rememberSealedInvite(message.uid, sealedCalendar, message.mailboxUid);
887
+ }
888
+ const mayHoldInvite = !message.encrypted &&
889
+ !inOutbox &&
890
+ message.folderUid !== draftsFolderUid &&
891
+ (attachments.some(isCalendarAttachment) || sealedCalendar !== undefined);
867
892
  // The calendar file of a message whose invitation card is drawn is the card, not a chip in the attachment list (a nameless "attachment" to click):
868
893
  // it is listed again if the card could not be drawn (no invitation the server can read, or the lookup failed), so the file is never out of reach.
869
894
  const { invite: shownInvite } = useMessageInvite(message.uid, mayHoldInvite);
@@ -934,7 +959,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
934
959
  /** The rules page's "New mail filter", started from this message: its sender and (for a message the server can read) its subject. */
935
960
  function handleCreateRule() {
936
961
  const params = new URLSearchParams({ mailboxUid: message.mailboxUid, from: message.from.address });
937
- const subject = message.encrypted ? "" : withoutSubjectPrefixes(message.subject);
962
+ const subject = message.encrypted || message.zeroAccess ? "" : withoutSubjectPrefixes(message.subject);
938
963
  if (subject) {
939
964
  params.set("subject", subject);
940
965
  }
@@ -959,7 +984,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
959
984
  createRule: handleCreateRule,
960
985
  };
961
986
  // An encrypted message can be printed once it has been opened here: while it is still opening, or when it can't be, there is nothing to print.
962
- const printReason = !message.encrypted
987
+ const printReason = !message.encrypted && !message.zeroAccess
963
988
  ? undefined
964
989
  : security === null
965
990
  ? "Still opening this message"
@@ -970,13 +995,13 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
970
995
  : "This message can't be read, so it can't be printed";
971
996
  // In Junk Email the card's button is the way back (Not junk), which only needs the right to change the message.
972
997
  const reportReason = !writable ? "View-only mailbox" : undefined;
973
- const card = (_jsxs(CardShell, { unread: cardUnread, children: [_jsxs("div", { className: ["flex flex-wrap items-start gap-x-3 gap-y-1 px-4 pt-3 pb-2", cardUnread ? "bg-primary/[0.07]" : ""].join(" "), children: [_jsx(SenderAvatar, { from: senderRecipient }), _jsxs("div", { className: "flex-1 min-w-[12rem]", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1", children: [threadHeader ? (_jsxs("h2", { className: "relative min-w-0 flex-1", style: BODY_FONT_STYLE, children: [_jsx("button", { type: "button", ref: threadHeader.buttonRef, onClick: threadHeader.onToggle, "aria-expanded": true, "aria-controls": threadHeader.bodyId, "aria-label": `${cardUnread ? "Unread. " : ""}${senderLabel}`, className: ["absolute inset-0 w-full rounded-sm", ROW_FOCUS_CLASS].join(" ") }), _jsx("span", { className: "pointer-events-none relative flex text-sm", children: _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, label: `Contact card for ${senderLabel}`, className: "pointer-events-auto min-w-0 max-w-full", children: _jsx(MailAddress, { recipient: senderRecipient, className: senderClass(cardUnread) }) }) })] })) : (_jsxs("p", { className: "text-sm break-words min-w-0", children: ["From", " ", _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, className: "font-semibold text-text", children: senderLabel })] })), security && _jsx(SecurityIndicator, { security: security })] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsx(RecipientLine, { label: "To", recipients: message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc"), context: cardContext }), _jsx(RecipientLine, { label: "Cc", recipients: message.recipients.filter((r) => r.type === "cc"), context: cardContext }), _jsx(RecipientLine, { label: "Bcc", recipients: message.recipients.filter((r) => r.type === "bcc"), context: cardContext })] }), _jsxs("div", { className: "ml-auto flex flex-wrap items-center justify-end gap-1", children: [_jsx("time", { dateTime: message.receivedDate, className: ["text-xs mr-1", dateClass(cardUnread)].join(" "), children: new Date(message.receivedDate).toLocaleString() }), adaptable && (_jsx("button", { type: "button", "aria-label": "View original", "aria-pressed": viewOriginal, title: viewOriginal ? "Follow the theme" : "View original", onClick: () => setViewOriginal(!viewOriginal), className: [
998
+ const card = (_jsxs(CardShell, { unread: cardUnread, children: [_jsxs("div", { className: ["flex flex-wrap items-start gap-x-3 gap-y-1 px-4 pt-3 pb-2", cardUnread ? "bg-primary/[0.07]" : ""].join(" "), children: [_jsx(SenderAvatar, { from: senderRecipient }), _jsxs("div", { className: "flex-1 min-w-[12rem]", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1", children: [threadHeader ? (_jsxs("h2", { className: "relative min-w-0 flex-1", style: BODY_FONT_STYLE, children: [_jsx("button", { type: "button", ref: threadHeader.buttonRef, onClick: threadHeader.onToggle, "aria-expanded": true, "aria-controls": threadHeader.bodyId, "aria-label": `${cardUnread ? "Unread. " : ""}${senderLabel}`, className: ["absolute inset-0 w-full rounded-sm", ROW_FOCUS_CLASS].join(" ") }), _jsx("span", { className: "pointer-events-none relative flex text-sm", children: _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, label: `Contact card for ${senderLabel}`, className: "pointer-events-auto min-w-0 max-w-full", children: _jsx(MailAddress, { recipient: senderRecipient, className: senderClass(cardUnread) }) }) })] })) : (_jsxs("p", { className: "text-sm break-words min-w-0", children: ["From", " ", _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, className: "font-semibold text-text", children: senderLabel })] })), security && _jsx(SecurityIndicator, { security: security, zeroAccess: !!message.zeroAccess })] }), security?.signerKeyUnknown && (_jsx("p", { role: "status", className: "mt-2 text-sm text-text-muted", children: "Signed with an OpenPGP key RapidMX doesn\u2019t have for this sender yet, so the signature couldn\u2019t be checked." })), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsx(RecipientLine, { label: "To", recipients: message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc"), context: cardContext }), _jsx(RecipientLine, { label: "Cc", recipients: message.recipients.filter((r) => r.type === "cc"), context: cardContext }), _jsx(RecipientLine, { label: "Bcc", recipients: message.recipients.filter((r) => r.type === "bcc"), context: cardContext })] }), _jsxs("div", { className: "ml-auto flex flex-wrap items-center justify-end gap-1", children: [_jsx("time", { dateTime: message.receivedDate, className: ["text-xs mr-1", dateClass(cardUnread)].join(" "), children: new Date(message.receivedDate).toLocaleString() }), adaptable && (_jsx("button", { type: "button", "aria-label": "View original", "aria-pressed": viewOriginal, title: viewOriginal ? "Follow the theme" : "View original", onClick: () => setViewOriginal(!viewOriginal), className: [
974
999
  "inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt",
975
1000
  viewOriginal ? "bg-primary/10" : "",
976
1001
  ].join(" "), children: viewOriginal ? _jsx(HiOutlineMoon, { size: 16, "aria-hidden": "true" }) : _jsx(HiOutlineSun, { size: 16, "aria-hidden": "true" }) })), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true" }), label: "Reply", shortcut: keyboard ? SHORTCUTS.mail.reply : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("reply") }), _jsx(IconAction, { icon: _jsxs("span", { className: "inline-flex items-center", "aria-hidden": "true", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16 }), _jsx(HiOutlineArrowUturnLeft, { size: 16, className: "-ml-2.5" })] }), label: "Reply All", shortcut: keyboard ? SHORTCUTS.mail.replyAll : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("replyAll") }), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnRight, { size: 16, "aria-hidden": "true" }), label: "Forward", shortcut: keyboard ? SHORTCUTS.mail.forward : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("forward") }), archivable && (_jsx(IconAction, { icon: _jsx(HiOutlineArchiveBox, { size: 16, "aria-hidden": "true" }), label: "Archive", shortcut: keyboard ? SHORTCUTS.mail.archive : undefined, busy: archiving, disabled: archiving, onClick: handleArchive })), movable && (_jsx(IconAction, { icon: _jsx(HiOutlineFolderArrowDown, { size: 16, "aria-hidden": "true" }), label: "Move to", shortcut: keyboard ? SHORTCUTS.mail.move : undefined, onClick: () => setMovePrompt(true) })), reportable && (_jsx(IconAction, { icon: inJunk ? _jsx(HiOutlineInboxArrowDown, { size: 16, "aria-hidden": "true" }) : _jsx(HiOutlineNoSymbol, { size: 16, "aria-hidden": "true" }), label: inJunk ? "Not junk" : "Report junk", busy: messageActions.busy, disabled: messageActions.busy || reportReason !== undefined, reason: reportReason, onClick: () => void (inJunk ? messageActions.notJunk() : messageActions.reportJunk()) })), labels && labels.length > 0 && (_jsx(LabelMenuButton, { "aria-label": "Labels", label: "Labels", className: "border border-border py-1.5", labels: labels, mailboxUid: message.mailboxUid, onLabelCreated: onLabelCreated, applied: currentLabelsMessage.labelUids ?? [], onCommit: (labelUids) => void handleApplyLabels(labelUids), busy: savingLabels, note: "Ticked labels are applied to this message and unticked ones removed.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } })), archivable && (_jsx(MessageMoreMenu, { actions: menuActions, senderAddress: message.from.address, read: currentLabelsMessage.flags.read === true, flagged: currentLabelsMessage.flags.flagged === true, writable: writable, busy: messageActions.busy, inJunk: inJunk, inDeletedItems: inDeletedItems, sent: !!isSentItems, ownSender: readerOwnAddresses.includes(message.from.address.toLowerCase()), canTrustSender: writable, printReason: printReason, composing: preparingCompose, triggerClassName: ICON_BUTTON_CLASS }))] })] }), (answered || forwarded) && (_jsx("p", { className: "px-4 py-1.5 text-xs text-text-muted bg-surface-alt border-y border-border", children: answered && forwarded ? "You replied to and forwarded this message." : answered ? "You replied to this message." : "You forwarded this message." })), _jsxs("div", { id: threadHeader?.bodyId, className: "px-4 pb-3 flex flex-col gap-2", children: [inThread && (subjectDiffers || !sameSubject(shownSubject, threadSubject)) && (_jsx("h3", { className: "text-sm font-semibold break-words", children: shownSubject })), _jsx("div", { className: "flex flex-wrap items-center gap-2 empty:hidden", children: sendingStatus }), inOutbox && message.scheduledSendError && _jsxs(Alert, { children: ["This message wasn\u2019t sent: ", message.scheduledSendError] }), cancelError && _jsx(Alert, { children: cancelError }), keyChangeNotice && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: keyChangeNotice })), security && UNVERIFIED_SIGNER_STATES.has(security.state) && (_jsxs("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: ["Signed, but the signer isn\u2019t a trusted contact key, so the sender isn\u2019t verified.", security.signerEmails && security.signerEmails.length > 0 && _jsxs(_Fragment, { children: [" Certificate for ", security.signerEmails.join(", "), "."] }), security.signerFingerprint && (_jsxs(_Fragment, { children: [" ", "Fingerprint ", _jsx("span", { className: "font-mono text-xs break-all", children: security.signerFingerprint }), "."] }))] })), pendingConflict && (_jsxs("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["This sender has a signing key change waiting for your review, so this signer can\u2019t be trusted from here.", " ", _jsx("a", { href: `/contacts/${encodeURIComponent(senderKeyState.contactUid)}`, className: "font-medium text-primary-dark hover:underline", children: "Review it in Contacts" })] })), trustableCertificate && (_jsx("div", { children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => {
977
1002
  setTrustError(null);
978
1003
  setTrustConfirmOpen(true);
979
- }, children: "Trust this signer" }) })), subjectDiffers && (_jsxs("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: ["The subject shown above is the one the sender signed. It differs from the subject this message was delivered with (\u201C", message.subject, "\u201D), which may have been changed on the way, e.g. by a mailing list."] })), security?.headerTamperDetected && (_jsx(Alert, { children: "This message's visible From/To/Cc/Date/Subject don't match what the sender actually signed or encrypted \u2014 an intermediary may have altered them after sending. Treat the fields shown above with caution." })), security?.state === "verified_at_first_open" && (_jsx("p", { role: "status", className: `py-2 px-3 rounded-sm text-sm text-text ${security.laterCompromised ? "bg-warning/15" : "bg-surface-alt"}`, children: verifiedAtFirstOpenMessage(security) })), keyChanged && (_jsxs("section", { "aria-label": "Signing key changed", className: "py-3 px-3 rounded-sm text-sm bg-warning/15 text-text flex flex-col gap-2", children: [_jsx("h2", { className: "font-semibold", children: "This sender\u2019s signing key changed" }), _jsxs("p", { children: ["The signature on this message is valid and its certificate names ", senderAddress, ", but it was made with a different key than the one you trust for this sender", keyChanged.state === "verified_at_first_open" ? "." : _jsx(_Fragment, { children: ", so it isn\u2019t verified." })] }), _jsx(KeyChangeReview, { mailboxUid: message.mailboxUid, address: senderAddress, useType: "sign", ownerName: "the sender", current: pinnedSignerKey && { fingerprint: pinnedSignerKey.fingerprint, since: senderKeyState.pinnedSince }, proposed: {
1004
+ }, children: "Trust this signer" }) })), subjectDiffers && (_jsxs("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: ["The subject shown above is the one the sender signed. It differs from the subject this message was delivered with (\u201C", message.subject, "\u201D), which may have been changed on the way, e.g. by a mailing list."] })), security?.headerTamperDetected && (_jsx(Alert, { children: "This message's visible From/To/Cc/Date/Subject don't match what the sender actually signed or encrypted \u2014 an intermediary may have altered them after sending. Treat the fields shown above with caution." })), security?.state === "verified_at_first_open" && (_jsx("p", { role: "status", className: `py-2 px-3 rounded-sm text-sm text-text ${security.laterCompromised ? "bg-warning/15" : "bg-surface-alt"}`, children: verifiedAtFirstOpenMessage(security) })), keyChanged && (_jsxs("section", { "aria-label": "Signing key changed", className: "py-3 px-3 rounded-sm text-sm bg-warning/15 text-text flex flex-col gap-2", children: [_jsx("h2", { className: "font-semibold", children: "This sender\u2019s signing key changed" }), _jsxs("p", { children: ["The signature on this message is valid and its certificate names ", senderAddress, ", but it was made with a different key than the one you trust for this sender", keyChanged.state === "verified_at_first_open" ? "." : _jsx(_Fragment, { children: ", so it isn\u2019t verified." })] }), _jsx(KeyChangeReview, { mailboxUid: message.mailboxUid, address: senderAddress, useType: openPgpSigner ? "encrypt" : "sign", ...(openPgpSigner ? { keyType: "openpgp" } : {}), ownerName: "the sender", current: pinnedSignerKey && { fingerprint: pinnedSignerKey.fingerprint, since: pinnedSignerSince }, proposed: {
980
1005
  fingerprint: keyChanged.signerFingerprint,
981
1006
  emails: keyChanged.signerEmails,
982
1007
  observedAt: recordedConflict?.observedAt,
@@ -990,6 +1015,6 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
990
1015
  .filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
991
1016
  .map((type) => (_jsxs("div", { className: "flex flex-wrap items-center gap-2 py-2 px-3 rounded-sm bg-surface-alt text-sm", children: [_jsxs("span", { children: [senderLabel, " requested a ", type, " receipt for this message."] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: receiptBusy === type, disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "approve"), children: "Send receipt" }), _jsx(Button, { type: "button", variant: "text", disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "decline"), children: "Decline" })] }, type))), receiptError && _jsx(Alert, { children: receiptError }), mayHoldInvite && _jsx(InviteCard, { messageUid: message.uid, headingLevel: inThread ? 3 : 2 }), security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: "These attachments come from a message whose signature couldn\u2019t be verified. Open them only if you trust the sender." })), innerAttachments
992
1017
  ? innerAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: innerAttachments.map((attachment, index) => (_jsx("li", { children: isVCardAttachment({ filename: attachment.filename, mimeType: attachment.contentType }) ? (_jsx(VCardAttachmentChip, { label: attachment.filename ?? `Unnamed ${attachment.contentType} attachment`, message: message, loadText: async () => new TextDecoder().decode(attachment.decode() ?? new Uint8Array(0)), onDownload: () => downloadMimeAttachment(attachment) })) : (_jsx("button", { type: "button", onClick: () => downloadMimeAttachment(attachment), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: attachment.filename ?? `Unnamed ${attachment.contentType} attachment` })) }, `${index}:${attachment.filename ?? ""}`))) }))
993
- : listedAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: listedAttachments.map((attachment) => (_jsx("li", { children: isVCardAttachment(attachment) ? (_jsx(VCardAttachmentChip, { label: `${attachment.filename} (${formatBytes(attachment.sizeBytes)})`, message: message, loadText: () => fetchVCardText(attachment.uid, client), onDownload: () => saveAttachment(attachment) })) : client ? (_jsxs("button", { type: "button", onClick: () => saveAttachment(attachment), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] })) : (_jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] })) }, attachment.uid))) })), _jsx("div", { ref: bodyRegionRef, tabIndex: -1, className: "outline-none", children: message.encrypted && security === null ? (_jsx(BodySkeleton, {})) : security?.decryptError !== undefined ? (_jsx(EncryptedBody, { locked: lockedRef.current, reason: security.decryptError, unlocking: unlocking, onUnlock: () => void handleUnlockToView() })) : (_jsx(MessageBody, { messageUid: message.uid, messageVersion: message.version, title: bodyTitle, content: bodyContent, attachments: attachments, inlineParts: innerAttachments, original: viewOriginal, onAdaptable: setAdaptable })) })] }), showFooter && (_jsxs("div", { className: "flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply"), children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply all to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll"), children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Forward this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward"), children: "Forward" })] }))] }));
994
- return (_jsxs("div", { className: inThread ? "min-w-0" : "flex-1 min-w-0 min-h-0 flex flex-col h-full", children: [inThread ? (card) : (_jsxs(_Fragment, { children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block px-4 pt-3", children: "\u2190 Back to messages" })), _jsx(SubjectCard, { subject: shownSubject }), _jsx("div", { className: "flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3", children: card })] })), _jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: message.mailboxUid, folders: folders ?? [], currentFolderUid: message.folderUid, onMove: handleMove, onFolderCreated: onFolderCreated }), messageActions.dialog, _jsx(MessageSourceDialog, { open: sourceView !== null, onClose: closeSource, mode: sourceView?.mode ?? "source", source: sourceView?.source ?? { status: "loading" }, encrypted: !!message.encrypted }), _jsxs(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Recall this message?", children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: "This asks every original recipient's mail system to delete their copy, but only if it's still unread there \u2014 there's no way to guarantee it, and no confirmation once it either succeeds or fails. Recipients who already read the message will keep it." }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", loading: recalling, disabled: recalling, onClick: handleRecall, className: "!w-auto", children: "Recall message" }), _jsx(Button, { type: "button", variant: "secondary", disabled: recalling, onClick: () => setConfirming(false), className: "!w-auto", children: "Cancel" })] })] }), _jsx(Modal, { open: trustConfirmOpen && !!trustableCertificate, onClose: () => !trusting && setTrustConfirmOpen(false), title: "Trust this signer?", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsxs("p", { children: ["Mail from ", _jsx("span", { className: "font-medium", children: senderAddress }), " signed with this certificate will show as verified."] }), _jsxs("dl", { className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate issued to" }), _jsx("dd", { children: security?.signerEmails?.length ? security.signerEmails.join(", ") : "No email address" }), _jsx("dt", { className: "text-xs text-text-muted", children: "Fingerprint" }), _jsx("dd", { className: "font-mono text-xs break-all", children: security?.signerFingerprint ? formatFingerprint(security.signerFingerprint) : "Unknown" })] }), _jsx("p", { className: "text-text-muted", children: "Anyone can create a certificate naming any address. Before trusting it, confirm this fingerprint with the sender through another channel, such as a phone call." }), trustError && _jsx(Alert, { children: trustError }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: trusting, disabled: trusting, onClick: () => handleTrustSigner(senderAddress, trustableCertificate), children: "Trust" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => setTrustConfirmOpen(false), children: "Cancel" })] })] }) })] }));
1018
+ : listedAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: listedAttachments.map((attachment) => (_jsx("li", { children: isVCardAttachment(attachment) ? (_jsx(VCardAttachmentChip, { label: `${attachment.filename} (${formatBytes(attachment.sizeBytes)})`, message: message, loadText: () => fetchVCardText(attachment.uid, client), onDownload: () => saveAttachment(attachment) })) : client ? (_jsxs("button", { type: "button", onClick: () => saveAttachment(attachment), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] })) : (_jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] })) }, attachment.uid))) })), _jsx("div", { ref: bodyRegionRef, tabIndex: -1, className: "outline-none", children: (message.encrypted || message.zeroAccess) && security === null ? (_jsx(BodySkeleton, {})) : security?.decryptError !== undefined ? (_jsx(EncryptedBody, { locked: lockedRef.current, reason: security.decryptError, unlocking: unlocking, onUnlock: () => void handleUnlockToView() })) : (_jsx(MessageBody, { messageUid: message.uid, messageVersion: message.version, title: bodyTitle, content: bodyContent, attachments: attachments, inlineParts: innerAttachments, original: viewOriginal, onAdaptable: setAdaptable })) })] }), showFooter && (_jsxs("div", { className: "flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply"), children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply all to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll"), children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Forward this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward"), children: "Forward" })] }))] }));
1019
+ return (_jsxs("div", { className: inThread ? "min-w-0" : "flex-1 min-w-0 min-h-0 flex flex-col h-full", children: [inThread ? (card) : (_jsxs(_Fragment, { children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block px-4 pt-3", children: "\u2190 Back to messages" })), _jsx(SubjectCard, { subject: shownSubject }), _jsx("div", { className: "flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3", children: card })] })), _jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: message.mailboxUid, folders: folders ?? [], currentFolderUid: message.folderUid, onMove: handleMove, onFolderCreated: onFolderCreated }), messageActions.dialog, _jsx(MessageSourceDialog, { open: sourceView !== null, onClose: closeSource, mode: sourceView?.mode ?? "source", source: sourceView?.source ?? { status: "loading" }, encrypted: !!message.encrypted || !!message.zeroAccess }), _jsxs(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Recall this message?", children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: "This asks every original recipient's mail system to delete their copy, but only if it's still unread there \u2014 there's no way to guarantee it, and no confirmation once it either succeeds or fails. Recipients who already read the message will keep it." }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", loading: recalling, disabled: recalling, onClick: handleRecall, className: "!w-auto", children: "Recall message" }), _jsx(Button, { type: "button", variant: "secondary", disabled: recalling, onClick: () => setConfirming(false), className: "!w-auto", children: "Cancel" })] })] }), _jsx(Modal, { open: trustConfirmOpen && !!trustableCertificate, onClose: () => !trusting && setTrustConfirmOpen(false), title: "Trust this signer?", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsxs("p", { children: ["Mail from ", _jsx("span", { className: "font-medium", children: senderAddress }), " signed with this certificate will show as verified."] }), _jsxs("dl", { className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate issued to" }), _jsx("dd", { children: security?.signerEmails?.length ? security.signerEmails.join(", ") : "No email address" }), _jsx("dt", { className: "text-xs text-text-muted", children: "Fingerprint" }), _jsx("dd", { className: "font-mono text-xs break-all", children: security?.signerFingerprint ? formatFingerprint(security.signerFingerprint) : "Unknown" })] }), _jsx("p", { className: "text-text-muted", children: "Anyone can create a certificate naming any address. Before trusting it, confirm this fingerprint with the sender through another channel, such as a phone call." }), trustError && _jsx(Alert, { children: trustError }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: trusting, disabled: trusting, onClick: () => handleTrustSigner(senderAddress, trustableCertificate, security?.signerKeyType), children: "Trust" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => setTrustConfirmOpen(false), children: "Cancel" })] })] }) })] }));
995
1020
  }
@@ -21,7 +21,7 @@ import { notify } from "../../../notifications/store.js";
21
21
  import { isFileDrag, isInlineImage } from "./dropFiles.js";
22
22
  import { evaluateEncryptionRequirement } from "./encryptionRequirement.js";
23
23
  import { decideSend } from "../../../mail/outbox/sendDecision.js";
24
- import { sendDecisionInput, startSend } from "../../../mail/outbox/sendJob.js";
24
+ import { sendDecisionInput, senderCanUseOpenPgp, startSend } from "../../../mail/outbox/sendJob.js";
25
25
  import { useCompose } from "./ComposeContext.js";
26
26
  import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
27
27
  import { SHORTCUTS } from "../../../keyboard/keymap.js";
@@ -362,7 +362,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, inli
362
362
  return;
363
363
  }
364
364
  lookupFailuresRef.current.delete(key);
365
- const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, result.lookup);
365
+ const status = resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, recipient.address, result.lookup, senderCanUseOpenPgp(getUnlockedKeys(mailbox.uid)));
366
366
  setRecipientStatuses((prev) => ({ ...prev, [key]: status }));
367
367
  });
368
368
  }
@@ -6,7 +6,8 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
6
6
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
7
7
  import { createPortal } from "react-dom";
8
8
  import { HiOutlineXMark } from "react-icons/hi2";
9
- import { fetchRecipientSuggestions, RECIPIENT_SUGGESTION_MIN_QUERY_LENGTH, } from "../../../../../lib/mail/directoryApi.js";
9
+ import { fetchSuggestionsWithProtectedContacts } from "./protectedContactSuggestions.js";
10
+ import { RECIPIENT_SUGGESTION_MIN_QUERY_LENGTH, } from "../../../../../lib/mail/directoryApi.js";
10
11
  import { formatRecipient, isValidRecipientAddress, parseRecipient, splitRecipientList, splitTypedRecipients } from "./recipients.js";
11
12
  /** How long typing must pause before suggestions are requested. */
12
13
  export const RECIPIENT_SUGGESTION_DEBOUNCE_MS = 150;
@@ -42,7 +43,7 @@ function dropdownPosition(anchor) {
42
43
  * least two characters, and a newer query aborts an older request. Arrow keys move through them, Enter or Tab picks
43
44
  * one (as `Name <address>`), Escape closes them, and Backspace in an empty input removes the last chip.
44
45
  */
45
- export default function RecipientInput({ id, label, value, onChange, onBlur, onCommit, mailboxUid, fetchSuggestions = fetchRecipientSuggestions, debounceMs = RECIPIENT_SUGGESTION_DEBOUNCE_MS, disabled, autoFocus, onEdit, initialPending = "", hideChip, placeholder, ariaLabel, listLabel, className, dropdownZIndex, }) {
46
+ export default function RecipientInput({ id, label, value, onChange, onBlur, onCommit, mailboxUid, fetchSuggestions = fetchSuggestionsWithProtectedContacts, debounceMs = RECIPIENT_SUGGESTION_DEBOUNCE_MS, disabled, autoFocus, onEdit, initialPending = "", hideChip, placeholder, ariaLabel, listLabel, className, dropdownZIndex, }) {
46
47
  const [pending, setPending] = useState(initialPending);
47
48
  // With the query they were loaded for: a list for earlier text is not offered (Enter or Tab would put somebody else in place of what was typed).
48
49
  const [suggestionList, setSuggestionList] = useState({ query: "", items: [] });
@@ -5,6 +5,8 @@ import type { ResumeCompose } from "../../../mail/outbox/composeBridge.js";
5
5
  * What the compose window needs to continue `draft` - a message in the Drafts folder the reader opened rather than one a window of this tab
6
6
  * saved - read back from the server: its recipients and subject from the record, its body from the server's copy (with its embedded pictures)
7
7
  * and its attachments. `undefined` for a draft that cannot be edited here: an encrypted one holds only its ciphertext, which is never opened.
8
+ * A draft saved in a zero-access mailbox is stored sealed to the mailbox's zero-access key: it is decrypted here with the unlocked keys (its
9
+ * subject comes from the decrypted message too, the record holding only `[...]`), and is `undefined` - left to read - while they're locked.
8
10
  * Never rejects for a body that could not be loaded - the draft then opens with the preview, as a reply's quote does.
9
11
  */
10
12
  export declare function resumeFromDraft(draft: Message, client?: ApiClient): Promise<ResumeCompose | undefined>;
@@ -2,7 +2,9 @@
2
2
  // Copyright (C) 2026 Jean-Philippe Steinmetz
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
- import { listAttachments } from "../../../../../lib/mail/mailApi.js";
5
+ import { getUnlockedKeys } from "../../../../../lib/crypto/keySession.js";
6
+ import { evaluateMessageSecurity } from "../../../../../lib/crypto/messageSecurity.js";
7
+ import { getMessageRawContent, listAttachments } from "../../../../../lib/mail/mailApi.js";
6
8
  import { formatMailAddress } from "../../../../../lib/mail/mailAddress.js";
7
9
  import { loadOriginalMessage } from "./quotedBody.js";
8
10
  /** The comma-separated address text a recipient field holds, for the recipients of one type. */
@@ -24,13 +26,22 @@ function textToHtml(text) {
24
26
  * What the compose window needs to continue `draft` - a message in the Drafts folder the reader opened rather than one a window of this tab
25
27
  * saved - read back from the server: its recipients and subject from the record, its body from the server's copy (with its embedded pictures)
26
28
  * and its attachments. `undefined` for a draft that cannot be edited here: an encrypted one holds only its ciphertext, which is never opened.
29
+ * A draft saved in a zero-access mailbox is stored sealed to the mailbox's zero-access key: it is decrypted here with the unlocked keys (its
30
+ * subject comes from the decrypted message too, the record holding only `[...]`), and is `undefined` - left to read - while they're locked.
27
31
  * Never rejects for a body that could not be loaded - the draft then opens with the preview, as a reply's quote does.
28
32
  */
29
33
  export async function resumeFromDraft(draft, client) {
30
34
  if (draft.encrypted) {
31
35
  return undefined;
32
36
  }
33
- const [original, attachments] = await Promise.all([loadOriginalMessage(draft, null), listAttachments(draft.folderUid, draft.uid, client)]);
37
+ let security = null;
38
+ if (draft.zeroAccess) {
39
+ security = await openSealedDraft(draft, client);
40
+ if (!security) {
41
+ return undefined;
42
+ }
43
+ }
44
+ const [original, attachments] = await Promise.all([loadOriginalMessage(draft, security), listAttachments(draft.folderUid, draft.uid, client)]);
34
45
  const html = original.body.html !== undefined ? original.body.html : original.body.text !== undefined ? textToHtml(original.body.text) : textToHtml(draft.bodyPreview ?? "");
35
46
  return {
36
47
  draft,
@@ -38,7 +49,7 @@ export async function resumeFromDraft(draft, client) {
38
49
  to: recipientText(draft.recipients, "to"),
39
50
  cc: recipientText(draft.recipients, "cc"),
40
51
  bcc: recipientText(draft.recipients, "bcc"),
41
- subject: draft.subject,
52
+ subject: security?.subject ?? draft.subject,
42
53
  html,
43
54
  attachments,
44
55
  requestReceipt: !!draft.requestReceipt,
@@ -46,3 +57,17 @@ export async function resumeFromDraft(draft, client) {
46
57
  encryptRequested: false,
47
58
  };
48
59
  }
60
+ /** `draft`'s sealed message decrypted with its mailbox's unlocked keys, or `null` when they're locked or it won't open with them. */
61
+ async function openSealedDraft(draft, client) {
62
+ const unlocked = getUnlockedKeys(draft.mailboxUid);
63
+ if (!unlocked) {
64
+ return null;
65
+ }
66
+ try {
67
+ const security = await evaluateMessageSecurity(await getMessageRawContent(draft.uid, client), unlocked);
68
+ return security.zeroAccess ? security : null;
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
@@ -0,0 +1,14 @@
1
+ import { type ContactSuggestionOptions, type RecipientSuggestion } from "../../../../../lib/mail/directoryApi.js";
2
+ import type { ApiClient } from "../../../../../lib/util/api.js";
3
+ /** How long a mailbox's protected contact names are kept: reading them is a crawl of the whole address book. */
4
+ export declare const PROTECTED_NAMES_TTL_MS: number;
5
+ /** How long suggestions wait, once the server's have arrived, for the protected contacts still being read. */
6
+ export declare const PROTECTED_NAMES_WAIT_MS = 250;
7
+ /** Forgets every mailbox's names (signing out, or the test seam). */
8
+ export declare function clearProtectedContactNames(): void;
9
+ /** Every address of the protected contacts of `mailboxUid` whose opened name contains `query` (case-insensitively). */
10
+ export declare function searchProtectedContactNames(mailboxUid: string, query: string, client?: ApiClient): Promise<RecipientSuggestion[]>;
11
+ /** `fetchRecipientSuggestions()` with the composing mailbox's protected contacts matched by name on the device, first - those read by the
12
+ * time the server's suggestions are in (plus `PROTECTED_NAMES_WAIT_MS`): a first search of a large address book shows the server's alone
13
+ * rather than waiting for the crawl, which goes on for the next keystroke. A failure to read them only leaves them out. */
14
+ export declare function fetchSuggestionsWithProtectedContacts(query: string, options?: ContactSuggestionOptions, client?: ApiClient): Promise<RecipientSuggestion[]>;
@@ -0,0 +1,96 @@
1
+ ///////////////////////////////////////////////////////////////////////////////
2
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
3
+ // SPDX-License-Identifier: MPL-2.0
4
+ ///////////////////////////////////////////////////////////////////////////////
5
+ /**
6
+ * Recipient suggestions that include contacts zero-access encryption protects. Their names are sealed (restapi `specs/search.md` §3 *What
7
+ * It Covers*), so the server's contact search finds them only by address; this matches their opened names on the device and puts them
8
+ * first. Names are opened only - never the rest of a contact - and kept for ten minutes, and only while the mailbox is unlocked: its lock
9
+ * (and signing out) forgets them, as does a search that finds it locked. Reading them never holds up the server's suggestions: what isn't read by the time those arrive (plus
10
+ * `PROTECTED_NAMES_WAIT_MS`) is left to the next keystroke.
11
+ */
12
+ import { listContacts, PINNED_FINGERPRINT_PAGE_SIZE } from "../../../../../lib/contacts/contactsApi.js";
13
+ import { getUnlockedKeys, subscribeKeySession } from "../../../../../lib/crypto/keySession.js";
14
+ import { fetchRecipientSuggestions, mergeRecipientSuggestions, RECIPIENT_SUGGESTION_DEFAULT_LIMIT, } from "../../../../../lib/mail/directoryApi.js";
15
+ import { listFolders } from "../../../../../lib/mail/mailApi.js";
16
+ import { isProtectedRecord, openSealedRecords } from "../../../../../lib/zeroAccess/sealedRecords.js";
17
+ import { onSigningOut } from "./composeFlushRegistry.js";
18
+ /** How long a mailbox's protected contact names are kept: reading them is a crawl of the whole address book. */
19
+ export const PROTECTED_NAMES_TTL_MS = 10 * 60000;
20
+ /** How long suggestions wait, once the server's have arrived, for the protected contacts still being read. */
21
+ export const PROTECTED_NAMES_WAIT_MS = 250;
22
+ /** At most this many contacts are read per contacts folder. */
23
+ const MAX_PAGES = 20;
24
+ const cache = new Map();
25
+ /** Forgets every mailbox's names (signing out, or the test seam). */
26
+ export function clearProtectedContactNames() {
27
+ cache.clear();
28
+ }
29
+ onSigningOut(clearProtectedContactNames);
30
+ let watchingKeys = false;
31
+ /** A mailbox's names go the moment it locks, so nothing opened outlives its key session. Subscribed with the first crawl, not on import. */
32
+ function forgetOnLock() {
33
+ if (watchingKeys) {
34
+ return;
35
+ }
36
+ watchingKeys = true;
37
+ subscribeKeySession(({ mailboxUid, state }) => {
38
+ if (state === "locked") {
39
+ cache.delete(mailboxUid);
40
+ }
41
+ });
42
+ }
43
+ /** `mailboxUid`'s protected contacts with their names opened. */
44
+ async function loadProtectedContacts(mailboxUid, client) {
45
+ const found = [];
46
+ // The folders side by side, each one's pages in turn.
47
+ await Promise.all((await listFolders(mailboxUid, client))
48
+ .filter((f) => f.type === "contacts")
49
+ .map(async (folder) => {
50
+ for (let page = 0; page < MAX_PAGES; page++) {
51
+ const batch = await listContacts(folder.uid, { limit: PINNED_FINGERPRINT_PAGE_SIZE, page }, client);
52
+ found.push(...batch.filter((contact) => isProtectedRecord(contact)));
53
+ if (batch.length < PINNED_FINGERPRINT_PAGE_SIZE) {
54
+ break;
55
+ }
56
+ }
57
+ }));
58
+ return openSealedRecords(found, ["displayName"]);
59
+ }
60
+ function protectedContacts(mailboxUid, client) {
61
+ let entry = cache.get(mailboxUid);
62
+ if (!entry || Date.now() - entry.loadedAt > PROTECTED_NAMES_TTL_MS) {
63
+ forgetOnLock();
64
+ const created = { loadedAt: Date.now(), contacts: loadProtectedContacts(mailboxUid, client) };
65
+ created.contacts.catch(() => cache.delete(mailboxUid));
66
+ cache.set(mailboxUid, created);
67
+ entry = created;
68
+ }
69
+ return entry.contacts;
70
+ }
71
+ /** Every address of the protected contacts of `mailboxUid` whose opened name contains `query` (case-insensitively). */
72
+ export async function searchProtectedContactNames(mailboxUid, query, client) {
73
+ const wanted = query.trim().toLowerCase();
74
+ if (wanted.length < 2) {
75
+ return [];
76
+ }
77
+ if (!getUnlockedKeys(mailboxUid)?.zeroAccessKeys?.length) {
78
+ // Locked: nothing opened is kept, and nothing could be opened now.
79
+ cache.delete(mailboxUid);
80
+ return [];
81
+ }
82
+ return (await protectedContacts(mailboxUid, client))
83
+ .filter((contact) => contact.displayName.toLowerCase().includes(wanted))
84
+ .flatMap((contact) => contact.emails.map((email) => ({ displayName: contact.displayName, address: email.address, kind: "contact" })));
85
+ }
86
+ /** `fetchRecipientSuggestions()` with the composing mailbox's protected contacts matched by name on the device, first - those read by the
87
+ * time the server's suggestions are in (plus `PROTECTED_NAMES_WAIT_MS`): a first search of a large address book shows the server's alone
88
+ * rather than waiting for the crawl, which goes on for the next keystroke. A failure to read them only leaves them out. */
89
+ export async function fetchSuggestionsWithProtectedContacts(query, options = {}, client) {
90
+ const local = options.mailboxUid ? searchProtectedContactNames(options.mailboxUid, query, client).catch(() => []) : Promise.resolve([]);
91
+ const server = await fetchRecipientSuggestions(query, options, client);
92
+ let timer;
93
+ const ready = await Promise.race([local, new Promise((resolve) => (timer = setTimeout(() => resolve([]), PROTECTED_NAMES_WAIT_MS)))]);
94
+ clearTimeout(timer);
95
+ return mergeRecipientSuggestions(ready, server, options.limit ?? RECIPIENT_SUGGESTION_DEFAULT_LIMIT);
96
+ }
@@ -18,7 +18,7 @@ export declare const QUOTE_CACHE_MS = 30000;
18
18
  /**
19
19
  * Starts fetching what a Reply/Forward of `message` will quote, without waiting for the click - called when the pointer or
20
20
  * keyboard reaches a Reply/Reply All/Forward button - so the request is usually already answered when it is clicked.
21
- * Nothing to fetch for an encrypted message (`loadOriginalMessage()` never asks the server for one's body).
21
+ * Nothing to fetch for an encrypted or zero-access message (`loadOriginalMessage()` never asks the server for one's body).
22
22
  */
23
23
  export declare function prefetchOriginalMessage(message: Message, client?: ApiClient): void;
24
24
  /** Forgets every remembered body (the sign-out and test seam). */
@@ -78,10 +78,10 @@ function withTimeout(promise, ms) {
78
78
  /**
79
79
  * Starts fetching what a Reply/Forward of `message` will quote, without waiting for the click - called when the pointer or
80
80
  * keyboard reaches a Reply/Reply All/Forward button - so the request is usually already answered when it is clicked.
81
- * Nothing to fetch for an encrypted message (`loadOriginalMessage()` never asks the server for one's body).
81
+ * Nothing to fetch for an encrypted or zero-access message (`loadOriginalMessage()` never asks the server for one's body).
82
82
  */
83
83
  export function prefetchOriginalMessage(message, client) {
84
- if (!message.encrypted) {
84
+ if (!message.encrypted && !message.zeroAccess) {
85
85
  void fetchContentHtml(message.uid, client);
86
86
  }
87
87
  }
@@ -173,7 +173,8 @@ export async function loadOriginalMessage(message, security, options = {}, clien
173
173
  ? [...headerRecipients(protectedHeaders.to, "to"), ...headerRecipients(protectedHeaders.cc, "cc")]
174
174
  : [];
175
175
  const result = { body: recovered ?? {}, ...(protectedRecipients.length > 0 ? { recipients: protectedRecipients } : {}) };
176
- if (message.encrypted) {
176
+ // The server holds only ciphertext of an encrypted or zero-access message: its body is what `security` decrypted, or nothing.
177
+ if (message.encrypted || message.zeroAccess) {
177
178
  return result;
178
179
  }
179
180
  let needsRaw = !!options.recipients && result.recipients === undefined;