@rapidmx/web-client 0.10.0 → 0.11.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/README.md +183 -2
- package/apps/admin/_layout.tsx +8 -2
- package/apps/admin/index.tsx +7 -1
- package/apps/admin/ingest-queue/index.tsx +2 -1
- package/apps/admin/mailboxes/[uid].tsx +23 -10
- package/apps/admin/quarantine/index.tsx +2 -1
- package/apps/admin/signing-certificates/index.tsx +343 -0
- package/apps/escrow/_layout.tsx +8 -2
- package/apps/shared/appearance/AppearanceForm.tsx +392 -0
- package/apps/shared/appearance/AppearanceHead.tsx +49 -0
- package/apps/shared/appearance/AppearanceProvider.tsx +470 -0
- package/apps/shared/appearance/ColorField.tsx +107 -0
- package/apps/shared/appearance/appearanceCache.ts +106 -0
- package/apps/shared/appearance/appearanceContext.ts +77 -0
- package/apps/shared/appearance/bootScript.ts +35 -0
- package/apps/shared/appearance/color.ts +169 -0
- package/apps/shared/appearance/photo.ts +88 -0
- package/apps/shared/appearance/resolvedTheme.ts +67 -0
- package/apps/shared/appearance/theme.ts +355 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +22 -10
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +107 -60
- package/apps/shared/components/admin/settings/BrandingForm.tsx +24 -0
- package/apps/shared/components/admin/settings/PluginsManager.tsx +228 -25
- package/apps/shared/components/admin/setup/SetupWizard.tsx +2 -1
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +4 -5
- package/apps/shared/components/contacts/ContactForm.tsx +11 -5
- package/apps/shared/components/contacts/ContactsSidebar.tsx +5 -3
- package/apps/shared/components/contacts/ContactsToolbar.tsx +25 -52
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +10 -5
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +13 -6
- package/apps/shared/components/layout/AppShell.tsx +98 -32
- package/apps/shared/components/layout/BrandingChrome.tsx +200 -13
- package/apps/shared/components/layout/RailIcon.tsx +100 -0
- package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -0
- package/apps/shared/components/layout/ThemeSwitch.tsx +84 -0
- package/apps/shared/components/layout/UserMenu.tsx +75 -6
- package/apps/shared/components/mail/ConversationList.tsx +4 -2
- package/apps/shared/components/mail/ConversationThreadPane.tsx +487 -475
- package/apps/shared/components/mail/LazyReadingPane.tsx +14 -16
- package/apps/shared/components/mail/MailSelectionBar.tsx +2 -10
- package/apps/shared/components/mail/MessageDetailPane.tsx +366 -287
- package/apps/shared/components/mail/OutboxBadge.tsx +44 -0
- package/apps/shared/components/mail/OutboxRowStatus.tsx +31 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +35 -2
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +239 -437
- package/apps/shared/components/mail/compose/encryptionRequirement.ts +86 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +39 -25
- package/apps/shared/components/mail/reading/EncryptedBody.tsx +65 -0
- package/apps/shared/components/mail/reading/EncryptedPreview.tsx +35 -0
- package/apps/shared/components/mail/reading/MessageBody.tsx +240 -0
- package/apps/shared/components/mail/reading/MessageCard.tsx +174 -0
- package/apps/shared/components/mail/reading/bodyContent.ts +100 -0
- package/apps/shared/components/mail/reading/bodyHtml.ts +298 -0
- package/apps/shared/components/mail/reading/color.ts +245 -0
- package/apps/shared/components/mail/reading/frameControl.ts +142 -0
- package/apps/shared/components/mail/reading/frameDocument.ts +123 -0
- package/apps/shared/components/mail/reading/safeDocument.ts +24 -0
- package/apps/shared/components/mail/reading/themeAdaptation.ts +221 -0
- package/apps/shared/components/mail/reading/themeSurface.ts +90 -0
- package/apps/shared/components/mail/reading/viewOriginal.ts +38 -0
- package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/components/sharing/PrincipalPicker.tsx +145 -0
- package/apps/shared/components/tasks/TasksSidebar.tsx +5 -3
- package/apps/shared/components/tasks/layout/TasksShell.tsx +9 -5
- package/apps/shared/mail/folderCounts.ts +9 -3
- package/apps/shared/mail/folderTree.ts +143 -0
- package/apps/shared/mail/outbox/UnlockBridge.tsx +23 -0
- package/apps/shared/mail/outbox/composeBridge.ts +69 -0
- package/apps/shared/mail/outbox/outboxState.ts +88 -0
- package/apps/shared/mail/outbox/pendingSends.ts +146 -0
- package/apps/shared/mail/outbox/sendDecision.ts +157 -0
- package/apps/shared/mail/outbox/sendJob.ts +427 -0
- package/apps/shared/mail/outbox/sendOutcomes.ts +146 -0
- package/apps/shared/mail/outbox/sendState.ts +35 -0
- package/apps/shared/mail/outbox/useOutboxStatus.ts +61 -0
- package/apps/shared/mail/useMailConnection.ts +81 -28
- package/apps/shared/mail/useMailLiveUpdates.ts +55 -10
- package/apps/shared/mail/useNewMailNotifications.ts +47 -32
- package/apps/shared/navigation/AppRouter.tsx +2 -2
- package/apps/shared/notifications/NotificationCenter.tsx +215 -0
- package/apps/shared/notifications/NotificationHistoryDialog.tsx +90 -0
- package/apps/shared/notifications/apiErrors.ts +98 -0
- package/apps/shared/notifications/headerOffset.ts +35 -0
- package/apps/shared/notifications/pushStatus.ts +56 -0
- package/apps/shared/notifications/store.ts +549 -0
- package/apps/shared/notifications/systemErrors.ts +63 -0
- package/apps/shared/notifications/useNotifications.ts +41 -0
- package/apps/shared/signing/enrollmentStorage.ts +33 -0
- package/apps/shared/signing/enrollmentTracker.ts +385 -0
- package/apps/shared/signing/enrollmentView.ts +251 -0
- package/apps/shared/signing/signingInfo.ts +58 -0
- package/apps/shared/signing/useNow.ts +19 -0
- package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -0
- package/apps/shared/styles/app.css +80 -0
- package/apps/www/_layout.tsx +8 -2
- package/apps/www/_routes.ts +1 -0
- package/apps/www/calendar/index.tsx +9 -14
- package/apps/www/contacts/[uid].tsx +5 -8
- package/apps/www/contacts/index.tsx +587 -584
- package/apps/www/index.tsx +38 -27
- package/apps/www/messages/[uid].tsx +3 -1
- package/apps/www/settings/appearance/index.tsx +21 -0
- package/apps/www/settings/auto-reply/index.tsx +3 -7
- package/apps/www/settings/encryption/index.tsx +151 -116
- package/apps/www/settings/filters/[uid].tsx +3 -1
- package/apps/www/settings/filters/new/index.tsx +3 -1
- package/apps/www/settings/privacy/index.tsx +3 -8
- package/apps/www/settings/read-receipts/index.tsx +3 -7
- package/apps/www/settings/sharing/index.tsx +44 -65
- package/apps/www/settings/signatures/[uid].tsx +3 -1
- package/apps/www/settings/signatures/new/index.tsx +3 -2
- package/apps/www/tasks/index.tsx +11 -16
- package/dist/apps/admin/_layout.d.ts +5 -1
- package/dist/apps/admin/_layout.js +3 -2
- package/dist/apps/admin/index.js +3 -2
- package/dist/apps/admin/ingest-queue/index.js +2 -1
- package/dist/apps/admin/mailboxes/[uid].js +9 -6
- package/dist/apps/admin/quarantine/index.js +2 -1
- package/dist/apps/admin/signing-certificates/index.d.ts +3 -0
- package/dist/apps/admin/signing-certificates/index.js +161 -0
- package/dist/apps/escrow/_layout.d.ts +5 -1
- package/dist/apps/escrow/_layout.js +3 -2
- package/dist/apps/shared/appearance/AppearanceForm.d.ts +7 -0
- package/dist/apps/shared/appearance/AppearanceForm.js +131 -0
- package/dist/apps/shared/appearance/AppearanceHead.d.ts +15 -0
- package/dist/apps/shared/appearance/AppearanceHead.js +31 -0
- package/dist/apps/shared/appearance/AppearanceProvider.d.ts +32 -0
- package/dist/apps/shared/appearance/AppearanceProvider.js +390 -0
- package/dist/apps/shared/appearance/ColorField.d.ts +22 -0
- package/dist/apps/shared/appearance/ColorField.js +40 -0
- package/dist/apps/shared/appearance/appearanceCache.d.ts +45 -0
- package/dist/apps/shared/appearance/appearanceCache.js +80 -0
- package/dist/apps/shared/appearance/appearanceContext.d.ts +45 -0
- package/dist/apps/shared/appearance/appearanceContext.js +29 -0
- package/dist/apps/shared/appearance/bootScript.d.ts +16 -0
- package/dist/apps/shared/appearance/bootScript.js +33 -0
- package/dist/apps/shared/appearance/color.d.ts +65 -0
- package/dist/apps/shared/appearance/color.js +132 -0
- package/dist/apps/shared/appearance/photo.d.ts +17 -0
- package/dist/apps/shared/appearance/photo.js +79 -0
- package/dist/apps/shared/appearance/resolvedTheme.d.ts +21 -0
- package/dist/apps/shared/appearance/resolvedTheme.js +55 -0
- package/dist/apps/shared/appearance/theme.d.ts +111 -0
- package/dist/apps/shared/appearance/theme.js +251 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +16 -6
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.d.ts +10 -5
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +49 -34
- package/dist/apps/shared/components/admin/settings/BrandingForm.d.ts +2 -0
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +7 -1
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +106 -20
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +2 -1
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +8 -8
- package/dist/apps/shared/components/contacts/ContactForm.js +1 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +4 -2
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +24 -9
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -1
- package/dist/apps/shared/components/escrow/layout/EscrowShell.js +7 -4
- package/dist/apps/shared/components/layout/AppShell.d.ts +6 -1
- package/dist/apps/shared/components/layout/AppShell.js +59 -13
- package/dist/apps/shared/components/layout/BrandingChrome.d.ts +69 -10
- package/dist/apps/shared/components/layout/BrandingChrome.js +140 -12
- package/dist/apps/shared/components/layout/RailIcon.d.ts +21 -0
- package/dist/apps/shared/components/layout/RailIcon.js +85 -0
- package/dist/apps/shared/components/layout/ResponsiveToolbar.d.ts +55 -0
- package/dist/apps/shared/components/layout/ResponsiveToolbar.js +199 -0
- package/dist/apps/shared/components/layout/ThemeSwitch.d.ts +9 -0
- package/dist/apps/shared/components/layout/ThemeSwitch.js +54 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +7 -1
- package/dist/apps/shared/components/layout/UserMenu.js +41 -7
- package/dist/apps/shared/components/mail/ConversationList.js +3 -2
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +2 -1
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +42 -20
- package/dist/apps/shared/components/mail/LazyReadingPane.js +9 -8
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +3 -4
- package/dist/apps/shared/components/mail/MailSelectionBar.js +2 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +18 -7
- package/dist/apps/shared/components/mail/MessageDetailPane.js +86 -41
- package/dist/apps/shared/components/mail/OutboxBadge.d.ts +17 -0
- package/dist/apps/shared/components/mail/OutboxBadge.js +18 -0
- package/dist/apps/shared/components/mail/OutboxRowStatus.d.ts +10 -0
- package/dist/apps/shared/components/mail/OutboxRowStatus.js +19 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +8 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +28 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.d.ts +2 -1
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +229 -328
- package/dist/apps/shared/components/mail/compose/encryptionRequirement.d.ts +49 -0
- package/dist/apps/shared/components/mail/compose/encryptionRequirement.js +35 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +2 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +20 -14
- package/dist/apps/shared/components/mail/reading/EncryptedBody.d.ts +21 -0
- package/dist/apps/shared/components/mail/reading/EncryptedBody.js +22 -0
- package/dist/apps/shared/components/mail/reading/EncryptedPreview.d.ts +18 -0
- package/dist/apps/shared/components/mail/reading/EncryptedPreview.js +22 -0
- package/dist/apps/shared/components/mail/reading/MessageBody.d.ts +32 -0
- package/dist/apps/shared/components/mail/reading/MessageBody.js +136 -0
- package/dist/apps/shared/components/mail/reading/MessageCard.d.ts +62 -0
- package/dist/apps/shared/components/mail/reading/MessageCard.js +58 -0
- package/dist/apps/shared/components/mail/reading/bodyContent.d.ts +38 -0
- package/dist/apps/shared/components/mail/reading/bodyContent.js +84 -0
- package/dist/apps/shared/components/mail/reading/bodyHtml.d.ts +47 -0
- package/dist/apps/shared/components/mail/reading/bodyHtml.js +267 -0
- package/dist/apps/shared/components/mail/reading/color.d.ts +63 -0
- package/dist/apps/shared/components/mail/reading/color.js +201 -0
- package/dist/apps/shared/components/mail/reading/frameControl.d.ts +33 -0
- package/dist/apps/shared/components/mail/reading/frameControl.js +120 -0
- package/dist/apps/shared/components/mail/reading/frameDocument.d.ts +52 -0
- package/dist/apps/shared/components/mail/reading/frameDocument.js +98 -0
- package/dist/apps/shared/components/mail/reading/safeDocument.d.ts +3 -0
- package/dist/apps/shared/components/mail/reading/safeDocument.js +12 -0
- package/dist/apps/shared/components/mail/reading/themeAdaptation.d.ts +56 -0
- package/dist/apps/shared/components/mail/reading/themeAdaptation.js +144 -0
- package/dist/apps/shared/components/mail/reading/themeSurface.d.ts +17 -0
- package/dist/apps/shared/components/mail/reading/themeSurface.js +83 -0
- package/dist/apps/shared/components/mail/reading/viewOriginal.d.ts +9 -0
- package/dist/apps/shared/components/mail/reading/viewOriginal.js +32 -0
- package/dist/apps/shared/components/settings/SigningCertificateCard.d.ts +32 -0
- package/dist/apps/shared/components/settings/SigningCertificateCard.js +98 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +26 -0
- package/dist/apps/shared/components/sharing/PrincipalPicker.js +77 -0
- package/dist/apps/shared/components/tasks/TasksSidebar.js +4 -2
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +3 -1
- package/dist/apps/shared/mail/folderCounts.d.ts +1 -1
- package/dist/apps/shared/mail/folderCounts.js +9 -3
- package/dist/apps/shared/mail/folderTree.d.ts +55 -0
- package/dist/apps/shared/mail/folderTree.js +116 -0
- package/dist/apps/shared/mail/outbox/UnlockBridge.d.ts +5 -0
- package/dist/apps/shared/mail/outbox/UnlockBridge.js +18 -0
- package/dist/apps/shared/mail/outbox/composeBridge.d.ts +34 -0
- package/dist/apps/shared/mail/outbox/composeBridge.js +32 -0
- package/dist/apps/shared/mail/outbox/outboxState.d.ts +41 -0
- package/dist/apps/shared/mail/outbox/outboxState.js +42 -0
- package/dist/apps/shared/mail/outbox/pendingSends.d.ts +39 -0
- package/dist/apps/shared/mail/outbox/pendingSends.js +109 -0
- package/dist/apps/shared/mail/outbox/sendDecision.d.ts +80 -0
- package/dist/apps/shared/mail/outbox/sendDecision.js +94 -0
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +68 -0
- package/dist/apps/shared/mail/outbox/sendJob.js +319 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.d.ts +3 -0
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +141 -0
- package/dist/apps/shared/mail/outbox/sendState.d.ts +25 -0
- package/dist/apps/shared/mail/outbox/sendState.js +8 -0
- package/dist/apps/shared/mail/outbox/useOutboxStatus.d.ts +11 -0
- package/dist/apps/shared/mail/outbox/useOutboxStatus.js +49 -0
- package/dist/apps/shared/mail/useMailConnection.d.ts +9 -8
- package/dist/apps/shared/mail/useMailConnection.js +64 -22
- package/dist/apps/shared/mail/useMailLiveUpdates.d.ts +17 -4
- package/dist/apps/shared/mail/useMailLiveUpdates.js +36 -10
- package/dist/apps/shared/mail/useNewMailNotifications.d.ts +4 -10
- package/dist/apps/shared/mail/useNewMailNotifications.js +43 -23
- package/dist/apps/shared/navigation/AppRouter.js +2 -2
- package/dist/apps/shared/notifications/NotificationCenter.d.ts +19 -0
- package/dist/apps/shared/notifications/NotificationCenter.js +90 -0
- package/dist/apps/shared/notifications/NotificationHistoryDialog.d.ts +10 -0
- package/dist/apps/shared/notifications/NotificationHistoryDialog.js +36 -0
- package/dist/apps/shared/notifications/apiErrors.d.ts +19 -0
- package/dist/apps/shared/notifications/apiErrors.js +86 -0
- package/dist/apps/shared/notifications/headerOffset.d.ts +8 -0
- package/dist/apps/shared/notifications/headerOffset.js +33 -0
- package/dist/apps/shared/notifications/pushStatus.d.ts +9 -0
- package/dist/apps/shared/notifications/pushStatus.js +54 -0
- package/dist/apps/shared/notifications/store.d.ts +136 -0
- package/dist/apps/shared/notifications/store.js +416 -0
- package/dist/apps/shared/notifications/systemErrors.d.ts +9 -0
- package/dist/apps/shared/notifications/systemErrors.js +58 -0
- package/dist/apps/shared/notifications/useNotifications.d.ts +17 -0
- package/dist/apps/shared/notifications/useNotifications.js +18 -0
- package/dist/apps/shared/signing/enrollmentStorage.d.ts +10 -0
- package/dist/apps/shared/signing/enrollmentStorage.js +33 -0
- package/dist/apps/shared/signing/enrollmentTracker.d.ts +73 -0
- package/dist/apps/shared/signing/enrollmentTracker.js +307 -0
- package/dist/apps/shared/signing/enrollmentView.d.ts +57 -0
- package/dist/apps/shared/signing/enrollmentView.js +214 -0
- package/dist/apps/shared/signing/signingInfo.d.ts +11 -0
- package/dist/apps/shared/signing/signingInfo.js +48 -0
- package/dist/apps/shared/signing/useNow.d.ts +2 -0
- package/dist/apps/shared/signing/useNow.js +18 -0
- package/dist/apps/shared/signing/useSigningEnrollmentWatcher.d.ts +16 -0
- package/dist/apps/shared/signing/useSigningEnrollmentWatcher.js +87 -0
- package/dist/apps/shared/styles/app.css +80 -0
- package/dist/apps/www/_layout.d.ts +5 -1
- package/dist/apps/www/_layout.js +3 -2
- package/dist/apps/www/_routes.js +1 -0
- package/dist/apps/www/calendar/index.js +3 -4
- package/dist/apps/www/contacts/[uid].js +5 -7
- package/dist/apps/www/contacts/index.js +14 -21
- package/dist/apps/www/index.js +37 -19
- package/dist/apps/www/messages/[uid].js +3 -1
- package/dist/apps/www/settings/appearance/index.d.ts +4 -0
- package/dist/apps/www/settings/appearance/index.js +13 -0
- package/dist/apps/www/settings/auto-reply/index.js +3 -6
- package/dist/apps/www/settings/encryption/index.js +103 -95
- package/dist/apps/www/settings/filters/[uid].js +3 -1
- package/dist/apps/www/settings/filters/new/index.js +3 -1
- package/dist/apps/www/settings/privacy/index.js +5 -8
- package/dist/apps/www/settings/read-receipts/index.js +3 -6
- package/dist/apps/www/settings/sharing/index.js +19 -33
- package/dist/apps/www/settings/signatures/[uid].js +3 -1
- package/dist/apps/www/settings/signatures/new/index.js +3 -2
- package/dist/apps/www/tasks/index.js +8 -14
- package/package.json +2 -2
- package/apps/shared/components/mail/NewMailToasts.tsx +0 -141
- package/apps/shared/components/mail/compose/SendFailureAlert.tsx +0 -48
- package/dist/apps/shared/components/mail/NewMailToasts.d.ts +0 -19
- package/dist/apps/shared/components/mail/NewMailToasts.js +0 -55
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.d.ts +0 -14
- package/dist/apps/shared/components/mail/compose/SendFailureAlert.js +0 -11
|
@@ -6,17 +6,19 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
6
6
|
import { useEffect, useRef, useState } from "react";
|
|
7
7
|
import { HiOutlineArrowsPointingIn, HiOutlineArrowsPointingOut, HiOutlineChevronDown, HiOutlineLockClosed, HiOutlineMinus, HiOutlinePaperClip, HiOutlineTrash, HiOutlineXMark, } from "react-icons/hi2";
|
|
8
8
|
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
9
|
-
import { assembleDraft,
|
|
9
|
+
import { assembleDraft, attachmentContentUrl, createDraft, deleteMessage, getMailbox, getMessage, listFolders, listMailboxes, uploadAttachment, } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
10
10
|
import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
|
|
11
|
-
import { describeSendFailure } from "@rapidmx/react-shared/mail/sendFailure.js";
|
|
12
11
|
import { buildComposeBodyHtml } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
|
|
13
12
|
import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
|
|
14
|
-
import {
|
|
13
|
+
import { resolveRecipientEncryption } from "@rapidmx/react-shared/crypto/composeSecurity.js";
|
|
15
14
|
import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
16
15
|
import { findActivePublicKey, getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
17
16
|
import { useUnlockPrompt } from "../../layout/UnlockPromptProvider.js";
|
|
18
|
-
import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
|
|
19
17
|
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
18
|
+
import { notify } from "../../../notifications/store.js";
|
|
19
|
+
import { evaluateEncryptionRequirement } from "./encryptionRequirement.js";
|
|
20
|
+
import { decideSend } from "../../../mail/outbox/sendDecision.js";
|
|
21
|
+
import { sendDecisionInput, startSend } from "../../../mail/outbox/sendJob.js";
|
|
20
22
|
import { useCompose } from "./ComposeContext.js";
|
|
21
23
|
import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
|
|
22
24
|
import { SHORTCUTS } from "../../../keyboard/keymap.js";
|
|
@@ -28,48 +30,25 @@ import RecipientInput from "./RecipientInput.js";
|
|
|
28
30
|
import { parseRecipientList } from "./recipients.js";
|
|
29
31
|
import RichTextEditor from "./RichTextEditor.js";
|
|
30
32
|
import ScheduleSendPicker from "./ScheduleSendPicker.js";
|
|
31
|
-
import SendFailureAlert from "./SendFailureAlert.js";
|
|
32
33
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
33
34
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
34
35
|
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
35
36
|
/** Debounce between the last edit and the draft autosave. */
|
|
36
37
|
export const DEFAULT_AUTOSAVE_DELAY_MS = 2000;
|
|
37
|
-
/** Automatic retries of a failed mailbox/encryption-policy
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
* (an uploaded attachment previewed via its content URL, or a `cid:` reference) would never be carried -
|
|
41
|
-
* only `assembleDraft()`'s server-side plaintext path rewrites those into real MIME parts. */
|
|
42
|
-
const INLINE_IMAGE_PATTERN = /<img\b[^>]*\bsrc\s*=\s*["']?(?:cid:|[^"'\s>]*\/mail\/attachments\/[^"'\s>]+\/content)/i;
|
|
43
|
-
const KEYS_LOCKED_SIGN_MESSAGE = "This message can't be signed right now - your signing key is locked or your mailbox details couldn't be loaded. Unlock and send again, or send it without signing.";
|
|
44
|
-
const KEYS_LOCKED_ENCRYPT_MESSAGE = "This message can't be encrypted right now - your encryption key is locked. Unlock and send again, or send it without encryption.";
|
|
45
|
-
const POLICY_UNAVAILABLE_MESSAGE = "Your encryption settings couldn't be loaded, so this message can't be encrypted right now. Try again, or send it without encryption.";
|
|
46
|
-
const BCC_ENCRYPTED_MESSAGE = "Bcc recipients can't be used with encrypted messages. Remove Bcc recipients or turn off encryption.";
|
|
47
|
-
const LOOKUP_UNAVAILABLE_MESSAGE = "Your recipients' encryption keys couldn't be checked, so this message might go out unencrypted when it should be encrypted. Try again, or send it without encryption.";
|
|
38
|
+
/** Automatic, silent retries of a failed mailbox/encryption-policy (or recipient key) load, one entry per retry - a flapping gateway is waited out
|
|
39
|
+
* in the background, without a word to the user. */
|
|
40
|
+
export const DEFAULT_CRYPTO_RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 15000, 30000, 60000];
|
|
48
41
|
/** Thrown by `deleteDraft()` when the server copy is no longer this window's draft (sent or scheduled elsewhere). */
|
|
49
42
|
const NO_LONGER_A_DRAFT_MESSAGE = "This message was already sent or scheduled from another window, so it wasn't deleted.";
|
|
50
|
-
/** The server refuses signed/encrypted mail whose From display name differs from its own safe form, which drops a
|
|
51
|
-
* name containing `@` (or a look-alike) or a line break. */
|
|
52
|
-
const UNSAFE_DISPLAY_NAME_PATTERN = /[@@﹫\r\n]/;
|
|
53
43
|
/** Whether `fresh` (a re-read of a draft) is still an unsent draft in `folderUid`: never delete a message another
|
|
54
44
|
* window has since sent, scheduled, or started sending. */
|
|
55
45
|
function isStillDraft(fresh, folderUid) {
|
|
56
46
|
return fresh.folderUid === folderUid && !fresh.scheduledSendTime && !fresh.scheduledSendLeaseExpiresAt && !fresh.scheduledSendRelayedAt;
|
|
57
47
|
}
|
|
58
|
-
/** Any tier of `policy` auto-encrypts - only then can a message with recipients still to be added turn out encrypted
|
|
59
|
-
* without the user asking. */
|
|
60
|
-
function policyCanAutoEncrypt(policy) {
|
|
61
|
-
return [policy.encryptSameOrg, policy.encryptFederated, policy.encryptExternal].includes("automatic");
|
|
62
|
-
}
|
|
63
48
|
const SAVE_STATUS_LABEL = { idle: "", saving: "Saving…", saved: "Draft saved", error: "Couldn't save draft" };
|
|
64
49
|
const DISCARD_TITLE = "Discard this draft?";
|
|
50
|
+
/** Shown only for a message that is actually headed for encryption (asked for, or applied by the loaded policy) - see `encryptionRequirement.ts`. */
|
|
65
51
|
const ENCRYPTED_CLOSE_MESSAGE = "Encrypted messages aren't saved as drafts, so closing this window discards what you've written.";
|
|
66
|
-
const CHECKING_CLOSE_MESSAGE = "This message may be encrypted, and that's still being checked, so it can't be saved as a draft yet. Keep editing and close again in a moment, or discard it.";
|
|
67
|
-
const CRYPTO_UNAVAILABLE_MESSAGE = "Your encryption settings couldn't be checked, so this draft isn't being saved - it might be a message that must be encrypted.";
|
|
68
|
-
const NO_RECIPIENTS_CLOSE_MESSAGE = "This message may be encrypted once its recipients are known, so it isn't saved as a draft until you add them. Add a recipient and close again, or discard it.";
|
|
69
|
-
const SEND_FAILED_TITLE = "This message wasn't sent";
|
|
70
|
-
function sendFailedCloseMessage(reason) {
|
|
71
|
-
return `Sending it failed (${reason.replace(/\.$/, "")}). Closing keeps it as a draft, unsent. Keep editing to try again, or discard it.`;
|
|
72
|
-
}
|
|
73
52
|
const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
|
|
74
53
|
const FIELD_INPUT = "flex-1 min-w-0 text-sm bg-transparent outline-none";
|
|
75
54
|
const MIN_WIDTH = 320;
|
|
@@ -104,7 +83,7 @@ function HeaderButton({ label, onClick, icon: Icon, disabled, shortcut, }) {
|
|
|
104
83
|
* once.
|
|
105
84
|
*/
|
|
106
85
|
export default function ComposeWindow({ session, onClose, onToggleMinimize, userUid, trusted, autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS, cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS, }) {
|
|
107
|
-
const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized, quotePending, late } = session;
|
|
86
|
+
const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized, quotePending, late, resume } = session;
|
|
108
87
|
// What a reply worked out after this window opened (see `OpenComposeInput.pending`): better recipients, applied below
|
|
109
88
|
// only to fields still exactly as they opened - so these, not `initialTo`/`initialCc`, are what "untouched" is measured against.
|
|
110
89
|
const baselineTo = late?.to ?? initialTo;
|
|
@@ -121,7 +100,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
121
100
|
const [mailboxes, setMailboxes] = useState([]);
|
|
122
101
|
// Attachments and inline images upload onto the current draft's uid, which lives in one mailbox's
|
|
123
102
|
// Drafts folder - so once anything has been uploaded the sender can no longer be switched.
|
|
124
|
-
const [hasUploads, setHasUploads] = useState(
|
|
103
|
+
const [hasUploads, setHasUploads] = useState(!!resume && resume.attachments.length > 0);
|
|
125
104
|
const isMobile = useIsMobile();
|
|
126
105
|
const { requestUnlock } = useUnlockPrompt();
|
|
127
106
|
const { openCompose } = useCompose();
|
|
@@ -132,26 +111,29 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
132
111
|
const windowRef = useRef(null);
|
|
133
112
|
const [draftsFolderUid, setDraftsFolderUid] = useState();
|
|
134
113
|
const [folderError, setFolderError] = useState(null);
|
|
135
|
-
|
|
114
|
+
// A window re-opened around a message that was already composed (a failed send's "Open draft") continues that server draft and starts with
|
|
115
|
+
// every field as it was typed; its body is not seeded from a signature or a quote.
|
|
116
|
+
const [draft, setDraft] = useState(resume?.draft ?? null);
|
|
136
117
|
// Drafts replaced by a From switch, awaiting deletion until their replacement has actually been created.
|
|
137
118
|
const supersededDraftsRef = useRef([]);
|
|
138
119
|
const [draftError, setDraftError] = useState(null);
|
|
139
120
|
const [expanded, setExpanded] = useState(false);
|
|
140
121
|
const [manualSize, setManualSize] = useState(null);
|
|
141
|
-
const [to, setTo] = useState(initialTo ?? "");
|
|
142
|
-
const [cc, setCc] = useState(initialCc ?? "");
|
|
143
|
-
const [bcc, setBcc] = useState("");
|
|
144
|
-
const [showCcBcc, setShowCcBcc] = useState(!!initialCc);
|
|
145
|
-
const [subject, setSubject] = useState(initialSubject ?? "");
|
|
146
|
-
const [html, setHtml] = useState("");
|
|
147
|
-
const [contentReady, setContentReady] = useState(
|
|
148
|
-
const [attachments, setAttachments] = useState([]);
|
|
122
|
+
const [to, setTo] = useState(resume?.to ?? initialTo ?? "");
|
|
123
|
+
const [cc, setCc] = useState(resume?.cc ?? initialCc ?? "");
|
|
124
|
+
const [bcc, setBcc] = useState(resume?.bcc ?? "");
|
|
125
|
+
const [showCcBcc, setShowCcBcc] = useState(!!(resume ? resume.cc || resume.bcc : initialCc));
|
|
126
|
+
const [subject, setSubject] = useState(resume?.subject ?? initialSubject ?? "");
|
|
127
|
+
const [html, setHtml] = useState(resume?.html ?? "");
|
|
128
|
+
const [contentReady, setContentReady] = useState(!!resume);
|
|
129
|
+
const [attachments, setAttachments] = useState(resume?.attachments ?? []);
|
|
149
130
|
const [attachError, setAttachError] = useState(null);
|
|
131
|
+
// A problem with the message as composed (no recipient, an attachment that can't be signed): shown here, and the window stays open. What
|
|
132
|
+
// goes wrong after Send was accepted is a pop-up instead (see `sendJob.ts`).
|
|
150
133
|
const [sendError, setSendError] = useState(null);
|
|
151
|
-
//
|
|
152
|
-
const [
|
|
153
|
-
const [
|
|
154
|
-
const [requestReceipt, setRequestReceipt] = useState(false);
|
|
134
|
+
// Send was pressed while an attachment was still uploading: the window waits (and says so) rather than send without it.
|
|
135
|
+
const [waitingForUploads, setWaitingForUploads] = useState(false);
|
|
136
|
+
const [requestReceipt, setRequestReceipt] = useState(!!resume?.requestReceipt);
|
|
155
137
|
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
|
|
156
138
|
const scheduleButtonRef = useRef(null);
|
|
157
139
|
const [mailbox, setMailbox] = useState(null);
|
|
@@ -160,11 +142,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
160
142
|
// Digital Signatures - a list that appends a footer after signing invalidates the signature) - see
|
|
161
143
|
// `MessageDetailPane.tsx`'s handleReply()/handleReplyAll() for where this is computed. The user can
|
|
162
144
|
// still turn it back on; this only changes the default.
|
|
163
|
-
const [signEnabled, setSignEnabled] = useState(!suppressSigning);
|
|
145
|
+
const [signEnabled, setSignEnabled] = useState(resume ? resume.signEnabled : !suppressSigning);
|
|
164
146
|
// A reply to or forward of an encrypted message starts (and, after a From switch, restarts) with encryption
|
|
165
147
|
// requested - see `OpenComposeInput.encrypt`.
|
|
166
|
-
const [encryptRequested, setEncryptRequested] = useState(!!initialEncrypt);
|
|
167
|
-
const [encryptionBlocked, setEncryptionBlocked] = useState(null);
|
|
148
|
+
const [encryptRequested, setEncryptRequested] = useState(resume ? resume.encryptRequested : !!initialEncrypt);
|
|
168
149
|
// Compose-time discovery (spec: "Discovery occurs ... when the user addresses a new message to a
|
|
169
150
|
// recipient", never on receipt) - keyed by address so a recipient already looked up isn't re-fetched
|
|
170
151
|
// just because the user re-focuses the field. Only ever grows via `checkRecipientDiscovery()` below;
|
|
@@ -174,16 +155,14 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
174
155
|
// look one up twice), and a counter bumped by a From switch so a lookup for the previous sender is dropped.
|
|
175
156
|
const lookupsInFlightRef = useRef(new Set());
|
|
176
157
|
const discoveryGenerationRef = useRef(0);
|
|
177
|
-
// A failed lookup stores no status
|
|
178
|
-
//
|
|
179
|
-
// run out the address is "exhausted" - shown as a check that couldn't be done, with Retry.
|
|
158
|
+
// A failed lookup stores no status: the recipient is simply "not known to be encryptable" (fail open - see `encryptionRequirement.ts`), and
|
|
159
|
+
// the lookup is retried in the background with the same backoff as the mailbox/policy load, without a word to the user.
|
|
180
160
|
const lookupFailuresRef = useRef(new Map());
|
|
181
161
|
const lookupRetryTimersRef = useRef(new Set());
|
|
182
162
|
const [lookupRetryToken, setLookupRetryToken] = useState(0);
|
|
183
|
-
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
// silent downgrade to plaintext; the user has to explicitly pick the override.
|
|
163
|
+
// A send refused *before it left* because signing/encryption was asked for (or the loaded policy applies it) but can't happen right now
|
|
164
|
+
// (keys locked, Bcc recipients on an encrypted message, a recipient without a key) - never a silent downgrade to plaintext; the user
|
|
165
|
+
// has to explicitly pick the override.
|
|
187
166
|
const [securityBlock, setSecurityBlock] = useState(null);
|
|
188
167
|
// The schedule time of the send that got blocked (undefined for an immediate send), so the banners'
|
|
189
168
|
// override buttons replay the same kind of send the user originally asked for.
|
|
@@ -204,8 +183,11 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
204
183
|
const lastSavedRef = useRef(null);
|
|
205
184
|
/** An edit is waiting on the autosave debounce - flushed if the window unmounts first. */
|
|
206
185
|
const pendingSaveRef = useRef(false);
|
|
207
|
-
/**
|
|
208
|
-
const
|
|
186
|
+
/** Send was pressed and is being validated/handed over: a second press (a double click, the shortcut held or repeated) does nothing. */
|
|
187
|
+
const submittingRef = useRef(false);
|
|
188
|
+
/** Attachment/inline-image uploads on the wire, and who is waiting for them to finish (Send pressed meanwhile). */
|
|
189
|
+
const uploadsInFlightRef = useRef(0);
|
|
190
|
+
const uploadWaitersRef = useRef([]);
|
|
209
191
|
/** The latest save request, if any - each save is chained after the one before it. Always settles (never
|
|
210
192
|
* rejects), to the saved message, or `undefined` when that save failed. */
|
|
211
193
|
const saveInFlightRef = useRef(Promise.resolve(undefined));
|
|
@@ -213,13 +195,10 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
213
195
|
const finishedRef = useRef(false);
|
|
214
196
|
// A mailbox with no keys enrolled yet just means sign/encrypt stay unavailable for this compose session -
|
|
215
197
|
// encryption is optional and gradual by design (see `KeyEnrollmentGate`'s own doc comment).
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
const [cryptoContextReady, setCryptoContextReady] = useState(false);
|
|
221
|
-
const [cryptoLoadFailed, setCryptoLoadFailed] = useState(false);
|
|
222
|
-
const [cryptoRetryToken, setCryptoRetryToken] = useState(0);
|
|
198
|
+
// The mailbox and the encryption policy load in the background and *nothing waits for them*: until both have loaded, the message is treated as
|
|
199
|
+
// not encrypted - its draft saves, Close and Send work, and no message about it is shown (see `encryptionRequirement.ts`). A failed load is
|
|
200
|
+
// retried silently with backoff (`cryptoRetryDelaysMs`); if it later turns out the message must be encrypted, `encryptionDecided` (below) starts
|
|
201
|
+
// to apply - see the transition rules there.
|
|
223
202
|
useEffect(() => {
|
|
224
203
|
let cancelled = false;
|
|
225
204
|
listMailboxes({ limit: 100 })
|
|
@@ -254,8 +233,6 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
254
233
|
let mailboxLoaded = mailbox?.uid === mailboxUid;
|
|
255
234
|
let policyLoaded = !!encryptionPolicy;
|
|
256
235
|
let retries = 0;
|
|
257
|
-
setCryptoContextReady(false);
|
|
258
|
-
setCryptoLoadFailed(false);
|
|
259
236
|
async function load() {
|
|
260
237
|
await Promise.all([
|
|
261
238
|
mailboxLoaded ||
|
|
@@ -273,13 +250,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
273
250
|
}
|
|
274
251
|
}, () => undefined),
|
|
275
252
|
]);
|
|
276
|
-
if (cancelled) {
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
const failed = !mailboxLoaded || !policyLoaded;
|
|
280
|
-
setCryptoContextReady(true);
|
|
281
|
-
setCryptoLoadFailed(failed);
|
|
282
|
-
if (failed && retries < cryptoRetryDelaysMs.length) {
|
|
253
|
+
if (!cancelled && (!mailboxLoaded || !policyLoaded) && retries < cryptoRetryDelaysMs.length) {
|
|
283
254
|
timer = setTimeout(() => void load(), cryptoRetryDelaysMs[retries++]);
|
|
284
255
|
}
|
|
285
256
|
}
|
|
@@ -288,7 +259,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
288
259
|
cancelled = true;
|
|
289
260
|
clearTimeout(timer);
|
|
290
261
|
};
|
|
291
|
-
}, [mailboxUid
|
|
262
|
+
}, [mailboxUid]);
|
|
292
263
|
useEffect(() => {
|
|
293
264
|
if (!mailboxUid) {
|
|
294
265
|
return;
|
|
@@ -315,14 +286,14 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
315
286
|
* Runs discovery for whichever of `addresses` haven't already been looked up this compose session,
|
|
316
287
|
* caching the result so the small per-recipient indicator below the recipient fields can update
|
|
317
288
|
* without waiting for Send. Called from the To/Cc/Bcc fields' own `onBlur` - not on every keystroke,
|
|
318
|
-
* and not the
|
|
289
|
+
* and not the lookup the background send (`sendJob.ts`) performs again at send time (deliberately: this is a
|
|
319
290
|
* best-effort UI hint, not something a stale value here should be trusted to skip at send time).
|
|
320
291
|
* A mailbox/policy fetch that hasn't resolved yet just leaves that address with no indicator - the effect
|
|
321
292
|
* below runs it again for every current recipient once both have loaded. A failed lookup leaves it without
|
|
322
|
-
* a status too, and is retried after `cryptoRetryDelaysMs` (then only on blur
|
|
293
|
+
* a status too, and is retried silently after `cryptoRetryDelaysMs` (then only on blur).
|
|
323
294
|
*
|
|
324
|
-
* The statuses also drive
|
|
325
|
-
*
|
|
295
|
+
* The statuses also drive `encryptionRequirement.ts`: only when every recipient has one, and the loaded policy
|
|
296
|
+
* auto-encrypts for all of them, does the message turn into one that is never saved as a plaintext draft.
|
|
326
297
|
*/
|
|
327
298
|
function checkRecipientDiscovery(addresses) {
|
|
328
299
|
if (!mailbox || !encryptionPolicy) {
|
|
@@ -351,12 +322,11 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
351
322
|
});
|
|
352
323
|
}
|
|
353
324
|
}
|
|
354
|
-
/** Schedules the next automatic retry of a failed lookup
|
|
325
|
+
/** Schedules the next automatic, silent retry of a failed lookup (until the backoff has run out; a later blur tries again). */
|
|
355
326
|
function noteLookupFailed(address) {
|
|
356
327
|
const failures = (lookupFailuresRef.current.get(address) ?? 0) + 1;
|
|
357
328
|
lookupFailuresRef.current.set(address, failures);
|
|
358
329
|
if (failures > cryptoRetryDelaysMs.length) {
|
|
359
|
-
setExhaustedLookups((prev) => ({ ...prev, [address]: true }));
|
|
360
330
|
return;
|
|
361
331
|
}
|
|
362
332
|
const timer = setTimeout(() => {
|
|
@@ -375,7 +345,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
375
345
|
useEffect(() => {
|
|
376
346
|
checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
|
|
377
347
|
}, [mailbox, encryptionPolicy, lookupRetryToken]);
|
|
378
|
-
// Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it
|
|
348
|
+
// Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it (not for a window re-opened around a composed message)
|
|
379
349
|
// plus any quoted original message (laid out by `buildComposeBodyHtml()`: an empty first paragraph for the
|
|
380
350
|
// caret, then the signature, then the quote), before `RichTextEditor` ever mounts (gated by `contentReady`
|
|
381
351
|
// below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
|
|
@@ -523,9 +493,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
523
493
|
discoveryGenerationRef.current += 1;
|
|
524
494
|
lookupsInFlightRef.current = new Set();
|
|
525
495
|
lookupFailuresRef.current = new Map();
|
|
526
|
-
setExhaustedLookups({});
|
|
527
496
|
setEncryptRequested(!!initialEncrypt);
|
|
528
|
-
setEncryptionBlocked(null);
|
|
529
497
|
setSecurityBlock(null);
|
|
530
498
|
offeredCryptoRef.current = { sign: false, encrypt: false };
|
|
531
499
|
setFromMailboxUid(nextMailboxUid);
|
|
@@ -581,6 +549,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
581
549
|
setAttachError("Please wait for the draft to finish loading before inserting an image.");
|
|
582
550
|
return null;
|
|
583
551
|
}
|
|
552
|
+
uploadStarted();
|
|
584
553
|
try {
|
|
585
554
|
const attachment = await uploadAttachment(draft.uid, file);
|
|
586
555
|
setHasUploads(true);
|
|
@@ -591,6 +560,21 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
591
560
|
setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload image.");
|
|
592
561
|
return null;
|
|
593
562
|
}
|
|
563
|
+
finally {
|
|
564
|
+
uploadFinished();
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/** Send waits for every upload started before it: see `submit()`. */
|
|
568
|
+
function uploadStarted() {
|
|
569
|
+
uploadsInFlightRef.current += 1;
|
|
570
|
+
}
|
|
571
|
+
function uploadFinished() {
|
|
572
|
+
uploadsInFlightRef.current -= 1;
|
|
573
|
+
if (uploadsInFlightRef.current === 0) {
|
|
574
|
+
for (const resolve of uploadWaitersRef.current.splice(0)) {
|
|
575
|
+
resolve();
|
|
576
|
+
}
|
|
577
|
+
}
|
|
594
578
|
}
|
|
595
579
|
/** Uploading an attachment bumps the draft's version server-side (it records `hasAttachments`), so a later
|
|
596
580
|
* Discard would otherwise send a stale `?version=`. Best-effort: Discard also retries with the server's
|
|
@@ -613,201 +597,110 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
613
597
|
const files = Array.from(e.target.files ?? []);
|
|
614
598
|
e.target.value = "";
|
|
615
599
|
setAttachError(null);
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
/**
|
|
629
|
-
* Builds this draft's final body — plaintext, signed-only, or signed+encrypted per
|
|
630
|
-
* `specs/end-to-end_encryption.md` — then stores it via `assembleDraft()`/`assembleDraftRaw()`.
|
|
631
|
-
* Shared by immediate and scheduled sends (and the banners' override buttons rendered below, via
|
|
632
|
-
* `forcePlaintext`) so the sign/encrypt decision lives in exactly one place.
|
|
633
|
-
*
|
|
634
|
-
* Returns `"blocked"` (never throws for this case) whenever the message can't go out the way the user
|
|
635
|
-
* asked for — never a silent downgrade to plaintext:
|
|
636
|
-
* encryption was requested (or auto-applies) but not every recipient can currently be encrypted to -
|
|
637
|
-
* the spec's "Multiple Recipients" all-or-nothing rule (sets `encryptionBlocked`); signing/encryption was
|
|
638
|
-
* on offer this session but the keys have since locked, or the mailbox/encryption policy needed to decide
|
|
639
|
-
* couldn't be loaded (sets `securityBlock`, and re-prompts the unlock dialog when the keys are merely
|
|
640
|
-
* locked); or the message would be encrypted but has Bcc recipients - every recipient's certificate goes
|
|
641
|
-
* into one shared envelope, which would disclose the Bcc list to every other recipient, and the send API
|
|
642
|
-
* has no per-recipient-copy submission to avoid that (sets `securityBlock`).
|
|
643
|
-
* Callers must stop (not send) when they get this back. `forcePlaintext` (the user's explicit choice
|
|
644
|
-
* from one of those banners) skips every one of these checks and never encrypts; signing still
|
|
645
|
-
* happens if it's currently possible.
|
|
646
|
-
*
|
|
647
|
-
* Discovery (`lookupKeys()`) and the resulting sign/encrypt decision only run at this, the final
|
|
648
|
-
* pre-send step — `checkRecipientDiscovery()`'s on-blur badges are a best-effort hint only.
|
|
649
|
-
*/
|
|
650
|
-
async function assembleForSend(toRecipients, ccRecipients, bccRecipients, forcePlaintext) {
|
|
651
|
-
// Keys are read at the moment they're needed, and again after any await: they can lock (idle
|
|
652
|
-
// timeout, sign-out elsewhere) mid-send, and a destroyed `UnlockedKeys` object must count as locked.
|
|
653
|
-
// Only reachable once a draft exists, which itself requires `mailboxUid` to have resolved.
|
|
654
|
-
function readKeys() {
|
|
655
|
-
const stored = getUnlockedKeys(mailboxUid);
|
|
656
|
-
const unlocked = stored?.destroyed ? undefined : stored;
|
|
657
|
-
return {
|
|
658
|
-
unlocked,
|
|
659
|
-
// `mailbox` is required to build protected headers (own From address) whenever signing or
|
|
660
|
-
// encrypting - not just guarded on the encryption branch below, since a signed-only message
|
|
661
|
-
// needs it too.
|
|
662
|
-
canSign: signEnabled && !!mailbox && !!unlocked?.signingPrivateKey && !!unlocked.signingCertDer,
|
|
663
|
-
canEncryptSelf: !!unlocked?.encryptionPrivateKey && !!unlocked.encryptionCertDer,
|
|
664
|
-
};
|
|
665
|
-
}
|
|
666
|
-
let keys = readKeys();
|
|
667
|
-
const allRecipients = [...toRecipients, ...ccRecipients, ...bccRecipients];
|
|
668
|
-
// The Sign toggle was shown (and is still checked), but signing can't actually happen any more.
|
|
669
|
-
const signingLost = () => !forcePlaintext && signEnabled && offeredCryptoRef.current.sign && !keys.canSign;
|
|
670
|
-
if (signingLost()) {
|
|
671
|
-
return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
|
|
672
|
-
}
|
|
673
|
-
if (!forcePlaintext && !mailbox) {
|
|
674
|
-
// The mailbox's enrolled keys are what say whether this message could be encrypted at all - with keys
|
|
675
|
-
// that aren't unlocked in this session, a failed mailbox load would otherwise skip encryption silently.
|
|
676
|
-
return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
|
|
677
|
-
}
|
|
678
|
-
let wantEncrypt = false;
|
|
679
|
-
let recipientCertDers = [];
|
|
680
|
-
// Entered whenever this message could be encrypted at all - including a mailbox with an enrolled
|
|
681
|
-
// encryption key that this session never unlocked: policy may still auto-encrypt, and that must block
|
|
682
|
-
// (and prompt to unlock) rather than quietly send plaintext.
|
|
683
|
-
if (!forcePlaintext && (keys.canEncryptSelf || offeredCryptoRef.current.encrypt || hasEnrolledEncryptionKey)) {
|
|
684
|
-
if (!encryptionPolicy) {
|
|
685
|
-
// Without it, there's no telling whether policy auto-encrypts this message.
|
|
686
|
-
return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
|
|
687
|
-
}
|
|
688
|
-
// Reachable only with `mailbox` loaded (checked above).
|
|
689
|
-
const ownPrefersMutual = mailbox.encryptPreference?.preferEncrypt === "mutual";
|
|
690
|
-
const lookups = await Promise.all(allRecipients.map((r) => lookupKeys(mailboxUid, r.address).then((lookup) => ({ lookup }), () => null)));
|
|
691
|
-
keys = readKeys();
|
|
692
|
-
if (signingLost()) {
|
|
693
|
-
return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
|
|
694
|
-
}
|
|
695
|
-
// A failed lookup isn't "no key": treating it as one could send plaintext that policy would encrypt.
|
|
696
|
-
if (lookups.some((result) => !result) && (encryptRequested || policyCanAutoEncrypt(encryptionPolicy))) {
|
|
697
|
-
return blockSend(LOOKUP_UNAVAILABLE_MESSAGE, "Send without encryption", false);
|
|
698
|
-
}
|
|
699
|
-
const statuses = allRecipients.map((r, i) => resolveRecipientEncryption(mailbox.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, r.address, lookups[i]?.lookup));
|
|
700
|
-
const decision = decideMessageEncryption(statuses);
|
|
701
|
-
wantEncrypt = encryptRequested || decision.autoEncrypt;
|
|
702
|
-
if (wantEncrypt) {
|
|
703
|
-
if (!keys.canEncryptSelf) {
|
|
704
|
-
return blockSend(KEYS_LOCKED_ENCRYPT_MESSAGE, "Send without encryption", !keys.unlocked);
|
|
705
|
-
}
|
|
706
|
-
if (!decision.canEncryptAll) {
|
|
707
|
-
setEncryptionBlocked(decision.blockedRecipients);
|
|
708
|
-
return "blocked";
|
|
600
|
+
uploadStarted();
|
|
601
|
+
try {
|
|
602
|
+
for (const file of files) {
|
|
603
|
+
try {
|
|
604
|
+
const attachment = await uploadAttachment(draft.uid, file);
|
|
605
|
+
setHasUploads(true);
|
|
606
|
+
setAttachments((prev) => [...prev, attachment]);
|
|
607
|
+
void refreshDraftVersion(draft.uid);
|
|
709
608
|
}
|
|
710
|
-
|
|
711
|
-
|
|
609
|
+
catch (err) {
|
|
610
|
+
setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
|
|
712
611
|
}
|
|
713
|
-
recipientCertDers = [keys.unlocked.encryptionCertDer, ...statuses.map((s) => fromBase64(s.encryptCert.publicKey))];
|
|
714
612
|
}
|
|
715
613
|
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
// Matches BaseMailComposeRoute.assembleRaw()'s own server-side rejection, surfaced here with a
|
|
719
|
-
// clearer explanation than that route's generic 400 rather than via a round trip.
|
|
720
|
-
throw new ApiRequestError("A signed or encrypted message cannot include file attachments yet - remove them before sending.", 400);
|
|
721
|
-
}
|
|
722
|
-
if ((wantEncrypt || canSign) && INLINE_IMAGE_PATTERN.test(html)) {
|
|
723
|
-
throw new ApiRequestError("A signed or encrypted message cannot include inline images yet - remove them before sending.", 400);
|
|
724
|
-
}
|
|
725
|
-
if (!wantEncrypt && !canSign) {
|
|
726
|
-
// See the old compose page's identical note: `sanitize-html` is Node-oriented and the server-side
|
|
727
|
-
// gate in `BaseMailComposeRoute.assemble()` is the sole authoritative sanitizer regardless, so no
|
|
728
|
-
// client-side pass is done here either.
|
|
729
|
-
return assembleDraft(draft.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject, html });
|
|
730
|
-
}
|
|
731
|
-
const domain = mailbox.primarySmtpAddress.split("@")[1] ?? "localhost";
|
|
732
|
-
const displayName = mailbox.displayName && !UNSAFE_DISPLAY_NAME_PATTERN.test(mailbox.displayName) ? mailbox.displayName : "";
|
|
733
|
-
const protectedHeaders = {
|
|
734
|
-
from: displayName
|
|
735
|
-
? `"${displayName.replace(/"/g, '\\"')}" <${mailbox.primarySmtpAddress}>`
|
|
736
|
-
: mailbox.primarySmtpAddress,
|
|
737
|
-
to: toRecipients.map((r) => r.address).join(", "),
|
|
738
|
-
cc: ccRecipients.length > 0 ? ccRecipients.map((r) => r.address).join(", ") : undefined,
|
|
739
|
-
date: new Date().toUTCString(),
|
|
740
|
-
subject,
|
|
741
|
-
messageId: `<${crypto.randomUUID()}@${domain}>`,
|
|
742
|
-
};
|
|
743
|
-
const bodyContentType = 'text/html; charset="utf-8"';
|
|
744
|
-
const signing = canSign ? { certDer: keys.unlocked.signingCertDer, privateKey: keys.unlocked.signingPrivateKey } : undefined;
|
|
745
|
-
// The S/MIME code (PKI.js and the ASN.1/X.509 libraries, over half a megabyte) is only needed here, to sign or
|
|
746
|
-
// encrypt - a plain message never loads it, and it is kept out of the compose window's own chunk.
|
|
747
|
-
const { applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } = await import("@rapidmx/react-shared/crypto/smimeMessage.js");
|
|
748
|
-
const outerHeaders = wantEncrypt ? applyBaselineOuterHeaders(protectedHeaders) : protectedHeaders;
|
|
749
|
-
const mimePart = wantEncrypt
|
|
750
|
-
? await buildEncryptedMessage(bodyContentType, html, protectedHeaders, outerHeaders, recipientCertDers, signing)
|
|
751
|
-
: await buildSignedOnlyMessage(bodyContentType, html, protectedHeaders, signing.certDer, signing.privateKey);
|
|
752
|
-
const rawMime = assembleOutboundMime(outerHeaders, mimePart);
|
|
753
|
-
return assembleDraftRaw(draft.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject: outerHeaders.subject, rawMime });
|
|
754
|
-
}
|
|
755
|
-
/** Records why a send was refused (see `assembleForSend()`), re-prompting the unlock dialog when the
|
|
756
|
-
* cause is keys that are enrolled but locked. */
|
|
757
|
-
function blockSend(message, overrideLabel, keysLocked) {
|
|
758
|
-
setSecurityBlock({ message, overrideLabel });
|
|
759
|
-
if (keysLocked && (hasEnrolledSigningKey || hasEnrolledEncryptionKey)) {
|
|
760
|
-
void handleUnlockForCrypto();
|
|
614
|
+
finally {
|
|
615
|
+
uploadFinished();
|
|
761
616
|
}
|
|
762
|
-
return "blocked";
|
|
763
617
|
}
|
|
764
618
|
/**
|
|
765
|
-
*
|
|
766
|
-
*
|
|
767
|
-
*
|
|
768
|
-
*
|
|
769
|
-
*
|
|
770
|
-
*
|
|
771
|
-
*
|
|
619
|
+
* Send (or, given `scheduledSendTimeIso`, Send later) - and get out of the way. The window is the place the message is *written*; nothing
|
|
620
|
+
* about getting it to the server is waited for here.
|
|
621
|
+
*
|
|
622
|
+
* 1. **Validate synchronously**, from what the window already knows (no request): a recipient is required; and if the message is one
|
|
623
|
+
* that must be signed or encrypted and can't be right now (keys locked, Bcc on an encrypted message, a recipient with no key, an
|
|
624
|
+
* attachment that can't be signed) that is shown right here and the window stays open - the sender fixes it or picks the override.
|
|
625
|
+
* An attachment still uploading is waited for, inline (Send says so), never left behind.
|
|
626
|
+
* 2. **Close immediately** (`onClose()`): `startSend()` (see `sendJob.ts`) takes over with a snapshot of the message. From here the only
|
|
627
|
+
* feedback is the Outbox indicator; a failure at any stage is a sticky pop-up with the reason and Retry / Open draft.
|
|
628
|
+
*
|
|
629
|
+
* Pressed twice (a double click, the shortcut repeated) it sends once: the first press sets `submittingRef` before any `await`, and
|
|
630
|
+
* `startSend()` itself refuses a draft that is already being sent. `forcePlaintext` is the override buttons' choice.
|
|
772
631
|
*/
|
|
773
632
|
async function submit(forcePlaintext, scheduledSendTimeIso) {
|
|
774
|
-
|
|
775
|
-
|
|
633
|
+
if (submittingRef.current || finishedRef.current) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (parseAddresses(latestRef.current.to).length === 0) {
|
|
776
637
|
setSendError("At least one recipient is required.");
|
|
777
638
|
return;
|
|
778
639
|
}
|
|
779
|
-
|
|
780
|
-
sendingRef.current = true;
|
|
640
|
+
submittingRef.current = true;
|
|
781
641
|
setSendError(null);
|
|
782
|
-
setSendDetails([]);
|
|
783
642
|
setSecurityBlock(null);
|
|
784
|
-
setEncryptionBlocked(null);
|
|
785
|
-
pendingSaveRef.current = false;
|
|
786
643
|
try {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
644
|
+
if (uploadsInFlightRef.current > 0) {
|
|
645
|
+
setWaitingForUploads(true);
|
|
646
|
+
await new Promise((resolve) => uploadWaitersRef.current.push(resolve));
|
|
647
|
+
// The editor puts an image it has just uploaded into the message a moment after the upload resolves: let the window catch up.
|
|
648
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
649
|
+
setWaitingForUploads(false);
|
|
650
|
+
}
|
|
651
|
+
const current = latestRef.current;
|
|
652
|
+
const request = {
|
|
653
|
+
draft: current.draft,
|
|
654
|
+
mailboxUid: current.mailboxUid,
|
|
655
|
+
mailbox: current.mailbox ?? undefined,
|
|
656
|
+
policy: current.encryptionPolicy ?? undefined,
|
|
657
|
+
toText: current.to,
|
|
658
|
+
ccText: current.cc,
|
|
659
|
+
bccText: current.bcc,
|
|
660
|
+
to: parseAddresses(current.to),
|
|
661
|
+
cc: parseAddresses(current.cc),
|
|
662
|
+
bcc: parseAddresses(current.bcc),
|
|
663
|
+
subject: current.subject,
|
|
664
|
+
html: current.html,
|
|
665
|
+
attachments: current.attachments,
|
|
666
|
+
requestReceipt: current.requestReceipt,
|
|
667
|
+
scheduledSendTime: scheduledSendTimeIso,
|
|
668
|
+
signEnabled: current.signEnabled,
|
|
669
|
+
offeredSign: offeredCryptoRef.current.sign,
|
|
670
|
+
offeredEncrypt: offeredCryptoRef.current.encrypt,
|
|
671
|
+
encryptRequested: current.encryptRequested,
|
|
672
|
+
forcePlaintext,
|
|
673
|
+
saved: saveInFlightRef.current,
|
|
674
|
+
};
|
|
675
|
+
if (request.to.length === 0) {
|
|
676
|
+
setSendError("At least one recipient is required.");
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const early = decideSend(sendDecisionInput(request, request.mailbox, request.policy, current.statuses, false));
|
|
680
|
+
if (early.action === "rejected") {
|
|
681
|
+
setSendError(early.message);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (early.action === "blocked") {
|
|
791
685
|
setBlockedScheduleIso(scheduledSendTimeIso);
|
|
686
|
+
setSecurityBlock(early.block);
|
|
687
|
+
if (early.block.keysLocked && (hasEnrolledSigningKey || hasEnrolledEncryptionKey)) {
|
|
688
|
+
void handleUnlockForCrypto();
|
|
689
|
+
}
|
|
792
690
|
return;
|
|
793
691
|
}
|
|
794
|
-
|
|
795
|
-
// plaintext copy over it if the rest of the send fails.
|
|
796
|
-
lastSavedRef.current = `${assembled.uid}:${contentKey}`;
|
|
797
|
-
const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
|
|
798
|
-
await sendMessage(withReceipt.uid, scheduledSendTimeIso ? { scheduledSendTime: scheduledSendTimeIso } : undefined);
|
|
692
|
+
pendingSaveRef.current = false;
|
|
799
693
|
finishedRef.current = true;
|
|
694
|
+
if (!startSend(request)) {
|
|
695
|
+
finishedRef.current = false;
|
|
696
|
+
setSendError("This message is already being sent.");
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
800
699
|
onClose();
|
|
801
700
|
}
|
|
802
|
-
catch (err) {
|
|
803
|
-
// The window stays open with the draft exactly as it was; nothing here closes, discards or saves over it.
|
|
804
|
-
const failure = describeSendFailure(err, scheduledSendTimeIso ? "Could not schedule this message." : "Could not send this message.");
|
|
805
|
-
setSendError(failure.message);
|
|
806
|
-
setSendDetails(failure.lines);
|
|
807
|
-
}
|
|
808
701
|
finally {
|
|
809
|
-
|
|
810
|
-
|
|
702
|
+
submittingRef.current = false;
|
|
703
|
+
setWaitingForUploads(false);
|
|
811
704
|
}
|
|
812
705
|
}
|
|
813
706
|
function handleScheduleSend(scheduledSendTimeIso) {
|
|
@@ -817,7 +710,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
817
710
|
async function handleUnlockForCrypto() {
|
|
818
711
|
try {
|
|
819
712
|
// Only reachable when hasEnrolledSigningKey/hasEnrolledEncryptionKey found a real enrolled key
|
|
820
|
-
// in mailbox.keys (the unlock button below, or
|
|
713
|
+
// in mailbox.keys (the unlock button below, or a blocked send()), so neither is empty/undefined here.
|
|
821
714
|
await requestUnlock(mailbox.uid, mailbox.keys);
|
|
822
715
|
setUnlockRefresh((n) => n + 1);
|
|
823
716
|
}
|
|
@@ -832,7 +725,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
832
725
|
* `latestRef` only once it actually runs, so it saves what was last typed (skipping the request when the
|
|
833
726
|
* save before it already stored exactly that). Whether a save may happen at all is re-checked at that same
|
|
834
727
|
* moment: while it waited, the message may have become one that must not be stored as plaintext (Encrypt
|
|
835
|
-
* turned on, a recipient that auto-encrypts), or the window may have
|
|
728
|
+
* turned on, a recipient that auto-encrypts), or the window may have been sent or finished - then it
|
|
836
729
|
* resolves `{ skipped: true }` without a request (`message` then being the previous save's result). Never
|
|
837
730
|
* rejects; `message` is `undefined` when the save failed.
|
|
838
731
|
*/
|
|
@@ -844,7 +737,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
844
737
|
const outcome = (async () => {
|
|
845
738
|
const before = await previous;
|
|
846
739
|
const current = latestRef.current;
|
|
847
|
-
if (current.autosaveSuppressed ||
|
|
740
|
+
if (current.autosaveSuppressed || finishedRef.current) {
|
|
848
741
|
setSaveStatus("idle");
|
|
849
742
|
// Still the newest copy the server holds, for the version a later delete needs.
|
|
850
743
|
return { skipped: true, message: before };
|
|
@@ -963,39 +856,19 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
963
856
|
}
|
|
964
857
|
}
|
|
965
858
|
/** "Close": keeps the draft (saving any unsaved edits first, and closing only once that save succeeded) -
|
|
966
|
-
* unless there's nothing in it worth keeping, or it can't be saved because it's headed for encryption
|
|
967
|
-
*
|
|
968
|
-
*
|
|
969
|
-
*
|
|
970
|
-
|
|
971
|
-
async function handleClose(sendFailureAcknowledged = false) {
|
|
859
|
+
* unless there's nothing in it worth keeping, or it can't be saved because it's really headed for encryption
|
|
860
|
+
* (asked for, or applied by the loaded policy): that needs the same confirmation as a discard. Whatever is
|
|
861
|
+
* *unknown* (settings loading or failed, a lookup pending) is not a reason to hold the window: it is saved like any
|
|
862
|
+
* other message. A failed save keeps the window open with Discard / Keep editing. */
|
|
863
|
+
async function handleClose() {
|
|
972
864
|
if (!hasUserContent) {
|
|
973
865
|
void discardNow();
|
|
974
866
|
return;
|
|
975
867
|
}
|
|
976
|
-
if (sendError && !sendFailureAcknowledged) {
|
|
977
|
-
// Closing would keep the draft and say nothing, which reads as if it had been sent.
|
|
978
|
-
setClosePrompt({ title: SEND_FAILED_TITLE, message: sendFailedCloseMessage(sendError), closeAnyway: true });
|
|
979
|
-
return;
|
|
980
|
-
}
|
|
981
868
|
if (encryptionDecided) {
|
|
982
869
|
setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
|
|
983
870
|
return;
|
|
984
871
|
}
|
|
985
|
-
if (cryptoCheckUnavailable) {
|
|
986
|
-
setClosePrompt({ title: DISCARD_TITLE, message: CRYPTO_UNAVAILABLE_MESSAGE, retry: true });
|
|
987
|
-
return;
|
|
988
|
-
}
|
|
989
|
-
if (awaitingRecipients) {
|
|
990
|
-
setClosePrompt({ title: DISCARD_TITLE, message: NO_RECIPIENTS_CLOSE_MESSAGE });
|
|
991
|
-
return;
|
|
992
|
-
}
|
|
993
|
-
if (encryptionUndetermined) {
|
|
994
|
-
// A recipient typed but never blurred has no lookup running yet.
|
|
995
|
-
checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
|
|
996
|
-
setClosePrompt({ title: DISCARD_TITLE, message: CHECKING_CLOSE_MESSAGE });
|
|
997
|
-
return;
|
|
998
|
-
}
|
|
999
872
|
if (!draft || !contentReady) {
|
|
1000
873
|
saveErrorRef.current = draftError ?? "the draft hasn't been created yet";
|
|
1001
874
|
showSaveFailedPrompt();
|
|
@@ -1007,7 +880,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1007
880
|
setClosing(false);
|
|
1008
881
|
if (outcome.skipped) {
|
|
1009
882
|
// It became a message that mustn't be saved as plaintext while waiting on an earlier save.
|
|
1010
|
-
setClosePrompt({ title: DISCARD_TITLE, message:
|
|
883
|
+
setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
|
|
1011
884
|
return;
|
|
1012
885
|
}
|
|
1013
886
|
if (!outcome.message) {
|
|
@@ -1018,13 +891,6 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1018
891
|
finishedRef.current = true;
|
|
1019
892
|
onClose();
|
|
1020
893
|
}
|
|
1021
|
-
function retryCryptoContext() {
|
|
1022
|
-
setClosePrompt(null);
|
|
1023
|
-
setCryptoRetryToken((n) => n + 1);
|
|
1024
|
-
lookupFailuresRef.current = new Map();
|
|
1025
|
-
setExhaustedLookups({});
|
|
1026
|
-
setLookupRetryToken((n) => n + 1);
|
|
1027
|
-
}
|
|
1028
894
|
// A plain read from keySession.ts's module-level session store, not React state - see that module's
|
|
1029
895
|
// own doc comment. Cheap enough to read fresh on every render rather than caching in state.
|
|
1030
896
|
const unlockedKeys = mailboxUid ? getUnlockedKeys(mailboxUid) : undefined;
|
|
@@ -1056,27 +922,65 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1056
922
|
const contentKey = JSON.stringify([to, cc, bcc, subject, html]);
|
|
1057
923
|
const baselineKey = JSON.stringify([baselineTo ?? "", baselineCc ?? "", "", initialSubject ?? "", seededHtml ?? ""]);
|
|
1058
924
|
const hasUserContent = contentKey !== baselineKey || hasUploads;
|
|
1059
|
-
//
|
|
1060
|
-
//
|
|
1061
|
-
//
|
|
1062
|
-
//
|
|
1063
|
-
//
|
|
1064
|
-
//
|
|
925
|
+
// Whether this message is *really* headed for encryption - `encryptionRequirement.ts` decides, and it fails open: the message is plain (its
|
|
926
|
+
// draft saves, Close and Send work, nothing is shown) unless the user asked for encryption or the policy was loaded and applies it to every
|
|
927
|
+
// recipient (whose keys were all looked up). What has not loaded, or failed to, is not held against the message.
|
|
928
|
+
//
|
|
929
|
+
// Transitions, when a later load turns a plain message into an encrypted one (the policy arrives, a slow lookup answers, a recipient is
|
|
930
|
+
// added): (1) autosave stops at once (`autosaveSuppressed`); (2) a plaintext copy this window already saved is *replaced by an empty draft*
|
|
931
|
+
// (`scrubSavedDraft()` below) so the plaintext doesn't sit on the server; (3) Close asks first ("Encrypted messages aren't saved as drafts");
|
|
932
|
+
// (4) Send encrypts (or is refused, with the override, when it can't) - never silently plaintext. And back: when it stops being encrypted
|
|
933
|
+
// (a recipient removed) autosave simply resumes.
|
|
1065
934
|
const currentAddresses = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)].map((r) => r.address);
|
|
1066
935
|
const currentStatuses = currentAddresses.map((address) => recipientStatuses[address]);
|
|
1067
|
-
const
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
const
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
936
|
+
const hasEncryptionKey = !!unlockedKeys?.encryptionPrivateKey || hasEnrolledEncryptionKey || offeredCryptoRef.current.encrypt;
|
|
937
|
+
const requirement = evaluateEncryptionRequirement({
|
|
938
|
+
encryptRequested,
|
|
939
|
+
policy: encryptionPolicy ?? undefined,
|
|
940
|
+
mailboxLoaded: !!mailbox,
|
|
941
|
+
hasEncryptionKey,
|
|
942
|
+
recipients: currentStatuses,
|
|
943
|
+
});
|
|
944
|
+
const encryptionDecided = requirement.required;
|
|
945
|
+
const autosaveSuppressed = encryptionDecided;
|
|
946
|
+
const latestRef = useRef({
|
|
947
|
+
draft, mailboxUid, mailbox, encryptionPolicy, to, cc, bcc, subject, html, attachments, requestReceipt, signEnabled, encryptRequested,
|
|
948
|
+
statuses: currentStatuses, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided,
|
|
949
|
+
});
|
|
950
|
+
latestRef.current = {
|
|
951
|
+
draft, mailboxUid, mailbox, encryptionPolicy, to, cc, bcc, subject, html, attachments, requestReceipt, signEnabled, encryptRequested,
|
|
952
|
+
statuses: currentStatuses, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided,
|
|
953
|
+
};
|
|
954
|
+
/** A message that turned out to need encryption after a plaintext copy of it was saved: the saved copy is replaced by an empty draft (the same
|
|
955
|
+
* uid, so attachments and the folder are undisturbed), chained after any save still on the wire. Best effort - a failure is reported once. */
|
|
956
|
+
function scrubSavedDraft(target) {
|
|
957
|
+
lastSavedRef.current = null;
|
|
958
|
+
setSaveStatus("idle");
|
|
959
|
+
const previous = saveInFlightRef.current;
|
|
960
|
+
const scrub = (async () => {
|
|
961
|
+
await previous;
|
|
962
|
+
try {
|
|
963
|
+
// Its answer (the new version) is what a later delete waits for through `saveInFlightRef`, like any save's.
|
|
964
|
+
return await assembleDraft(target.uid, { to: [], cc: [], bcc: [], subject: "", html: "" });
|
|
965
|
+
}
|
|
966
|
+
catch {
|
|
967
|
+
notify({
|
|
968
|
+
id: `scrub-draft:${target.uid}`,
|
|
969
|
+
kind: "warning",
|
|
970
|
+
title: "An earlier draft copy is still saved",
|
|
971
|
+
message: "This message needs encryption, but the copy saved before that was known couldn't be replaced. Discarding the draft removes it.",
|
|
972
|
+
});
|
|
973
|
+
return undefined;
|
|
974
|
+
}
|
|
975
|
+
})();
|
|
976
|
+
saveInFlightRef.current = scrub;
|
|
977
|
+
}
|
|
978
|
+
// Only a message that was really saved (`lastSavedRef` names its draft) has a plaintext copy to replace.
|
|
979
|
+
useEffect(() => {
|
|
980
|
+
if (encryptionDecided && draft && lastSavedRef.current?.startsWith(`${draft.uid}:`)) {
|
|
981
|
+
scrubSavedDraft(draft);
|
|
982
|
+
}
|
|
983
|
+
}, [encryptionDecided, draft?.uid]);
|
|
1080
984
|
// Recipients a reply could only work out after this window opened (Reply All: whoever the original's headers name)
|
|
1081
985
|
// replace the ones it opened with - but only in a field the user hasn't touched, never over what they typed.
|
|
1082
986
|
useEffect(() => {
|
|
@@ -1095,8 +999,6 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1095
999
|
useEffect(() => {
|
|
1096
1000
|
const due = !!draft &&
|
|
1097
1001
|
contentReady &&
|
|
1098
|
-
cryptoContextReady &&
|
|
1099
|
-
!sending &&
|
|
1100
1002
|
!autosaveSuppressed &&
|
|
1101
1003
|
hasUserContent &&
|
|
1102
1004
|
lastSavedRef.current !== `${draft.uid}:${contentKey}`;
|
|
@@ -1106,7 +1008,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1106
1008
|
}
|
|
1107
1009
|
const timer = setTimeout(() => void saveDraftNow(), autosaveDelayMs);
|
|
1108
1010
|
return () => clearTimeout(timer);
|
|
1109
|
-
}, [draft, contentReady,
|
|
1011
|
+
}, [draft, contentReady, autosaveSuppressed, hasUserContent, contentKey]);
|
|
1110
1012
|
// Unmounting with an edit still waiting on the debounce (e.g. the whole app shell unmounting) saves it
|
|
1111
1013
|
// right away rather than losing it.
|
|
1112
1014
|
useEffect(() => () => {
|
|
@@ -1148,7 +1050,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1148
1050
|
// out - that navigation must not be cancellable.
|
|
1149
1051
|
useEffect(() => {
|
|
1150
1052
|
function handleBeforeUnload(event) {
|
|
1151
|
-
if (finishedRef.current ||
|
|
1053
|
+
if (finishedRef.current || isSigningOut()) {
|
|
1152
1054
|
return;
|
|
1153
1055
|
}
|
|
1154
1056
|
const latest = latestRef.current;
|
|
@@ -1168,7 +1070,7 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1168
1070
|
// Where the caret starts, once per session: a reply/forward in the body (at its top), a new message in To - or,
|
|
1169
1071
|
// with To already filled in (e.g. Contacts' "Email"), in Subject. Minimizing unmounts the fields, so restoring a
|
|
1170
1072
|
// window must not move the caret there again.
|
|
1171
|
-
const isReplyOrForward = initialQuotedHtml !== undefined || !!quotePending;
|
|
1073
|
+
const isReplyOrForward = initialQuotedHtml !== undefined || !!quotePending || !!resume;
|
|
1172
1074
|
const fieldsFocusedRef = useRef(false);
|
|
1173
1075
|
const bodyFocusedRef = useRef(false);
|
|
1174
1076
|
const focusToOnMount = !isReplyOrForward && !initialTo && !fieldsFocusedRef.current;
|
|
@@ -1202,35 +1104,34 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1202
1104
|
// Keyboard shortcuts, active while the focus is inside this window (`container`; the scope attribute on its root is what tells the
|
|
1203
1105
|
// dispatcher the focus is in a compose window at all). They do what the Send and Close buttons and autosave do, under the same conditions -
|
|
1204
1106
|
// a shortcut pressed while its button would be disabled is consumed and does nothing.
|
|
1205
|
-
|
|
1107
|
+
// Send never waits for the encryption settings to load (fail open, see `encryptionRequirement.ts`); it only needs the draft to exist. Pressed
|
|
1108
|
+
// while an earlier press is still being handled it does nothing (`submit()` guards that itself).
|
|
1109
|
+
const canSend = !!draft && !closing && !waitingForUploads;
|
|
1206
1110
|
useShortcut(SHORTCUTS.compose.send, () => void (canSend && submit(false)), { container: windowRef });
|
|
1207
1111
|
useShortcut(SHORTCUTS.compose.saveDraft, () => {
|
|
1208
|
-
// Autosave's own conditions: never a draft that must not be stored as plaintext
|
|
1209
|
-
if (draft && contentReady &&
|
|
1112
|
+
// Autosave's own conditions: never a draft that must not be stored as plaintext.
|
|
1113
|
+
if (draft && contentReady && !closing && !autosaveSuppressed && hasUserContent) {
|
|
1210
1114
|
void saveDraftNow();
|
|
1211
1115
|
}
|
|
1212
1116
|
}, { container: windowRef });
|
|
1213
1117
|
useShortcut(SHORTCUTS.compose.close, () => {
|
|
1214
|
-
if (!
|
|
1118
|
+
if (!closing) {
|
|
1215
1119
|
void handleClose();
|
|
1216
1120
|
}
|
|
1217
1121
|
}, { container: windowRef });
|
|
1218
1122
|
useShortcut(SHORTCUTS.compose.create, () => openCompose({ mailboxUid }), { container: windowRef });
|
|
1219
1123
|
const title = subject.trim() || "New Message";
|
|
1220
1124
|
const titleId = `compose-title-${id}`;
|
|
1221
|
-
const discardModal = (_jsxs(Modal, { open: closePrompt !== null, onClose: () => setClosePrompt(null), title: closePrompt?.title ?? DISCARD_TITLE, children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: closePrompt?.message }), _jsxs("div", { className: "flex gap-3", children: [
|
|
1222
|
-
setClosePrompt(null);
|
|
1223
|
-
void handleClose(true);
|
|
1224
|
-
}, disabled: sending || closing, className: "!w-auto", children: "Close, keep draft" })), _jsx(Button, { type: "button", onClick: () => void discardNow(), disabled: sending || closing, className: "!w-auto", children: "Discard" }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => setClosePrompt(null), className: "!w-auto", children: "Keep editing" })] })] }));
|
|
1125
|
+
const discardModal = (_jsxs(Modal, { open: closePrompt !== null, onClose: () => setClosePrompt(null), title: closePrompt?.title ?? DISCARD_TITLE, children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: closePrompt?.message }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", onClick: () => void discardNow(), disabled: closing, className: "!w-auto", children: "Discard" }), _jsx(Button, { type: "button", variant: "secondary", onClick: () => setClosePrompt(null), className: "!w-auto", children: "Keep editing" })] })] }));
|
|
1225
1126
|
if (minimized) {
|
|
1226
|
-
return (_jsxs(_Fragment, { children: [_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }),
|
|
1127
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Restore", onClick: onToggleMinimize, icon: HiOutlineArrowsPointingOut }), _jsx(HeaderButton, { label: "Discard draft", onClick: handleDiscard, icon: HiOutlineXMark, disabled: closing })] })] }) }), discardModal] }));
|
|
1227
1128
|
}
|
|
1228
1129
|
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: windowRef, role: "dialog", "data-shortcut-scope": "compose", "aria-labelledby": titleId, style: !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
|
|
1229
1130
|
"relative shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
|
|
1230
1131
|
isMobile
|
|
1231
1132
|
? "fixed inset-0 w-full h-full rounded-none border-0"
|
|
1232
1133
|
: ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
|
|
1233
|
-
].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled:
|
|
1134
|
+
].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled: closing, shortcut: SHORTCUTS.compose.close })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2 shrink-0 max-h-[45%] overflow-y-auto", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(Alert, { children: sendError }), attachError && _jsx(Alert, { children: attachError }), discardError && _jsx(Alert, { children: discardError })] })), securityBlock && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: securityBlock.message }), _jsx(Button, { type: "button", onClick: () => void submit(true, blockedScheduleIso), children: securityBlock.overrideLabel })] }) })), fromOptions.length > 1 && (_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-from-${id}`, className: "text-xs text-text-muted shrink-0", children: "From" }), _jsx("select", { id: `compose-from-${id}`, className: `${FIELD_INPUT} disabled:opacity-55`, value: mailboxUid ?? "", disabled: hasUploads, title: hasUploads ? "The sender can't be changed after adding attachments or images." : undefined, onChange: (e) => handleFromChange(e.target.value), children: fromOptions.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx(RecipientInput, { id: `compose-to-${id}`, label: "To", value: to, mailboxUid: mailboxUid, onChange: setTo, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)), autoFocus: focusToOnMount }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx(RecipientInput, { id: `compose-cc-${id}`, label: "Cc", value: cc, mailboxUid: mailboxUid, onChange: setCc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx(RecipientInput, { id: `compose-bcc-${id}`, label: "Bcc", value: bcc, mailboxUid: mailboxUid, onChange: setBcc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] })] })), (() => {
|
|
1234
1135
|
const knownRecipients = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]
|
|
1235
1136
|
.map((r) => recipientStatuses[r.address])
|
|
1236
1137
|
.filter((status) => !!status);
|
|
@@ -1238,5 +1139,5 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1238
1139
|
return null;
|
|
1239
1140
|
}
|
|
1240
1141
|
return (_jsx("ul", { className: "flex flex-wrap gap-1.5 px-3 pt-1.5", "aria-label": "Recipient encryption availability", children: knownRecipients.map((status) => (_jsxs("li", { className: `text-xs font-medium py-0.5 px-2 rounded-pill ${status.canEncrypt ? "bg-primary/10 text-primary-dark" : "bg-surface-alt text-text-muted"}`, children: [status.address, " ", status.canEncrypt ? "supports encryption" : "no encryption key found"] }, status.address))) }));
|
|
1241
|
-
})(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, autoFocus: focusSubjectOnMount, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && (_jsx(RichTextEditor, { appendHtml: lateQuoteHtml, onAppended: handleQuoteAppended, value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage, autoFocusStart: focusBodyOnMount, onInitialized: handleEditorInitialized })) }), quotePending && (_jsx("p", { role: "status", className: "px-3 pb-1 text-xs text-text-muted", children: "Loading the original message\u2026" })), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsx("li", { className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted", children: attachment.filename }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), needsUnlockForCrypto && (_jsx("div", { className: "px-3 pb-1", children: _jsxs("button", { type: "button", onClick: handleUnlockForCrypto, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to sign or encrypt this message"] }) })), unlockedKeys?.signingPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: signEnabled, onChange: (e) => setSignEnabled(e.target.checked) }), "Digitally sign this message"] })), unlockedKeys?.encryptionPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: encryptRequested, onChange: (e) => setEncryptRequested(e.target.checked) }), "Encrypt this message"] })), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => void submit(false), title: withHint("Send", SHORTCUTS.compose.send, keyEnv), "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.compose.send, keyEnv), disabled: !draft ||
|
|
1142
|
+
})(), _jsx("div", { className: FIELD_ROW, children: _jsx("input", { "aria-label": "Subject", type: "text", placeholder: "Subject", className: FIELD_INPUT, value: subject, autoFocus: focusSubjectOnMount, onChange: (e) => setSubject(e.target.value) }) }), _jsx("div", { className: "flex-1 min-h-0 p-2", children: contentReady && (_jsx(RichTextEditor, { appendHtml: lateQuoteHtml, onAppended: handleQuoteAppended, value: html, onChange: setHtml, fill: true, onUploadImage: handleUploadImage, autoFocusStart: focusBodyOnMount, onInitialized: handleEditorInitialized })) }), quotePending && (_jsx("p", { role: "status", className: "px-3 pb-1 text-xs text-text-muted", children: "Loading the original message\u2026" })), attachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2 px-3 pb-2", children: attachments.map((attachment) => (_jsx("li", { className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted", children: attachment.filename }, attachment.uid))) })), _jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: requestReceipt, onChange: (e) => setRequestReceipt(e.target.checked) }), "Request a read receipt"] }), needsUnlockForCrypto && (_jsx("div", { className: "px-3 pb-1", children: _jsxs("button", { type: "button", onClick: handleUnlockForCrypto, className: "inline-flex items-center gap-1 text-xs font-medium text-primary-dark hover:underline", children: [_jsx(HiOutlineLockClosed, { size: 12, "aria-hidden": "true" }), "Unlock to sign or encrypt this message"] }) })), unlockedKeys?.signingPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: signEnabled, onChange: (e) => setSignEnabled(e.target.checked) }), "Digitally sign this message"] })), unlockedKeys?.encryptionPrivateKey && (_jsxs("label", { className: "flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted", children: [_jsx("input", { type: "checkbox", checked: encryptRequested, onChange: (e) => setEncryptRequested(e.target.checked) }), "Encrypt this message"] })), _jsxs("div", { className: "shrink-0 flex items-center gap-1 px-3 py-2 border-t border-border", children: [_jsxs("div", { className: "flex items-center rounded-pill bg-primary text-white overflow-hidden", children: [_jsx("button", { type: "button", onClick: () => void submit(false), title: withHint("Send", SHORTCUTS.compose.send, keyEnv), "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.compose.send, keyEnv), disabled: !draft || closing || waitingForUploads, className: "py-1.5 pl-5 pr-3 font-semibold text-sm hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: "Send" }), _jsx("button", { ref: scheduleButtonRef, type: "button", "aria-label": "Send later", "aria-haspopup": "true", "aria-expanded": schedulePickerOpen, disabled: !draft || closing || waitingForUploads, onClick: () => setSchedulePickerOpen((o) => !o), className: "py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed", children: _jsx(HiOutlineChevronDown, { size: 14 }) })] }), schedulePickerOpen && (_jsx(ScheduleSendPicker, { anchorRef: scheduleButtonRef, onClose: () => setSchedulePickerOpen(false), onSchedule: handleScheduleSend })), _jsxs("label", { "aria-label": "Attach files", title: "Attach files", className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text cursor-pointer has-[:disabled]:opacity-40 has-[:disabled]:cursor-not-allowed", children: [_jsx(HiOutlinePaperClip, { size: 18 }), _jsx("input", { type: "file", multiple: true, disabled: !draft, onChange: handleFilesSelected, className: "sr-only" })] }), _jsx("span", { role: "status", "aria-live": "polite", className: "ml-auto text-xs text-text-muted", children: waitingForUploads ? "Waiting for attachments to finish uploading…" : SAVE_STATUS_LABEL[saveStatus] }), _jsx("button", { type: "button", "aria-label": "Discard draft", title: "Discard draft", onClick: handleDiscard, disabled: closing, className: "w-8 h-8 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text disabled:opacity-40 disabled:cursor-not-allowed", children: _jsx(HiOutlineTrash, { size: 18 }) })] })] })] }), discardModal] }));
|
|
1242
1143
|
}
|