@rapidmx/web-client 0.36.0 → 0.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/apps/shared/calendar/calendarReminders.ts +35 -0
  2. package/apps/shared/calendar/sealedEvents.ts +42 -9
  3. package/apps/shared/calendar/useCalendarReminders.ts +28 -9
  4. package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
  5. package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +29 -1
  6. package/apps/shared/components/calendar/EventDetails.tsx +13 -2
  7. package/apps/shared/components/calendar/EventEditor.tsx +124 -17
  8. package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
  9. package/apps/shared/components/calendar/EventModal.tsx +19 -2
  10. package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
  11. package/apps/shared/components/calendar/eventForm.ts +2 -0
  12. package/apps/shared/components/contacts/ContactCard.tsx +13 -1
  13. package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
  14. package/apps/shared/components/contacts/ContactForm.tsx +56 -7
  15. package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
  16. package/apps/shared/components/contacts/contactCardData.ts +6 -3
  17. package/apps/shared/components/contacts/contactKeys.ts +3 -3
  18. package/apps/shared/components/layout/AppShell.tsx +34 -5
  19. package/apps/shared/components/layout/KeyEnrollmentGate.tsx +11 -4
  20. package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +37 -6
  21. package/apps/shared/components/mail/InviteCard.tsx +11 -1
  22. package/apps/shared/components/mail/MessageDetailPane.tsx +25 -14
  23. package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
  24. package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
  25. package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
  26. package/apps/shared/components/mail/invite/inviteStore.ts +11 -1
  27. package/apps/shared/components/mail/pinnedSigners.ts +14 -0
  28. package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
  29. package/apps/shared/components/settings/KeyExportSetting.tsx +4 -1
  30. package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
  31. package/apps/shared/components/settings/ZeroAccessSetting.tsx +13 -2
  32. package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
  33. package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
  34. package/apps/shared/mail/outbox/sendJob.ts +349 -50
  35. package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
  36. package/apps/shared/search/LocalIndexLifecycle.tsx +62 -33
  37. package/apps/shared/search/localIndexBuilder.ts +35 -24
  38. package/apps/shared/search/localIndexRpcClient.ts +91 -11
  39. package/apps/shared/search/localIndexSchema.ts +18 -2
  40. package/apps/shared/search/localIndexTransport.ts +6 -3
  41. package/apps/shared/search/localIndexWorker.ts +35 -18
  42. package/apps/shared/search/searchTier2.ts +5 -0
  43. package/apps/shared/search/zeroAccessIndexSync.ts +232 -40
  44. package/apps/shared/search/zeroAccessSyncState.ts +70 -0
  45. package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
  46. package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
  47. package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
  48. package/apps/www/_shell.tsx +2 -0
  49. package/apps/www/calendar/index.tsx +49 -5
  50. package/apps/www/contacts/index.tsx +67 -11
  51. package/apps/www/index.tsx +10 -1
  52. package/apps/www/settings/encryption/index.tsx +16 -1
  53. package/apps/www/settings/privacy/index.tsx +87 -38
  54. package/apps/www/tasks/index.tsx +24 -6
  55. package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
  56. package/dist/apps/shared/calendar/calendarReminders.js +26 -0
  57. package/dist/apps/shared/calendar/sealedEvents.d.ts +3 -2
  58. package/dist/apps/shared/calendar/sealedEvents.js +40 -9
  59. package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
  60. package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
  61. package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
  62. package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +9 -2
  63. package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
  64. package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
  65. package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
  66. package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
  67. package/dist/apps/shared/components/calendar/EventModal.js +15 -2
  68. package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
  69. package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
  70. package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
  71. package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
  72. package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
  73. package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
  74. package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
  75. package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
  76. package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
  77. package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
  78. package/dist/apps/shared/components/contacts/contactCardData.js +6 -3
  79. package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
  80. package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
  81. package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
  82. package/dist/apps/shared/components/layout/AppShell.js +17 -6
  83. package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +10 -4
  84. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +3 -1
  85. package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +30 -4
  86. package/dist/apps/shared/components/mail/InviteCard.js +11 -1
  87. package/dist/apps/shared/components/mail/MessageDetailPane.js +15 -11
  88. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
  89. package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
  90. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
  91. package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
  92. package/dist/apps/shared/components/mail/invite/inviteStore.js +11 -1
  93. package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
  94. package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
  95. package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
  96. package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
  97. package/dist/apps/shared/components/settings/KeyExportSetting.js +3 -1
  98. package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
  99. package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
  100. package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +2 -0
  101. package/dist/apps/shared/components/settings/ZeroAccessSetting.js +12 -3
  102. package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
  103. package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
  104. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  105. package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
  106. package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
  107. package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
  108. package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
  109. package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
  110. package/dist/apps/shared/search/LocalIndexLifecycle.js +55 -33
  111. package/dist/apps/shared/search/localIndexBuilder.d.ts +4 -1
  112. package/dist/apps/shared/search/localIndexBuilder.js +31 -20
  113. package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
  114. package/dist/apps/shared/search/localIndexRpcClient.js +126 -10
  115. package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
  116. package/dist/apps/shared/search/localIndexSchema.js +16 -2
  117. package/dist/apps/shared/search/localIndexTransport.d.ts +6 -3
  118. package/dist/apps/shared/search/localIndexWorker.d.ts +9 -0
  119. package/dist/apps/shared/search/localIndexWorker.js +22 -15
  120. package/dist/apps/shared/search/searchTier2.js +5 -0
  121. package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +37 -6
  122. package/dist/apps/shared/search/zeroAccessIndexSync.js +199 -37
  123. package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
  124. package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
  125. package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
  126. package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
  127. package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
  128. package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
  129. package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
  130. package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
  131. package/dist/apps/www/_shell.d.ts +1 -1
  132. package/dist/apps/www/_shell.js +2 -2
  133. package/dist/apps/www/calendar/index.js +47 -5
  134. package/dist/apps/www/contacts/index.js +64 -11
  135. package/dist/apps/www/index.js +10 -1
  136. package/dist/apps/www/settings/encryption/index.js +14 -4
  137. package/dist/apps/www/settings/privacy/index.js +46 -5
  138. package/dist/apps/www/tasks/index.js +21 -7
  139. package/dist/lib/admin/domainsApi.d.ts +1 -1
  140. package/dist/lib/calendar/calendarApi.d.ts +46 -6
  141. package/dist/lib/calendar/calendarApi.js +110 -17
  142. package/dist/lib/calendar/calendarMutations.d.ts +20 -7
  143. package/dist/lib/calendar/calendarMutations.js +73 -14
  144. package/dist/lib/calendar/inviteApi.d.ts +17 -3
  145. package/dist/lib/calendar/inviteApi.js +61 -4
  146. package/dist/lib/calendar/protectedEvents.d.ts +31 -0
  147. package/dist/lib/calendar/protectedEvents.js +113 -0
  148. package/dist/lib/calendar/recurrence.d.ts +2 -0
  149. package/dist/lib/calendar/recurrence.js +16 -0
  150. package/dist/lib/calendar/sealedInvites.d.ts +6 -8
  151. package/dist/lib/calendar/sealedInvites.js +43 -6
  152. package/dist/lib/contacts/contactsApi.d.ts +29 -3
  153. package/dist/lib/contacts/contactsApi.js +23 -7
  154. package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
  155. package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
  156. package/dist/lib/crypto/composeSecurity.d.ts +11 -3
  157. package/dist/lib/crypto/composeSecurity.js +13 -5
  158. package/dist/lib/crypto/keyExport.js +2 -2
  159. package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
  160. package/dist/lib/crypto/keyPairMatch.js +65 -0
  161. package/dist/lib/crypto/keySession.d.ts +19 -2
  162. package/dist/lib/crypto/keySession.js +89 -13
  163. package/dist/lib/crypto/keyvaultApi.d.ts +41 -5
  164. package/dist/lib/crypto/keyvaultApi.js +13 -3
  165. package/dist/lib/crypto/meetingKeys.d.ts +25 -0
  166. package/dist/lib/crypto/meetingKeys.js +87 -0
  167. package/dist/lib/crypto/messageSecurity.d.ts +26 -2
  168. package/dist/lib/crypto/messageSecurity.js +122 -10
  169. package/dist/lib/crypto/openPgpKey.d.ts +39 -0
  170. package/dist/lib/crypto/openPgpKey.js +89 -0
  171. package/dist/lib/crypto/openpgp.d.ts +47 -0
  172. package/dist/lib/crypto/openpgp.js +88 -0
  173. package/dist/lib/crypto/pgpMessage.d.ts +51 -0
  174. package/dist/lib/crypto/pgpMessage.js +271 -0
  175. package/dist/lib/crypto/sharedMailboxKeys.d.ts +15 -3
  176. package/dist/lib/crypto/sharedMailboxKeys.js +102 -29
  177. package/dist/lib/crypto/smimeMessage.d.ts +21 -0
  178. package/dist/lib/crypto/smimeMessage.js +11 -7
  179. package/dist/lib/crypto/zeroAccessKey.js +5 -0
  180. package/dist/lib/mail/mailApi.d.ts +33 -0
  181. package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
  182. package/dist/lib/mail/mailboxImportApi.js +15 -0
  183. package/dist/lib/search/zeroAccessIndexApi.d.ts +4 -0
  184. package/dist/lib/tasks/tasksApi.d.ts +11 -1
  185. package/dist/lib/tasks/tasksApi.js +10 -4
  186. package/dist/lib/util/clientPlatform.d.ts +14 -0
  187. package/dist/lib/util/clientPlatform.js +26 -0
  188. package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
  189. package/dist/lib/videoconf/protectedMeetings.js +181 -0
  190. package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
  191. package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
  192. package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
  193. package/dist/lib/zeroAccess/sealedMoves.js +63 -0
  194. package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
  195. package/dist/lib/zeroAccess/sealedRecords.js +136 -0
  196. package/lib/admin/domainsApi.ts +1 -1
  197. package/lib/calendar/calendarApi.ts +140 -17
  198. package/lib/calendar/calendarMutations.ts +79 -12
  199. package/lib/calendar/inviteApi.ts +75 -5
  200. package/lib/calendar/protectedEvents.ts +143 -0
  201. package/lib/calendar/recurrence.ts +17 -0
  202. package/lib/calendar/sealedInvites.ts +47 -7
  203. package/lib/contacts/contactsApi.ts +43 -17
  204. package/lib/contacts/useContactPhotoSrc.ts +103 -13
  205. package/lib/crypto/composeSecurity.ts +20 -5
  206. package/lib/crypto/keyExport.ts +2 -2
  207. package/lib/crypto/keyPairMatch.ts +69 -0
  208. package/lib/crypto/keySession.ts +102 -12
  209. package/lib/crypto/keyvaultApi.ts +49 -7
  210. package/lib/crypto/meetingKeys.ts +118 -0
  211. package/lib/crypto/messageSecurity.ts +174 -13
  212. package/lib/crypto/openPgpKey.ts +118 -0
  213. package/lib/crypto/openpgp.ts +115 -0
  214. package/lib/crypto/pgpMessage.ts +336 -0
  215. package/lib/crypto/sharedMailboxKeys.ts +106 -28
  216. package/lib/crypto/smimeMessage.ts +11 -7
  217. package/lib/crypto/zeroAccessKey.ts +5 -0
  218. package/lib/mail/mailApi.ts +27 -0
  219. package/lib/mail/mailboxImportApi.ts +24 -0
  220. package/lib/search/zeroAccessIndexApi.ts +4 -0
  221. package/lib/tasks/tasksApi.ts +19 -5
  222. package/lib/util/clientPlatform.ts +50 -0
  223. package/lib/videoconf/protectedMeetings.ts +221 -0
  224. package/lib/videoconf/videoMeetingsApi.ts +15 -4
  225. package/lib/zeroAccess/sealedMoves.ts +79 -0
  226. package/lib/zeroAccess/sealedRecords.ts +153 -0
  227. package/package.json +12 -3
@@ -17,7 +17,7 @@ import { getUnlockedKeys, subscribeKeySession } from "../../../../lib/crypto/key
17
17
  import { extractAddresses } from "../../../../lib/crypto/mime.js";
18
18
  import { SignerKeyConflictError, signingKeyFingerprints, trustSigner } from "../../../../lib/crypto/keyvaultApi.js";
19
19
  import { isLikelyMailingList } from "../../../../lib/crypto/composeSecurity.js";
20
- import { clearPinnedSignerCache, getPinnedSignerFingerprints, getSignerKeyState } from "./pinnedSigners.js";
20
+ import { clearPinnedSignerCache, getPinnedSigners, getSignerKeyState } from "./pinnedSigners.js";
21
21
  import { currentVerificationSeal, getVaultGeneration, sendVerificationSeal } from "./verificationSeals.js";
22
22
  import { getMyMailboxAccess } from "../../../../lib/mail/mailboxAccessApi.js";
23
23
  import { useApiClient } from "../../../../lib/util/apiClientContext.js";
@@ -369,7 +369,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
369
369
  // mailbox's own signing keys when it sent the message itself. A failed lookup means no pins, which can only
370
370
  // ever make a signature "signer not verified", never verified.
371
371
  const senderAddress = message.from.address;
372
- 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: [] }));
373
373
  // Verification seals (see `verificationSeals.ts`) need unlocked keys and the vault's current master key
374
374
  // generation; without either the message is evaluated exactly as before, with no seal. The sender's key records
375
375
  // are loaded alongside, so a seal can say when the signer key was later revoked as compromised.
@@ -392,7 +392,8 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
392
392
  const ownPins = signingKeyFingerprints(ownKeys);
393
393
  const [contactPins, sealContext] = await Promise.all([pinsPromise, sealContextPromise]);
394
394
  const pins = [...new Set([...contactPins.fingerprints, ...ownPins])];
395
- 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;
396
397
  // The raw content is a byte string - only ever handed to evaluateMessageSecurity(), never shown. Keys
397
398
  // are read after the pin lookup's await, so a lock that happened meanwhile is honored.
398
399
  const unlocked = getUnlockedKeys(message.mailboxUid);
@@ -429,7 +430,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
429
430
  const viaAlias = await messageSecurity.evaluateMessageSecurity(rawMime, unlocked, pinned, alias);
430
431
  result = { ...result, notAddressedToReader: viaAlias.notAddressedToReader };
431
432
  }
432
- const unpinned = contactPins.loaded && pins.length === 0;
433
+ const unpinned = contactPins.loaded && pins.length === 0 && contactPins.openPgpKeys.length === 0;
433
434
  const keyChanged = signerKeyChanged(result);
434
435
  const [keyState, access] = await Promise.all([
435
436
  keyChanged || (unpinned && UNVERIFIED_SIGNER_STATES.has(result.state)) ? loadKeyState() : undefined,
@@ -526,11 +527,11 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
526
527
  // Only ever invoked from the "Trust this signer" dialog, which only opens for an unverified-signer result carrying
527
528
  // `signerCertificate`. The pin goes on the contact in the mailbox the message belongs to; the evaluation is then
528
529
  // re-run against a fresh pinned-signer lookup, so the badge turns verified.
529
- async function handleTrustSigner(address, certificate) {
530
+ async function handleTrustSigner(address, certificate, type) {
530
531
  setTrusting(true);
531
532
  setTrustError(null);
532
533
  try {
533
- await trustSigner(message.mailboxUid, { address, certificate }, client);
534
+ await trustSigner(message.mailboxUid, { address, certificate, ...(type ? { type } : {}) }, client);
534
535
  clearPinnedSignerCache();
535
536
  setTrustConfirmOpen(false);
536
537
  setUnlockRefresh((n) => n + 1);
@@ -846,7 +847,10 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
846
847
  ? security.signerCertificate
847
848
  : undefined;
848
849
  const keyChanged = security && signerKeyChanged(security) ? security : undefined;
849
- 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;
850
854
  const recordedConflict = keyChanged && sameFingerprint(senderKeyState?.conflict?.observedKey.fingerprint, keyChanged.signerFingerprint)
851
855
  ? senderKeyState.conflict
852
856
  : undefined;
@@ -879,7 +883,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
879
883
  // Registered while rendering, not in an effect: the invitation lookup below must find it the moment it is enabled. Writing the same
880
884
  // file for the same message again is a no-op.
881
885
  if (sealedCalendar !== undefined) {
882
- rememberSealedInvite(message.uid, sealedCalendar);
886
+ rememberSealedInvite(message.uid, sealedCalendar, message.mailboxUid);
883
887
  }
884
888
  const mayHoldInvite = !message.encrypted &&
885
889
  !inOutbox &&
@@ -991,13 +995,13 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
991
995
  : "This message can't be read, so it can't be printed";
992
996
  // In Junk Email the card's button is the way back (Not junk), which only needs the right to change the message.
993
997
  const reportReason = !writable ? "View-only mailbox" : undefined;
994
- 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 })] }), 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: [
995
999
  "inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt",
996
1000
  viewOriginal ? "bg-primary/10" : "",
997
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: () => {
998
1002
  setTrustError(null);
999
1003
  setTrustConfirmOpen(true);
1000
- }, 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: {
1001
1005
  fingerprint: keyChanged.signerFingerprint,
1002
1006
  emails: keyChanged.signerEmails,
1003
1007
  observedAt: recordedConflict?.observedAt,
@@ -1012,5 +1016,5 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
1012
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
1013
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 ?? ""}`))) }))
1014
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" })] }))] }));
1015
- 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), children: "Trust" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => setTrustConfirmOpen(false), children: "Cancel" })] })] }) })] }));
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" })] })] }) })] }));
1016
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: [] });
@@ -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
+ }
@@ -4,7 +4,7 @@
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import { useCallback, useEffect, useReducer } from "react";
6
6
  import { getMessageInvite } from "../../../../../lib/calendar/inviteApi.js";
7
- import { clearSealedInvites, sealedInviteCalendar } from "../../../../../lib/calendar/sealedInvites.js";
7
+ import { clearSealedInvites, onSealedInvitesForgotten, sealedInviteCalendar } from "../../../../../lib/calendar/sealedInvites.js";
8
8
  import { onSigningOut } from "../compose/composeFlushRegistry.js";
9
9
  import { useApiClient } from "../../../../../lib/util/apiClientContext.js";
10
10
  /**
@@ -105,3 +105,13 @@ export function useMessageInvite(uid, enabled = true) {
105
105
  return { invite: value ?? null, settled: value !== undefined, setInvite };
106
106
  }
107
107
  onSigningOut(clearSealedInvites);
108
+ onSigningOut(clearInviteCache);
109
+ // What the server read from a zero-access message's decrypted calendar file (its summary, description) goes with the file when the mailbox
110
+ // locks: mounted cards, chips and popovers draw nothing until it is read again.
111
+ onSealedInvitesForgotten((uids) => {
112
+ for (const uid of uids) {
113
+ if (entries.delete(uid)) {
114
+ notify(uid);
115
+ }
116
+ }
117
+ });
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { SignerKeyState } from "../../../../lib/contacts/contactsApi.js";
22
22
  import type { ApiClient } from "../../../../lib/util/api.js";
23
+ import type { SignerPins } from "../../../../lib/crypto/messageSecurity.js";
23
24
  /** How long one mailbox's loaded contact list is reused before it is fetched again. */
24
25
  export declare const CONTACTS_CACHE_TTL_MS = 60000;
25
26
  /** Forgets every cached contact list for the default global path - on sign-out, a key-session lock, after the
@@ -28,12 +29,17 @@ export declare const CONTACTS_CACHE_TTL_MS = 60000;
28
29
  export declare function clearPinnedSignerCache(): void;
29
30
  /** The sender `address`'s pinned signing fingerprints from `mailboxUid`'s contacts. Rejects if they can't be loaded. */
30
31
  export declare function getPinnedSignerFingerprints(mailboxUid: string, address: string, client?: ApiClient): Promise<string[]>;
32
+ /** The sender `address`'s pinned signing fingerprints and pinned OpenPGP certificates from `mailboxUid`'s contacts - what
33
+ * `evaluateMessageSecurity()` takes as `SignerPins`. Rejects if they can't be loaded. */
34
+ export declare function getPinnedSigners(mailboxUid: string, address: string, client?: ApiClient): Promise<SignerPins>;
31
35
  /** A sender's signing-key state (`signerKeyStateFor()`) plus what a key-changed notice needs to show and link. */
32
36
  export interface SenderKeyState extends SignerKeyState {
33
37
  /** The contact holding the first pinned signing key, else the one holding the conflict, else the first match. */
34
38
  contactUid?: string;
35
39
  /** When the first pinned signing key started being trusted - see `keyPinnedSince()`. */
36
40
  pinnedSince?: number;
41
+ /** When the first pinned OpenPGP certificate (`openPgp[0]`) started being trusted. */
42
+ openPgpPinnedSince?: number;
37
43
  }
38
44
  /** The sender `address`'s signing-key state from `mailboxUid`'s (cached) contacts, for a "signing key changed" comparison
39
45
  * or to spot a recorded conflict. Reads stored contacts only. Rejects if they can't be loaded. */
@@ -22,7 +22,7 @@
22
22
  * `tauri-client` accounts must never answer each other's "who does this sender's key belong to" question from
23
23
  * one shared cache keyed only by `mailboxUid` (mailbox uids are not guaranteed unique across accounts/servers).
24
24
  */
25
- import { PINNED_FINGERPRINT_MAX_PAGES, PINNED_FINGERPRINT_PAGE_SIZE, listContacts, pinnedSigningFingerprintsFor, signerKeyStateFor, } from "../../../../lib/contacts/contactsApi.js";
25
+ import { PINNED_FINGERPRINT_MAX_PAGES, PINNED_FINGERPRINT_PAGE_SIZE, listContacts, pinnedOpenPgpKeysFor, pinnedSigningFingerprintsFor, signerKeyStateFor, } from "../../../../lib/contacts/contactsApi.js";
26
26
  import { keyPinnedSince } from "../contacts/contactKeys.js";
27
27
  import { listFolders } from "../../../../lib/mail/mailApi.js";
28
28
  import { subscribeKeySession } from "../../../../lib/crypto/keySession.js";
@@ -96,6 +96,12 @@ function cachedMailboxContacts(mailboxUid, client) {
96
96
  export async function getPinnedSignerFingerprints(mailboxUid, address, client) {
97
97
  return pinnedSigningFingerprintsFor(await cachedMailboxContacts(mailboxUid, client), address);
98
98
  }
99
+ /** The sender `address`'s pinned signing fingerprints and pinned OpenPGP certificates from `mailboxUid`'s contacts - what
100
+ * `evaluateMessageSecurity()` takes as `SignerPins`. Rejects if they can't be loaded. */
101
+ export async function getPinnedSigners(mailboxUid, address, client) {
102
+ const contacts = await cachedMailboxContacts(mailboxUid, client);
103
+ return { fingerprints: pinnedSigningFingerprintsFor(contacts, address), openPgpKeys: pinnedOpenPgpKeysFor(contacts, address) };
104
+ }
99
105
  /** The sender `address`'s signing-key state from `mailboxUid`'s (cached) contacts, for a "signing key changed" comparison
100
106
  * or to spot a recorded conflict. Reads stored contacts only. Rejects if they can't be loaded. */
101
107
  export async function getSignerKeyState(mailboxUid, address, client) {
@@ -109,9 +115,12 @@ export async function getSignerKeyState(mailboxUid, address, client) {
109
115
  const contact = holder ??
110
116
  matching.find((candidate) => (candidate.keyConflicts ?? []).some((conflict) => conflict.useType === "sign")) ??
111
117
  matching[0];
118
+ const openPgpKey = state.openPgp?.[0];
119
+ const openPgpHolder = openPgpKey && matching.find((candidate) => (candidate.keys ?? []).includes(openPgpKey));
112
120
  return {
113
121
  ...state,
114
122
  ...(contact ? { contactUid: contact.uid } : {}),
115
123
  ...(holder ? { pinnedSince: keyPinnedSince(holder, pinned) } : {}),
124
+ ...(openPgpHolder ? { openPgpPinnedSince: keyPinnedSince(openPgpHolder, openPgpKey) } : {}),
116
125
  };
117
126
  }
@@ -9,8 +9,8 @@ export declare function printHeaders(message: Message, from: string): PrintableM
9
9
  */
10
10
  export declare function olderConversationMessages(message: Message, client?: ApiClient): Promise<Message[]>;
11
11
  /**
12
- * One earlier message of a conversation as it prints: the body the reading pane would show. An encrypted one is decrypted here when this device has
13
- * its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext. `client` is the explicit `ApiClient` every
12
+ * One earlier message of a conversation as it prints: the body the reading pane would show. An encrypted one - end to end, or sealed to its mailbox's
13
+ * zero-access key, whose subject and body the server doesn't hold readable - is decrypted here when this device has its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext. `client` is the explicit `ApiClient` every
14
14
  * request goes through when given (see `useApiClient()`); without one they are the cookie requests they always were.
15
15
  */
16
16
  export declare function printableOlderMessage(message: Message, client?: ApiClient): Promise<PrintableMessage>;
@@ -33,13 +33,13 @@ export async function olderConversationMessages(message, client) {
33
33
  .sort((a, b) => new Date(b.receivedDate).getTime() - new Date(a.receivedDate).getTime());
34
34
  }
35
35
  /**
36
- * One earlier message of a conversation as it prints: the body the reading pane would show. An encrypted one is decrypted here when this device has
37
- * its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext. `client` is the explicit `ApiClient` every
36
+ * One earlier message of a conversation as it prints: the body the reading pane would show. An encrypted one - end to end, or sealed to its mailbox's
37
+ * zero-access key, whose subject and body the server doesn't hold readable - is decrypted here when this device has its mailbox's keys unlocked; when it can't be, it prints a line saying so rather than the ciphertext. `client` is the explicit `ApiClient` every
38
38
  * request goes through when given (see `useApiClient()`); without one they are the cookie requests they always were.
39
39
  */
40
40
  export async function printableOlderMessage(message, client) {
41
41
  const attachments = await listAttachments(message.folderUid, message.uid, client).catch(() => []);
42
- if (message.encrypted) {
42
+ if (message.encrypted || message.zeroAccess) {
43
43
  const unlocked = getUnlockedKeys(message.mailboxUid);
44
44
  if (unlocked) {
45
45
  const { evaluateMessageSecurity } = await import("../../../../../lib/crypto/messageSecurity.js");
@@ -12,6 +12,8 @@ import Button from "../../../../lib/components/buttons/Button.js";
12
12
  import { notifyApiError } from "../../notifications/apiErrors.js";
13
13
  import PolicyLockedNotice from "./PolicyLockedNotice.js";
14
14
  const INPUT_CLASS = "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface";
15
+ /** How long the file's object URL is kept, since some browsers (WebKit, older Firefox) still read it after the click returns. */
16
+ const DOWNLOAD_URL_LIFETIME_MS = 60000;
15
17
  /** Hands `bytes` to the browser as a file download. */
16
18
  function saveFile(bytes, filename) {
17
19
  const url = URL.createObjectURL(new Blob([bytes], { type: "application/x-pkcs12" }));
@@ -21,7 +23,7 @@ function saveFile(bytes, filename) {
21
23
  document.body.appendChild(link);
22
24
  link.click();
23
25
  link.remove();
24
- URL.revokeObjectURL(url);
26
+ setTimeout(() => URL.revokeObjectURL(url), DOWNLOAD_URL_LIFETIME_MS);
25
27
  }
26
28
  /**
27
29
  * "Export keys for Outlook/phone" (`lib/crypto/keyExport.ts`): a passphrase-protected `.p12` with the mailbox's zero-access
@@ -0,0 +1,13 @@
1
+ import React from "react";
2
+ import { type Mailbox } from "../../../../lib/mail/mailApi.js";
3
+ export interface OpenPgpKeySettingProps {
4
+ mailbox: Mailbox;
5
+ /** The caller owns the mailbox and isn't impersonating: only the owner may export its secret key. */
6
+ canManage: boolean;
7
+ }
8
+ /**
9
+ * The mailbox's OpenPGP key (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*): what it is for, its
10
+ * fingerprint, a download of its public key (to give a correspondent by hand), and an export of its secret key, protected by a
11
+ * passphrase, for reading this mailbox's OpenPGP mail in another app. Shows nothing while the mailbox has no OpenPGP key.
12
+ */
13
+ export default function OpenPgpKeySetting({ mailbox, canManage }: OpenPgpKeySettingProps): React.JSX.Element | null;
@@ -0,0 +1,143 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ ///////////////////////////////////////////////////////////////////////////////
3
+ // Copyright (C) 2026 Jean-Philippe Steinmetz
4
+ // SPDX-License-Identifier: MPL-2.0
5
+ ///////////////////////////////////////////////////////////////////////////////
6
+ import { useState } from "react";
7
+ import { findActivePublicKey } from "../../../../lib/crypto/keyvaultApi.js";
8
+ import { MIN_EXPORT_PASSPHRASE_LENGTH } from "../../../../lib/crypto/keyExport.js";
9
+ import { exportOpenPgpSecretKey } from "../../../../lib/crypto/openPgpKey.js";
10
+ import { getMailbox, updateMailbox } from "../../../../lib/mail/mailApi.js";
11
+ import { ApiRequestError } from "../../../../lib/util/api.js";
12
+ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
13
+ import Alert from "../../../../lib/components/feedback/Alert.js";
14
+ import Button from "../../../../lib/components/buttons/Button.js";
15
+ import { notifyApiError } from "../../notifications/apiErrors.js";
16
+ import PolicyLockedNotice from "./PolicyLockedNotice.js";
17
+ import { useSettingsShell } from "./layout/SettingsShell.js";
18
+ const INPUT_CLASS = "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface";
19
+ /** How long a download's object URL is kept, since some browsers (WebKit, older Firefox) still read it after the click returns. */
20
+ const DOWNLOAD_URL_LIFETIME_MS = 60000;
21
+ /** Hands `text` to the browser as a file download. */
22
+ function saveText(text, filename) {
23
+ const url = URL.createObjectURL(new Blob([text], { type: "application/pgp-keys" }));
24
+ const link = document.createElement("a");
25
+ link.href = url;
26
+ link.download = filename;
27
+ document.body.appendChild(link);
28
+ link.click();
29
+ link.remove();
30
+ setTimeout(() => URL.revokeObjectURL(url), DOWNLOAD_URL_LIFETIME_MS);
31
+ }
32
+ /** `preference` advertising `format` - S/MIME is the default, so it is advertised by saying nothing. */
33
+ function withPreferredFormat(preference, format) {
34
+ const { preferFormat: _previous, ...rest } = preference ?? { preferEncrypt: "nopreference" };
35
+ return format === "openpgp" ? { ...rest, preferFormat: "openpgp" } : rest;
36
+ }
37
+ /** A fingerprint in groups of four, for reading aloud. */
38
+ function groupFingerprint(fingerprint) {
39
+ return fingerprint.toUpperCase().replace(/(.{4})/g, "$1 ").trim();
40
+ }
41
+ /**
42
+ * The mailbox's OpenPGP key (restapi `specs/end-to-end_encryption.md` *OpenPGP Interoperability*): what it is for, its
43
+ * fingerprint, a download of its public key (to give a correspondent by hand), and an export of its secret key, protected by a
44
+ * passphrase, for reading this mailbox's OpenPGP mail in another app. Shows nothing while the mailbox has no OpenPGP key.
45
+ */
46
+ export default function OpenPgpKeySetting({ mailbox, canManage }) {
47
+ const client = useApiClient();
48
+ const { patchMailbox } = useSettingsShell();
49
+ const [open, setOpen] = useState(false);
50
+ const [password, setPassword] = useState("");
51
+ const [passphrase, setPassphrase] = useState("");
52
+ const [confirmPassphrase, setConfirmPassphrase] = useState("");
53
+ const [exporting, setExporting] = useState(false);
54
+ const [error, setError] = useState(null);
55
+ const [format, setFormat] = useState(mailbox.encryptPreference?.preferFormat === "openpgp" ? "openpgp" : "smime");
56
+ const [savingFormat, setSavingFormat] = useState(false);
57
+ const key = findActivePublicKey(mailbox.keys ?? [], "encrypt", "openpgp");
58
+ if (!key) {
59
+ return null;
60
+ }
61
+ const address = mailbox.primarySmtpAddress;
62
+ function reset() {
63
+ setOpen(false);
64
+ setPassword("");
65
+ setPassphrase("");
66
+ setConfirmPassphrase("");
67
+ setError(null);
68
+ }
69
+ /** Advertises `next` as the format this mailbox would rather receive (restapi *Choosing the format*), to RapidMX servers through
70
+ * key discovery and the `RapidMX-Key` header. */
71
+ async function saveFormat(next) {
72
+ const previous = format;
73
+ setFormat(next);
74
+ setSavingFormat(true);
75
+ try {
76
+ const write = async (current) => {
77
+ const encryptPreference = withPreferredFormat(current.encryptPreference, next);
78
+ const saved = await updateMailbox({ uid: mailbox.uid, version: current.version, encryptPreference }, client);
79
+ return { version: saved.version, encryptPreference: saved.encryptPreference ?? encryptPreference };
80
+ };
81
+ let saved;
82
+ try {
83
+ saved = await write(mailbox);
84
+ }
85
+ catch (err) {
86
+ // Enrolling a key moves the mailbox's version on: read it again and retry once.
87
+ if (!(err instanceof ApiRequestError && err.status === 409)) {
88
+ throw err;
89
+ }
90
+ saved = await write(await getMailbox(mailbox.uid, {}, client));
91
+ }
92
+ // Kept in the settings shell's copy of the mailbox, which this page is mounted from again on a switch back to it.
93
+ patchMailbox?.(mailbox.uid, saved);
94
+ }
95
+ catch (err) {
96
+ setFormat(previous);
97
+ notifyApiError(err, "Couldn't save your preferred encryption format");
98
+ }
99
+ finally {
100
+ setSavingFormat(false);
101
+ }
102
+ }
103
+ async function downloadPublicKey() {
104
+ const { armorPublicKey, readPublicKey } = await import("../../../../lib/crypto/openpgp.js");
105
+ const certificate = await readPublicKey(key.publicKey);
106
+ if (certificate) {
107
+ saveText(armorPublicKey(certificate), `${address}-openpgp-public.asc`);
108
+ }
109
+ }
110
+ async function handleExport(e) {
111
+ e.preventDefault();
112
+ if (passphrase.length < MIN_EXPORT_PASSPHRASE_LENGTH) {
113
+ setError(`The file's passphrase must be at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters.`);
114
+ return;
115
+ }
116
+ if (passphrase !== confirmPassphrase) {
117
+ setError("The passphrases don't match.");
118
+ return;
119
+ }
120
+ setExporting(true);
121
+ setError(null);
122
+ try {
123
+ const { armored } = await exportOpenPgpSecretKey(mailbox.uid, password, passphrase, client);
124
+ saveText(armored, `${address}-openpgp-secret.asc`);
125
+ reset();
126
+ }
127
+ catch (err) {
128
+ if (err instanceof ApiRequestError) {
129
+ notifyApiError(err, "Couldn't export your OpenPGP key");
130
+ }
131
+ else {
132
+ setError("Incorrect password.");
133
+ }
134
+ }
135
+ finally {
136
+ setExporting(false);
137
+ }
138
+ }
139
+ return (_jsxs("div", { children: [_jsx("h2", { className: "text-sm font-semibold mb-2", children: "OpenPGP key" }), _jsx("p", { className: "text-sm text-text-muted mb-2", children: "People who use Proton Mail, Thunderbird or another OpenPGP app can send you encrypted mail with this key, and you can send it to them. RapidMX publishes it for you; you can also give it to someone yourself. RapidMX uses S/MIME whenever a recipient can receive it." }), _jsxs("p", { className: "text-sm mb-2", children: ["Fingerprint: ", _jsx("code", { className: "text-xs break-all", children: groupFingerprint(key.fingerprint) })] }), _jsxs("fieldset", { className: "mb-3", disabled: !canManage || savingFormat, children: [_jsx("legend", { className: "text-sm font-semibold mb-1", children: "Preferred format" }), _jsx("p", { className: "text-xs text-text-muted mb-1", children: "What other RapidMX servers use to encrypt to you when they could use either. Choose OpenPGP if you mostly read this mailbox in an OpenPGP app." }), [
140
+ ["smime", "S/MIME (recommended)"],
141
+ ["openpgp", "OpenPGP"],
142
+ ].map(([value, label]) => (_jsxs("label", { className: "flex items-center gap-2 text-sm", children: [_jsx("input", { type: "radio", name: `preferred-format-${mailbox.uid}`, checked: format === value, onChange: () => void saveFormat(value) }), label] }, value)))] }), _jsxs("div", { className: "flex flex-wrap gap-2 mb-2", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => void downloadPublicKey(), children: "Download public key" }), canManage && !open && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setOpen(true), children: "Export secret key\u2026" }))] }), !canManage ? (_jsx(PolicyLockedNotice, { children: "Only this mailbox\u2019s owner, signed in as themselves, can export its secret key." })) : (open && (_jsxs("form", { onSubmit: (e) => void handleExport(e), className: "flex flex-col gap-2", children: [_jsx("p", { className: "text-sm text-text-muted", children: "Anyone with the exported file and its passphrase can read this mailbox\u2019s OpenPGP mail and sign as you." }), _jsx("input", { type: "password", "aria-label": "Encryption password", placeholder: "Encryption password", className: INPUT_CLASS, value: password, onChange: (e) => setPassword(e.target.value), disabled: exporting, autoComplete: "current-password" }), _jsx("input", { type: "password", "aria-label": "Key passphrase", placeholder: `Key passphrase (at least ${MIN_EXPORT_PASSPHRASE_LENGTH} characters)`, className: INPUT_CLASS, value: passphrase, onChange: (e) => setPassphrase(e.target.value), disabled: exporting, autoComplete: "new-password" }), _jsx("input", { type: "password", "aria-label": "Confirm key passphrase", placeholder: "Confirm key passphrase", className: INPUT_CLASS, value: confirmPassphrase, onChange: (e) => setConfirmPassphrase(e.target.value), disabled: exporting, autoComplete: "new-password" }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { type: "submit", className: "!w-auto", loading: exporting, disabled: exporting || !password, children: "Export" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: reset, disabled: exporting, children: "Cancel" })] })] })))] }));
143
+ }