@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.
- package/apps/escrow/matters/[uid].tsx +4 -3
- package/apps/shared/calendar/calendarReminders.ts +35 -0
- package/apps/shared/calendar/sealedEvents.ts +113 -0
- package/apps/shared/calendar/useCalendarReminders.ts +28 -9
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +4 -0
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +89 -3
- package/apps/shared/components/calendar/EventDetails.tsx +13 -2
- package/apps/shared/components/calendar/EventEditor.tsx +124 -17
- package/apps/shared/components/calendar/EventFormParts.tsx +1 -0
- package/apps/shared/components/calendar/EventModal.tsx +19 -2
- package/apps/shared/components/calendar/RequestChangeForm.tsx +4 -1
- package/apps/shared/components/calendar/eventForm.ts +2 -0
- package/apps/shared/components/contacts/ContactCard.tsx +13 -1
- package/apps/shared/components/contacts/ContactDetailPane.tsx +189 -9
- package/apps/shared/components/contacts/ContactForm.tsx +56 -7
- package/apps/shared/components/contacts/KeyChangeReview.tsx +5 -1
- package/apps/shared/components/contacts/contactCardData.ts +8 -4
- package/apps/shared/components/contacts/contactKeys.ts +3 -3
- package/apps/shared/components/layout/AppShell.tsx +34 -5
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +30 -1
- package/apps/shared/components/layout/SharedMailboxKeyManager.tsx +122 -0
- package/apps/shared/components/layout/UnlockPromptProvider.tsx +34 -0
- package/apps/shared/components/mail/ConversationList.tsx +1 -1
- package/apps/shared/components/mail/InviteCard.tsx +11 -1
- package/apps/shared/components/mail/MessageDetailPane.tsx +59 -26
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +9 -2
- package/apps/shared/components/mail/compose/RecipientInput.tsx +2 -1
- package/apps/shared/components/mail/compose/draftResume.ts +28 -3
- package/apps/shared/components/mail/compose/protectedContactSuggestions.ts +121 -0
- package/apps/shared/components/mail/compose/quotedBody.ts +4 -3
- package/apps/shared/components/mail/invite/inviteStore.ts +17 -1
- package/apps/shared/components/mail/layout/MailShell.tsx +2 -0
- package/apps/shared/components/mail/pinnedSigners.ts +14 -0
- package/apps/shared/components/mail/reading/conversationPrint.ts +3 -3
- package/apps/shared/components/mail/reading/useMessageActions.ts +4 -1
- package/apps/shared/components/settings/KeyExportSetting.tsx +173 -0
- package/apps/shared/components/settings/OpenPgpKeySetting.tsx +240 -0
- package/apps/shared/components/settings/PolicyLockedNotice.tsx +23 -0
- package/apps/shared/components/settings/ZeroAccessSetting.tsx +128 -0
- package/apps/shared/components/settings/ZeroAccessTypes.tsx +175 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +9 -2
- package/apps/shared/mail/decryptedMessages.ts +11 -3
- package/apps/shared/mail/outbox/sendJob.ts +349 -50
- package/apps/shared/mail/outbox/sendOutcomes.ts +91 -20
- package/apps/shared/mail/reportNotices.ts +3 -1
- package/apps/shared/mail/zeroAccessSource.ts +27 -0
- package/apps/shared/search/LocalIndexLifecycle.tsx +84 -24
- package/apps/shared/search/localIndexBuilder.ts +57 -20
- package/apps/shared/search/localIndexRpcClient.ts +96 -12
- package/apps/shared/search/localIndexSchema.ts +20 -4
- package/apps/shared/search/localIndexTransport.ts +6 -2
- package/apps/shared/search/localIndexWorker.ts +37 -18
- package/apps/shared/search/searchTier2.ts +5 -0
- package/apps/shared/search/zeroAccessIndexSync.ts +679 -0
- package/apps/shared/search/zeroAccessSyncState.ts +70 -0
- package/apps/shared/zeroAccess/ProtectedMark.tsx +19 -0
- package/apps/shared/zeroAccess/useKeysLocked.ts +25 -0
- package/apps/shared/zeroAccess/useOpenedRecord.ts +71 -0
- package/apps/www/_shell.tsx +2 -0
- package/apps/www/calendar/index.tsx +225 -61
- package/apps/www/contacts/index.tsx +67 -11
- package/apps/www/index.tsx +19 -3
- package/apps/www/settings/encryption/index.tsx +21 -1
- package/apps/www/settings/privacy/index.tsx +89 -39
- package/apps/www/tasks/index.tsx +24 -6
- package/dist/apps/escrow/matters/[uid].js +2 -2
- package/dist/apps/shared/calendar/calendarReminders.d.ts +14 -0
- package/dist/apps/shared/calendar/calendarReminders.js +26 -0
- package/dist/apps/shared/calendar/sealedEvents.d.ts +14 -0
- package/dist/apps/shared/calendar/sealedEvents.js +85 -0
- package/dist/apps/shared/calendar/useCalendarReminders.d.ts +2 -1
- package/dist/apps/shared/calendar/useCalendarReminders.js +27 -9
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -0
- package/dist/apps/shared/components/admin/settings/EncryptionPolicyForm.js +25 -2
- package/dist/apps/shared/components/calendar/EventDetails.js +13 -2
- package/dist/apps/shared/components/calendar/EventEditor.js +115 -18
- package/dist/apps/shared/components/calendar/EventFormParts.js +1 -1
- package/dist/apps/shared/components/calendar/EventModal.d.ts +1 -1
- package/dist/apps/shared/components/calendar/EventModal.js +15 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +3 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +1 -1
- package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -0
- package/dist/apps/shared/components/contacts/ContactCard.js +12 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.d.ts +2 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +103 -9
- package/dist/apps/shared/components/contacts/ContactForm.d.ts +4 -1
- package/dist/apps/shared/components/contacts/ContactForm.js +37 -7
- package/dist/apps/shared/components/contacts/KeyChangeReview.d.ts +3 -1
- package/dist/apps/shared/components/contacts/KeyChangeReview.js +3 -2
- package/dist/apps/shared/components/contacts/contactCardData.js +8 -4
- package/dist/apps/shared/components/contacts/contactKeys.d.ts +1 -1
- package/dist/apps/shared/components/contacts/contactKeys.js +3 -3
- package/dist/apps/shared/components/layout/AppShell.d.ts +18 -1
- package/dist/apps/shared/components/layout/AppShell.js +17 -6
- package/dist/apps/shared/components/layout/KeyEnrollmentGate.js +27 -2
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.d.ts +17 -0
- package/dist/apps/shared/components/layout/SharedMailboxKeyManager.js +98 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.d.ts +3 -0
- package/dist/apps/shared/components/layout/UnlockPromptProvider.js +32 -0
- package/dist/apps/shared/components/mail/ConversationList.js +1 -1
- package/dist/apps/shared/components/mail/InviteCard.js +11 -1
- package/dist/apps/shared/components/mail/MessageDetailPane.js +46 -21
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +2 -2
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +3 -2
- package/dist/apps/shared/components/mail/compose/draftResume.d.ts +2 -0
- package/dist/apps/shared/components/mail/compose/draftResume.js +28 -3
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.d.ts +14 -0
- package/dist/apps/shared/components/mail/compose/protectedContactSuggestions.js +96 -0
- package/dist/apps/shared/components/mail/compose/quotedBody.d.ts +1 -1
- package/dist/apps/shared/components/mail/compose/quotedBody.js +4 -3
- package/dist/apps/shared/components/mail/invite/inviteStore.js +16 -1
- package/dist/apps/shared/components/mail/layout/MailShell.js +2 -1
- package/dist/apps/shared/components/mail/pinnedSigners.d.ts +6 -0
- package/dist/apps/shared/components/mail/pinnedSigners.js +10 -1
- package/dist/apps/shared/components/mail/reading/conversationPrint.d.ts +2 -2
- package/dist/apps/shared/components/mail/reading/conversationPrint.js +3 -3
- package/dist/apps/shared/components/mail/reading/useMessageActions.js +4 -1
- package/dist/apps/shared/components/settings/KeyExportSetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/KeyExportSetting.js +90 -0
- package/dist/apps/shared/components/settings/OpenPgpKeySetting.d.ts +13 -0
- package/dist/apps/shared/components/settings/OpenPgpKeySetting.js +143 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.d.ts +11 -0
- package/dist/apps/shared/components/settings/PolicyLockedNotice.js +9 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.d.ts +16 -0
- package/dist/apps/shared/components/settings/ZeroAccessSetting.js +80 -0
- package/dist/apps/shared/components/settings/ZeroAccessTypes.d.ts +29 -0
- package/dist/apps/shared/components/settings/ZeroAccessTypes.js +107 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +3 -2
- package/dist/apps/shared/mail/decryptedMessages.d.ts +1 -1
- package/dist/apps/shared/mail/decryptedMessages.js +11 -3
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +37 -4
- package/dist/apps/shared/mail/outbox/sendJob.js +287 -42
- package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +12 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +76 -20
- package/dist/apps/shared/mail/reportNotices.js +3 -1
- package/dist/apps/shared/mail/zeroAccessSource.d.ts +8 -0
- package/dist/apps/shared/mail/zeroAccessSource.js +26 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.d.ts +2 -0
- package/dist/apps/shared/search/LocalIndexLifecycle.js +74 -24
- package/dist/apps/shared/search/localIndexBuilder.d.ts +9 -1
- package/dist/apps/shared/search/localIndexBuilder.js +50 -17
- package/dist/apps/shared/search/localIndexRpcClient.d.ts +5 -39
- package/dist/apps/shared/search/localIndexRpcClient.js +129 -11
- package/dist/apps/shared/search/localIndexSchema.d.ts +14 -2
- package/dist/apps/shared/search/localIndexSchema.js +18 -4
- package/dist/apps/shared/search/localIndexTransport.d.ts +6 -2
- package/dist/apps/shared/search/localIndexWorker.d.ts +11 -0
- package/dist/apps/shared/search/localIndexWorker.js +22 -15
- package/dist/apps/shared/search/searchTier2.js +5 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.d.ts +102 -0
- package/dist/apps/shared/search/zeroAccessIndexSync.js +544 -0
- package/dist/apps/shared/search/zeroAccessSyncState.d.ts +20 -0
- package/dist/apps/shared/search/zeroAccessSyncState.js +65 -0
- package/dist/apps/shared/zeroAccess/ProtectedMark.d.ts +7 -0
- package/dist/apps/shared/zeroAccess/ProtectedMark.js +10 -0
- package/dist/apps/shared/zeroAccess/useKeysLocked.d.ts +6 -0
- package/dist/apps/shared/zeroAccess/useKeysLocked.js +20 -0
- package/dist/apps/shared/zeroAccess/useOpenedRecord.d.ts +18 -0
- package/dist/apps/shared/zeroAccess/useOpenedRecord.js +66 -0
- package/dist/apps/www/_shell.d.ts +1 -1
- package/dist/apps/www/_shell.js +2 -2
- package/dist/apps/www/calendar/index.js +88 -20
- package/dist/apps/www/contacts/index.js +64 -11
- package/dist/apps/www/index.js +19 -3
- package/dist/apps/www/settings/encryption/index.js +16 -4
- package/dist/apps/www/settings/privacy/index.js +47 -6
- package/dist/apps/www/tasks/index.js +21 -7
- package/dist/lib/admin/domainsApi.d.ts +1 -1
- package/dist/lib/admin/escrowAccessRequestsApi.d.ts +6 -0
- package/dist/lib/calendar/calendarApi.d.ts +52 -6
- package/dist/lib/calendar/calendarApi.js +110 -17
- package/dist/lib/calendar/calendarMutations.d.ts +20 -7
- package/dist/lib/calendar/calendarMutations.js +73 -14
- package/dist/lib/calendar/inviteApi.d.ts +17 -3
- package/dist/lib/calendar/inviteApi.js +86 -7
- package/dist/lib/calendar/protectedEvents.d.ts +31 -0
- package/dist/lib/calendar/protectedEvents.js +113 -0
- package/dist/lib/calendar/recurrence.d.ts +2 -0
- package/dist/lib/calendar/recurrence.js +16 -0
- package/dist/lib/calendar/sealedInvites.d.ts +9 -0
- package/dist/lib/calendar/sealedInvites.js +65 -0
- package/dist/lib/contacts/contactsApi.d.ts +29 -3
- package/dist/lib/contacts/contactsApi.js +23 -7
- package/dist/lib/contacts/useContactPhotoSrc.d.ts +5 -2
- package/dist/lib/contacts/useContactPhotoSrc.js +91 -11
- package/dist/lib/crypto/composeSecurity.d.ts +11 -3
- package/dist/lib/crypto/composeSecurity.js +13 -5
- package/dist/lib/crypto/keyExport.d.ts +54 -0
- package/dist/lib/crypto/keyExport.js +161 -0
- package/dist/lib/crypto/keyPairMatch.d.ts +9 -0
- package/dist/lib/crypto/keyPairMatch.js +65 -0
- package/dist/lib/crypto/keySession.d.ts +64 -3
- package/dist/lib/crypto/keySession.js +179 -12
- package/dist/lib/crypto/keyvaultApi.d.ts +129 -8
- package/dist/lib/crypto/keyvaultApi.js +45 -3
- package/dist/lib/crypto/meetingKeys.d.ts +25 -0
- package/dist/lib/crypto/meetingKeys.js +87 -0
- package/dist/lib/crypto/messageSecurity.d.ts +56 -3
- package/dist/lib/crypto/messageSecurity.js +201 -13
- package/dist/lib/crypto/mime.d.ts +5 -0
- package/dist/lib/crypto/mime.js +25 -0
- package/dist/lib/crypto/openPgpKey.d.ts +39 -0
- package/dist/lib/crypto/openPgpKey.js +89 -0
- package/dist/lib/crypto/openpgp.d.ts +47 -0
- package/dist/lib/crypto/openpgp.js +88 -0
- package/dist/lib/crypto/pgpMessage.d.ts +51 -0
- package/dist/lib/crypto/pgpMessage.js +271 -0
- package/dist/lib/crypto/sharedMailboxKeys.d.ts +62 -0
- package/dist/lib/crypto/sharedMailboxKeys.js +337 -0
- package/dist/lib/crypto/smimeMessage.d.ts +21 -0
- package/dist/lib/crypto/smimeMessage.js +11 -7
- package/dist/lib/crypto/zeroAccessKey.d.ts +32 -0
- package/dist/lib/crypto/zeroAccessKey.js +71 -0
- package/dist/lib/mail/conversationsApi.d.ts +6 -0
- package/dist/lib/mail/mailApi.d.ts +63 -2
- package/dist/lib/mail/mailApi.js +5 -1
- package/dist/lib/mail/mailboxImportApi.d.ts +10 -0
- package/dist/lib/mail/mailboxImportApi.js +15 -0
- package/dist/lib/search/zeroAccessIndexApi.d.ts +85 -0
- package/dist/lib/search/zeroAccessIndexApi.js +35 -0
- package/dist/lib/tasks/tasksApi.d.ts +11 -1
- package/dist/lib/tasks/tasksApi.js +10 -4
- package/dist/lib/util/clientPlatform.d.ts +14 -0
- package/dist/lib/util/clientPlatform.js +26 -0
- package/dist/lib/videoconf/protectedMeetings.d.ts +74 -0
- package/dist/lib/videoconf/protectedMeetings.js +181 -0
- package/dist/lib/videoconf/videoMeetingsApi.d.ts +25 -4
- package/dist/lib/videoconf/videoMeetingsApi.js +1 -1
- package/dist/lib/zeroAccess/sealedMoves.d.ts +41 -0
- package/dist/lib/zeroAccess/sealedMoves.js +63 -0
- package/dist/lib/zeroAccess/sealedRecords.d.ts +29 -0
- package/dist/lib/zeroAccess/sealedRecords.js +136 -0
- package/lib/admin/domainsApi.ts +1 -1
- package/lib/admin/escrowAccessRequestsApi.ts +6 -0
- package/lib/calendar/calendarApi.ts +143 -17
- package/lib/calendar/calendarMutations.ts +79 -12
- package/lib/calendar/inviteApi.ts +101 -8
- package/lib/calendar/protectedEvents.ts +143 -0
- package/lib/calendar/recurrence.ts +17 -0
- package/lib/calendar/sealedInvites.ts +73 -0
- package/lib/contacts/contactsApi.ts +43 -17
- package/lib/contacts/useContactPhotoSrc.ts +103 -13
- package/lib/crypto/composeSecurity.ts +20 -5
- package/lib/crypto/keyExport.ts +229 -0
- package/lib/crypto/keyPairMatch.ts +69 -0
- package/lib/crypto/keySession.ts +216 -12
- package/lib/crypto/keyvaultApi.ts +174 -10
- package/lib/crypto/meetingKeys.ts +118 -0
- package/lib/crypto/messageSecurity.ts +284 -16
- package/lib/crypto/mime.ts +26 -0
- package/lib/crypto/openPgpKey.ts +118 -0
- package/lib/crypto/openpgp.ts +115 -0
- package/lib/crypto/pgpMessage.ts +336 -0
- package/lib/crypto/sharedMailboxKeys.ts +402 -0
- package/lib/crypto/smimeMessage.ts +11 -7
- package/lib/crypto/zeroAccessKey.ts +102 -0
- package/lib/mail/conversationsApi.ts +3 -0
- package/lib/mail/mailApi.ts +60 -3
- package/lib/mail/mailboxImportApi.ts +24 -0
- package/lib/search/zeroAccessIndexApi.ts +123 -0
- package/lib/tasks/tasksApi.ts +19 -5
- package/lib/util/clientPlatform.ts +50 -0
- package/lib/videoconf/protectedMeetings.ts +221 -0
- package/lib/videoconf/videoMeetingsApi.ts +15 -4
- package/lib/zeroAccess/sealedMoves.ts +79 -0
- package/lib/zeroAccess/sealedRecords.ts +153 -0
- 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,
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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;
|