@rapidmx/web-client 0.10.0 → 0.12.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/mailboxes/new/index.tsx +3 -3
- 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/escrowScopes/EscrowScopeKeyAndHoldersFields.tsx +4 -4
- 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/DomainDnsSetup.tsx +60 -14
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +65 -16
- 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/PrincipalListField.tsx +82 -0
- package/apps/shared/components/sharing/PrincipalPicker.tsx +76 -0
- package/apps/shared/components/sharing/PrincipalResolver.tsx +164 -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/mailboxes/new/index.js +1 -1
- 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/escrowScopes/EscrowScopeKeyAndHoldersFields.js +3 -2
- 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/DomainDnsSetup.js +25 -5
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +18 -7
- 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/PrincipalListField.d.ts +24 -0
- package/dist/apps/shared/components/sharing/PrincipalListField.js +23 -0
- package/dist/apps/shared/components/sharing/PrincipalPicker.d.ts +28 -0
- package/dist/apps/shared/components/sharing/PrincipalPicker.js +24 -0
- package/dist/apps/shared/components/sharing/PrincipalResolver.d.ts +44 -0
- package/dist/apps/shared/components/sharing/PrincipalResolver.js +92 -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
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
Mailbox,
|
|
21
21
|
Message,
|
|
22
22
|
assembleDraft,
|
|
23
|
-
assembleDraftRaw,
|
|
24
23
|
attachmentContentUrl,
|
|
25
24
|
createDraft,
|
|
26
25
|
deleteMessage,
|
|
@@ -28,21 +27,20 @@ import {
|
|
|
28
27
|
getMessage,
|
|
29
28
|
listFolders,
|
|
30
29
|
listMailboxes,
|
|
31
|
-
sendMessage,
|
|
32
|
-
setMessageRequestReceipt,
|
|
33
30
|
uploadAttachment,
|
|
34
31
|
} from "@rapidmx/react-shared/mail/mailApi.js";
|
|
35
32
|
import { listMailSignatures } from "@rapidmx/react-shared/mail/mailSignaturesApi.js";
|
|
36
|
-
import { describeSendFailure } from "@rapidmx/react-shared/mail/sendFailure.js";
|
|
37
33
|
import { buildComposeBodyHtml } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
|
|
38
34
|
import { peekMailboxWritability, useMailboxWritability } from "../writableMailboxes.js";
|
|
39
|
-
import {
|
|
35
|
+
import { resolveRecipientEncryption, RecipientEncryptionStatus } from "@rapidmx/react-shared/crypto/composeSecurity.js";
|
|
40
36
|
import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
41
37
|
import { EncryptionPolicy, findActivePublicKey, getEncryptionPolicy, lookupKeys } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
42
38
|
import { useUnlockPrompt } from "../../layout/UnlockPromptProvider.js";
|
|
43
|
-
import { fromBase64 } from "@rapidmx/react-shared/crypto/encoding.js";
|
|
44
|
-
import type { ProtectedHeaders } from "@rapidmx/react-shared/crypto/smimeMessage.js";
|
|
45
39
|
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
40
|
+
import { notify } from "../../../notifications/store.js";
|
|
41
|
+
import { evaluateEncryptionRequirement } from "./encryptionRequirement.js";
|
|
42
|
+
import { decideSend, SendBlock } from "../../../mail/outbox/sendDecision.js";
|
|
43
|
+
import { SendRequest, sendDecisionInput, startSend } from "../../../mail/outbox/sendJob.js";
|
|
46
44
|
import { useCompose, type ComposeSession } from "./ComposeContext.js";
|
|
47
45
|
import { ariaKeyShortcuts, withHint } from "../../../keyboard/format.js";
|
|
48
46
|
import { SHORTCUTS, ShortcutDef } from "../../../keyboard/keymap.js";
|
|
@@ -54,7 +52,6 @@ import RecipientInput from "./RecipientInput.js";
|
|
|
54
52
|
import { parseRecipientList } from "./recipients.js";
|
|
55
53
|
import RichTextEditor from "./RichTextEditor.js";
|
|
56
54
|
import ScheduleSendPicker from "./ScheduleSendPicker.js";
|
|
57
|
-
import SendFailureAlert from "./SendFailureAlert.js";
|
|
58
55
|
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
59
56
|
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
60
57
|
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
@@ -79,71 +76,29 @@ export interface ComposeWindowProps {
|
|
|
79
76
|
/** Debounce between the last edit and the draft autosave. */
|
|
80
77
|
export const DEFAULT_AUTOSAVE_DELAY_MS = 2000;
|
|
81
78
|
|
|
82
|
-
/** Automatic retries of a failed mailbox/encryption-policy
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
* (an uploaded attachment previewed via its content URL, or a `cid:` reference) would never be carried -
|
|
87
|
-
* only `assembleDraft()`'s server-side plaintext path rewrites those into real MIME parts. */
|
|
88
|
-
const INLINE_IMAGE_PATTERN = /<img\b[^>]*\bsrc\s*=\s*["']?(?:cid:|[^"'\s>]*\/mail\/attachments\/[^"'\s>]+\/content)/i;
|
|
89
|
-
|
|
90
|
-
const KEYS_LOCKED_SIGN_MESSAGE =
|
|
91
|
-
"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.";
|
|
92
|
-
const KEYS_LOCKED_ENCRYPT_MESSAGE =
|
|
93
|
-
"This message can't be encrypted right now - your encryption key is locked. Unlock and send again, or send it without encryption.";
|
|
94
|
-
const POLICY_UNAVAILABLE_MESSAGE =
|
|
95
|
-
"Your encryption settings couldn't be loaded, so this message can't be encrypted right now. Try again, or send it without encryption.";
|
|
96
|
-
const BCC_ENCRYPTED_MESSAGE = "Bcc recipients can't be used with encrypted messages. Remove Bcc recipients or turn off encryption.";
|
|
97
|
-
const LOOKUP_UNAVAILABLE_MESSAGE =
|
|
98
|
-
"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.";
|
|
79
|
+
/** Automatic, silent retries of a failed mailbox/encryption-policy (or recipient key) load, one entry per retry - a flapping gateway is waited out
|
|
80
|
+
* in the background, without a word to the user. */
|
|
81
|
+
export const DEFAULT_CRYPTO_RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 15000, 30000, 60000];
|
|
82
|
+
|
|
99
83
|
/** Thrown by `deleteDraft()` when the server copy is no longer this window's draft (sent or scheduled elsewhere). */
|
|
100
84
|
const NO_LONGER_A_DRAFT_MESSAGE = "This message was already sent or scheduled from another window, so it wasn't deleted.";
|
|
101
85
|
|
|
102
|
-
/** The server refuses signed/encrypted mail whose From display name differs from its own safe form, which drops a
|
|
103
|
-
* name containing `@` (or a look-alike) or a line break. */
|
|
104
|
-
const UNSAFE_DISPLAY_NAME_PATTERN = /[@@﹫\r\n]/;
|
|
105
|
-
|
|
106
86
|
/** Whether `fresh` (a re-read of a draft) is still an unsent draft in `folderUid`: never delete a message another
|
|
107
87
|
* window has since sent, scheduled, or started sending. */
|
|
108
88
|
function isStillDraft(fresh: Message, folderUid: string): boolean {
|
|
109
89
|
return fresh.folderUid === folderUid && !fresh.scheduledSendTime && !fresh.scheduledSendLeaseExpiresAt && !fresh.scheduledSendRelayedAt;
|
|
110
90
|
}
|
|
111
91
|
|
|
112
|
-
/** Any tier of `policy` auto-encrypts - only then can a message with recipients still to be added turn out encrypted
|
|
113
|
-
* without the user asking. */
|
|
114
|
-
function policyCanAutoEncrypt(policy: EncryptionPolicy): boolean {
|
|
115
|
-
return [policy.encryptSameOrg, policy.encryptFederated, policy.encryptExternal].includes("automatic");
|
|
116
|
-
}
|
|
117
|
-
|
|
118
92
|
const SAVE_STATUS_LABEL = { idle: "", saving: "Saving…", saved: "Draft saved", error: "Couldn't save draft" } as const;
|
|
119
93
|
|
|
120
94
|
const DISCARD_TITLE = "Discard this draft?";
|
|
95
|
+
/** Shown only for a message that is actually headed for encryption (asked for, or applied by the loaded policy) - see `encryptionRequirement.ts`. */
|
|
121
96
|
const ENCRYPTED_CLOSE_MESSAGE = "Encrypted messages aren't saved as drafts, so closing this window discards what you've written.";
|
|
122
|
-
const CHECKING_CLOSE_MESSAGE =
|
|
123
|
-
"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.";
|
|
124
|
-
const CRYPTO_UNAVAILABLE_MESSAGE =
|
|
125
|
-
"Your encryption settings couldn't be checked, so this draft isn't being saved - it might be a message that must be encrypted.";
|
|
126
|
-
const NO_RECIPIENTS_CLOSE_MESSAGE =
|
|
127
|
-
"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.";
|
|
128
|
-
|
|
129
|
-
const SEND_FAILED_TITLE = "This message wasn't sent";
|
|
130
|
-
function sendFailedCloseMessage(reason: string): string {
|
|
131
|
-
return `Sending it failed (${reason.replace(/\.$/, "")}). Closing keeps it as a draft, unsent. Keep editing to try again, or discard it.`;
|
|
132
|
-
}
|
|
133
97
|
|
|
134
98
|
/** The confirmation shown before a Close/Discard throws content away (or when a Close couldn't save it). */
|
|
135
99
|
interface ClosePrompt {
|
|
136
100
|
title: string;
|
|
137
101
|
message: string;
|
|
138
|
-
/** Also offer "Retry" for loading the encryption settings. */
|
|
139
|
-
retry?: boolean;
|
|
140
|
-
/** Also offer "Close, keep draft": a failed send's close prompt, where closing keeps the unsent draft as it is. */
|
|
141
|
-
closeAnyway?: boolean;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
interface SecurityBlock {
|
|
145
|
-
message: string;
|
|
146
|
-
overrideLabel: string;
|
|
147
102
|
}
|
|
148
103
|
|
|
149
104
|
const FIELD_ROW = "flex items-center gap-2 px-3 py-1.5 border-b border-border";
|
|
@@ -224,7 +179,7 @@ export default function ComposeWindow({
|
|
|
224
179
|
autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS,
|
|
225
180
|
cryptoRetryDelaysMs = DEFAULT_CRYPTO_RETRY_DELAYS_MS,
|
|
226
181
|
}: ComposeWindowProps) {
|
|
227
|
-
const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized, quotePending, late } = session;
|
|
182
|
+
const { id, initialTo, initialCc, initialSubject, initialQuotedHtml, initialEncrypt, signatureContext, suppressSigning, minimized, quotePending, late, resume } = session;
|
|
228
183
|
// What a reply worked out after this window opened (see `OpenComposeInput.pending`): better recipients, applied below
|
|
229
184
|
// only to fields still exactly as they opened - so these, not `initialTo`/`initialCc`, are what "untouched" is measured against.
|
|
230
185
|
const baselineTo = late?.to ?? initialTo;
|
|
@@ -241,7 +196,7 @@ export default function ComposeWindow({
|
|
|
241
196
|
const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
|
|
242
197
|
// Attachments and inline images upload onto the current draft's uid, which lives in one mailbox's
|
|
243
198
|
// Drafts folder - so once anything has been uploaded the sender can no longer be switched.
|
|
244
|
-
const [hasUploads, setHasUploads] = useState(
|
|
199
|
+
const [hasUploads, setHasUploads] = useState(!!resume && resume.attachments.length > 0);
|
|
245
200
|
const isMobile = useIsMobile();
|
|
246
201
|
const { requestUnlock } = useUnlockPrompt();
|
|
247
202
|
const { openCompose } = useCompose();
|
|
@@ -253,26 +208,29 @@ export default function ComposeWindow({
|
|
|
253
208
|
const windowRef = useRef<HTMLDivElement>(null);
|
|
254
209
|
const [draftsFolderUid, setDraftsFolderUid] = useState<string | undefined>();
|
|
255
210
|
const [folderError, setFolderError] = useState<string | null>(null);
|
|
256
|
-
|
|
211
|
+
// A window re-opened around a message that was already composed (a failed send's "Open draft") continues that server draft and starts with
|
|
212
|
+
// every field as it was typed; its body is not seeded from a signature or a quote.
|
|
213
|
+
const [draft, setDraft] = useState<Message | null>(resume?.draft ?? null);
|
|
257
214
|
// Drafts replaced by a From switch, awaiting deletion until their replacement has actually been created.
|
|
258
215
|
const supersededDraftsRef = useRef<Message[]>([]);
|
|
259
216
|
const [draftError, setDraftError] = useState<string | null>(null);
|
|
260
217
|
const [expanded, setExpanded] = useState(false);
|
|
261
218
|
const [manualSize, setManualSize] = useState<Size | null>(null);
|
|
262
|
-
const [to, setTo] = useState(initialTo ?? "");
|
|
263
|
-
const [cc, setCc] = useState(initialCc ?? "");
|
|
264
|
-
const [bcc, setBcc] = useState("");
|
|
265
|
-
const [showCcBcc, setShowCcBcc] = useState(!!initialCc);
|
|
266
|
-
const [subject, setSubject] = useState(initialSubject ?? "");
|
|
267
|
-
const [html, setHtml] = useState("");
|
|
268
|
-
const [contentReady, setContentReady] = useState(
|
|
269
|
-
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
|
219
|
+
const [to, setTo] = useState(resume?.to ?? initialTo ?? "");
|
|
220
|
+
const [cc, setCc] = useState(resume?.cc ?? initialCc ?? "");
|
|
221
|
+
const [bcc, setBcc] = useState(resume?.bcc ?? "");
|
|
222
|
+
const [showCcBcc, setShowCcBcc] = useState(!!(resume ? resume.cc || resume.bcc : initialCc));
|
|
223
|
+
const [subject, setSubject] = useState(resume?.subject ?? initialSubject ?? "");
|
|
224
|
+
const [html, setHtml] = useState(resume?.html ?? "");
|
|
225
|
+
const [contentReady, setContentReady] = useState(!!resume);
|
|
226
|
+
const [attachments, setAttachments] = useState<Attachment[]>(resume?.attachments ?? []);
|
|
270
227
|
const [attachError, setAttachError] = useState<string | null>(null);
|
|
228
|
+
// A problem with the message as composed (no recipient, an attachment that can't be signed): shown here, and the window stays open. What
|
|
229
|
+
// goes wrong after Send was accepted is a pop-up instead (see `sendJob.ts`).
|
|
271
230
|
const [sendError, setSendError] = useState<string | null>(null);
|
|
272
|
-
//
|
|
273
|
-
const [
|
|
274
|
-
const [
|
|
275
|
-
const [requestReceipt, setRequestReceipt] = useState(false);
|
|
231
|
+
// Send was pressed while an attachment was still uploading: the window waits (and says so) rather than send without it.
|
|
232
|
+
const [waitingForUploads, setWaitingForUploads] = useState(false);
|
|
233
|
+
const [requestReceipt, setRequestReceipt] = useState(!!resume?.requestReceipt);
|
|
276
234
|
const [schedulePickerOpen, setSchedulePickerOpen] = useState(false);
|
|
277
235
|
const scheduleButtonRef = useRef<HTMLButtonElement>(null);
|
|
278
236
|
const [mailbox, setMailbox] = useState<Mailbox | null>(null);
|
|
@@ -281,11 +239,10 @@ export default function ComposeWindow({
|
|
|
281
239
|
// Digital Signatures - a list that appends a footer after signing invalidates the signature) - see
|
|
282
240
|
// `MessageDetailPane.tsx`'s handleReply()/handleReplyAll() for where this is computed. The user can
|
|
283
241
|
// still turn it back on; this only changes the default.
|
|
284
|
-
const [signEnabled, setSignEnabled] = useState(!suppressSigning);
|
|
242
|
+
const [signEnabled, setSignEnabled] = useState(resume ? resume.signEnabled : !suppressSigning);
|
|
285
243
|
// A reply to or forward of an encrypted message starts (and, after a From switch, restarts) with encryption
|
|
286
244
|
// requested - see `OpenComposeInput.encrypt`.
|
|
287
|
-
const [encryptRequested, setEncryptRequested] = useState(!!initialEncrypt);
|
|
288
|
-
const [encryptionBlocked, setEncryptionBlocked] = useState<RecipientEncryptionStatus[] | null>(null);
|
|
245
|
+
const [encryptRequested, setEncryptRequested] = useState(resume ? resume.encryptRequested : !!initialEncrypt);
|
|
289
246
|
// Compose-time discovery (spec: "Discovery occurs ... when the user addresses a new message to a
|
|
290
247
|
// recipient", never on receipt) - keyed by address so a recipient already looked up isn't re-fetched
|
|
291
248
|
// just because the user re-focuses the field. Only ever grows via `checkRecipientDiscovery()` below;
|
|
@@ -295,17 +252,15 @@ export default function ComposeWindow({
|
|
|
295
252
|
// look one up twice), and a counter bumped by a From switch so a lookup for the previous sender is dropped.
|
|
296
253
|
const lookupsInFlightRef = useRef(new Set<string>());
|
|
297
254
|
const discoveryGenerationRef = useRef(0);
|
|
298
|
-
// A failed lookup stores no status
|
|
299
|
-
//
|
|
300
|
-
// run out the address is "exhausted" - shown as a check that couldn't be done, with Retry.
|
|
255
|
+
// A failed lookup stores no status: the recipient is simply "not known to be encryptable" (fail open - see `encryptionRequirement.ts`), and
|
|
256
|
+
// the lookup is retried in the background with the same backoff as the mailbox/policy load, without a word to the user.
|
|
301
257
|
const lookupFailuresRef = useRef(new Map<string, number>());
|
|
302
258
|
const lookupRetryTimersRef = useRef(new Set<ReturnType<typeof setTimeout>>());
|
|
303
259
|
const [lookupRetryToken, setLookupRetryToken] = useState(0);
|
|
304
|
-
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
const [securityBlock, setSecurityBlock] = useState<SecurityBlock | null>(null);
|
|
260
|
+
// A send refused *before it left* because signing/encryption was asked for (or the loaded policy applies it) but can't happen right now
|
|
261
|
+
// (keys locked, Bcc recipients on an encrypted message, a recipient without a key) - never a silent downgrade to plaintext; the user
|
|
262
|
+
// has to explicitly pick the override.
|
|
263
|
+
const [securityBlock, setSecurityBlock] = useState<SendBlock | null>(null);
|
|
309
264
|
// The schedule time of the send that got blocked (undefined for an immediate send), so the banners'
|
|
310
265
|
// override buttons replay the same kind of send the user originally asked for.
|
|
311
266
|
const [blockedScheduleIso, setBlockedScheduleIso] = useState<string | undefined>();
|
|
@@ -326,8 +281,11 @@ export default function ComposeWindow({
|
|
|
326
281
|
const lastSavedRef = useRef<string | null>(null);
|
|
327
282
|
/** An edit is waiting on the autosave debounce - flushed if the window unmounts first. */
|
|
328
283
|
const pendingSaveRef = useRef(false);
|
|
329
|
-
/**
|
|
330
|
-
const
|
|
284
|
+
/** Send was pressed and is being validated/handed over: a second press (a double click, the shortcut held or repeated) does nothing. */
|
|
285
|
+
const submittingRef = useRef(false);
|
|
286
|
+
/** Attachment/inline-image uploads on the wire, and who is waiting for them to finish (Send pressed meanwhile). */
|
|
287
|
+
const uploadsInFlightRef = useRef(0);
|
|
288
|
+
const uploadWaitersRef = useRef<(() => void)[]>([]);
|
|
331
289
|
/** The latest save request, if any - each save is chained after the one before it. Always settles (never
|
|
332
290
|
* rejects), to the saved message, or `undefined` when that save failed. */
|
|
333
291
|
const saveInFlightRef = useRef<Promise<Message | undefined>>(Promise.resolve(undefined));
|
|
@@ -336,13 +294,10 @@ export default function ComposeWindow({
|
|
|
336
294
|
|
|
337
295
|
// A mailbox with no keys enrolled yet just means sign/encrypt stay unavailable for this compose session -
|
|
338
296
|
// encryption is optional and gradual by design (see `KeyEnrollmentGate`'s own doc comment).
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
const [cryptoContextReady, setCryptoContextReady] = useState(false);
|
|
344
|
-
const [cryptoLoadFailed, setCryptoLoadFailed] = useState(false);
|
|
345
|
-
const [cryptoRetryToken, setCryptoRetryToken] = useState(0);
|
|
297
|
+
// The mailbox and the encryption policy load in the background and *nothing waits for them*: until both have loaded, the message is treated as
|
|
298
|
+
// not encrypted - its draft saves, Close and Send work, and no message about it is shown (see `encryptionRequirement.ts`). A failed load is
|
|
299
|
+
// retried silently with backoff (`cryptoRetryDelaysMs`); if it later turns out the message must be encrypted, `encryptionDecided` (below) starts
|
|
300
|
+
// to apply - see the transition rules there.
|
|
346
301
|
|
|
347
302
|
useEffect(() => {
|
|
348
303
|
let cancelled = false;
|
|
@@ -379,8 +334,6 @@ export default function ComposeWindow({
|
|
|
379
334
|
let mailboxLoaded = mailbox?.uid === mailboxUid;
|
|
380
335
|
let policyLoaded = !!encryptionPolicy;
|
|
381
336
|
let retries = 0;
|
|
382
|
-
setCryptoContextReady(false);
|
|
383
|
-
setCryptoLoadFailed(false);
|
|
384
337
|
async function load() {
|
|
385
338
|
await Promise.all([
|
|
386
339
|
mailboxLoaded ||
|
|
@@ -404,13 +357,7 @@ export default function ComposeWindow({
|
|
|
404
357
|
() => undefined,
|
|
405
358
|
),
|
|
406
359
|
]);
|
|
407
|
-
if (cancelled) {
|
|
408
|
-
return;
|
|
409
|
-
}
|
|
410
|
-
const failed = !mailboxLoaded || !policyLoaded;
|
|
411
|
-
setCryptoContextReady(true);
|
|
412
|
-
setCryptoLoadFailed(failed);
|
|
413
|
-
if (failed && retries < cryptoRetryDelaysMs.length) {
|
|
360
|
+
if (!cancelled && (!mailboxLoaded || !policyLoaded) && retries < cryptoRetryDelaysMs.length) {
|
|
414
361
|
timer = setTimeout(() => void load(), cryptoRetryDelaysMs[retries++]);
|
|
415
362
|
}
|
|
416
363
|
}
|
|
@@ -419,7 +366,7 @@ export default function ComposeWindow({
|
|
|
419
366
|
cancelled = true;
|
|
420
367
|
clearTimeout(timer);
|
|
421
368
|
};
|
|
422
|
-
}, [mailboxUid
|
|
369
|
+
}, [mailboxUid]);
|
|
423
370
|
|
|
424
371
|
useEffect(() => {
|
|
425
372
|
if (!mailboxUid) {
|
|
@@ -448,14 +395,14 @@ export default function ComposeWindow({
|
|
|
448
395
|
* Runs discovery for whichever of `addresses` haven't already been looked up this compose session,
|
|
449
396
|
* caching the result so the small per-recipient indicator below the recipient fields can update
|
|
450
397
|
* without waiting for Send. Called from the To/Cc/Bcc fields' own `onBlur` - not on every keystroke,
|
|
451
|
-
* and not the
|
|
398
|
+
* and not the lookup the background send (`sendJob.ts`) performs again at send time (deliberately: this is a
|
|
452
399
|
* best-effort UI hint, not something a stale value here should be trusted to skip at send time).
|
|
453
400
|
* A mailbox/policy fetch that hasn't resolved yet just leaves that address with no indicator - the effect
|
|
454
401
|
* below runs it again for every current recipient once both have loaded. A failed lookup leaves it without
|
|
455
|
-
* a status too, and is retried after `cryptoRetryDelaysMs` (then only on blur
|
|
402
|
+
* a status too, and is retried silently after `cryptoRetryDelaysMs` (then only on blur).
|
|
456
403
|
*
|
|
457
|
-
* The statuses also drive
|
|
458
|
-
*
|
|
404
|
+
* The statuses also drive `encryptionRequirement.ts`: only when every recipient has one, and the loaded policy
|
|
405
|
+
* auto-encrypts for all of them, does the message turn into one that is never saved as a plaintext draft.
|
|
459
406
|
*/
|
|
460
407
|
function checkRecipientDiscovery(addresses: ComposeRecipientInput[]) {
|
|
461
408
|
if (!mailbox || !encryptionPolicy) {
|
|
@@ -488,12 +435,11 @@ export default function ComposeWindow({
|
|
|
488
435
|
}
|
|
489
436
|
}
|
|
490
437
|
|
|
491
|
-
/** Schedules the next automatic retry of a failed lookup
|
|
438
|
+
/** Schedules the next automatic, silent retry of a failed lookup (until the backoff has run out; a later blur tries again). */
|
|
492
439
|
function noteLookupFailed(address: string) {
|
|
493
440
|
const failures = (lookupFailuresRef.current.get(address) ?? 0) + 1;
|
|
494
441
|
lookupFailuresRef.current.set(address, failures);
|
|
495
442
|
if (failures > cryptoRetryDelaysMs.length) {
|
|
496
|
-
setExhaustedLookups((prev) => ({ ...prev, [address]: true }));
|
|
497
443
|
return;
|
|
498
444
|
}
|
|
499
445
|
const timer = setTimeout(() => {
|
|
@@ -518,7 +464,7 @@ export default function ComposeWindow({
|
|
|
518
464
|
checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
|
|
519
465
|
}, [mailbox, encryptionPolicy, lookupRetryToken]);
|
|
520
466
|
|
|
521
|
-
// Resolves the mailbox's default signature (if any) for `signatureContext` and seeds `html` with it
|
|
467
|
+
// 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)
|
|
522
468
|
// plus any quoted original message (laid out by `buildComposeBodyHtml()`: an empty first paragraph for the
|
|
523
469
|
// caret, then the signature, then the quote), before `RichTextEditor` ever mounts (gated by `contentReady`
|
|
524
470
|
// below) — `RichTextEditor`'s own doc comment is explicit that `value` only seeds its *initial*
|
|
@@ -676,9 +622,7 @@ export default function ComposeWindow({
|
|
|
676
622
|
discoveryGenerationRef.current += 1;
|
|
677
623
|
lookupsInFlightRef.current = new Set();
|
|
678
624
|
lookupFailuresRef.current = new Map();
|
|
679
|
-
setExhaustedLookups({});
|
|
680
625
|
setEncryptRequested(!!initialEncrypt);
|
|
681
|
-
setEncryptionBlocked(null);
|
|
682
626
|
setSecurityBlock(null);
|
|
683
627
|
offeredCryptoRef.current = { sign: false, encrypt: false };
|
|
684
628
|
setFromMailboxUid(nextMailboxUid);
|
|
@@ -738,6 +682,7 @@ export default function ComposeWindow({
|
|
|
738
682
|
setAttachError("Please wait for the draft to finish loading before inserting an image.");
|
|
739
683
|
return null;
|
|
740
684
|
}
|
|
685
|
+
uploadStarted();
|
|
741
686
|
try {
|
|
742
687
|
const attachment = await uploadAttachment(draft.uid, file);
|
|
743
688
|
setHasUploads(true);
|
|
@@ -746,6 +691,21 @@ export default function ComposeWindow({
|
|
|
746
691
|
} catch (err) {
|
|
747
692
|
setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload image.");
|
|
748
693
|
return null;
|
|
694
|
+
} finally {
|
|
695
|
+
uploadFinished();
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Send waits for every upload started before it: see `submit()`. */
|
|
700
|
+
function uploadStarted() {
|
|
701
|
+
uploadsInFlightRef.current += 1;
|
|
702
|
+
}
|
|
703
|
+
function uploadFinished() {
|
|
704
|
+
uploadsInFlightRef.current -= 1;
|
|
705
|
+
if (uploadsInFlightRef.current === 0) {
|
|
706
|
+
for (const resolve of uploadWaitersRef.current.splice(0)) {
|
|
707
|
+
resolve();
|
|
708
|
+
}
|
|
749
709
|
}
|
|
750
710
|
}
|
|
751
711
|
|
|
@@ -770,228 +730,108 @@ export default function ComposeWindow({
|
|
|
770
730
|
const files = Array.from(e.target.files ?? []);
|
|
771
731
|
e.target.value = "";
|
|
772
732
|
setAttachError(null);
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
733
|
+
uploadStarted();
|
|
734
|
+
try {
|
|
735
|
+
for (const file of files) {
|
|
736
|
+
try {
|
|
737
|
+
const attachment = await uploadAttachment(draft!.uid, file);
|
|
738
|
+
setHasUploads(true);
|
|
739
|
+
setAttachments((prev) => [...prev, attachment]);
|
|
740
|
+
void refreshDraftVersion(draft!.uid);
|
|
741
|
+
} catch (err) {
|
|
742
|
+
setAttachError(err instanceof ApiRequestError ? err.message : "Could not upload attachment.");
|
|
743
|
+
}
|
|
781
744
|
}
|
|
745
|
+
} finally {
|
|
746
|
+
uploadFinished();
|
|
782
747
|
}
|
|
783
748
|
}
|
|
784
749
|
|
|
785
750
|
/**
|
|
786
|
-
*
|
|
787
|
-
*
|
|
788
|
-
* Shared by immediate and scheduled sends (and the banners' override buttons rendered below, via
|
|
789
|
-
* `forcePlaintext`) so the sign/encrypt decision lives in exactly one place.
|
|
751
|
+
* Send (or, given `scheduledSendTimeIso`, Send later) - and get out of the way. The window is the place the message is *written*; nothing
|
|
752
|
+
* about getting it to the server is waited for here.
|
|
790
753
|
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
*
|
|
795
|
-
*
|
|
796
|
-
*
|
|
797
|
-
* locked); or the message would be encrypted but has Bcc recipients - every recipient's certificate goes
|
|
798
|
-
* into one shared envelope, which would disclose the Bcc list to every other recipient, and the send API
|
|
799
|
-
* has no per-recipient-copy submission to avoid that (sets `securityBlock`).
|
|
800
|
-
* Callers must stop (not send) when they get this back. `forcePlaintext` (the user's explicit choice
|
|
801
|
-
* from one of those banners) skips every one of these checks and never encrypts; signing still
|
|
802
|
-
* happens if it's currently possible.
|
|
754
|
+
* 1. **Validate synchronously**, from what the window already knows (no request): a recipient is required; and if the message is one
|
|
755
|
+
* 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
|
|
756
|
+
* attachment that can't be signed) that is shown right here and the window stays open - the sender fixes it or picks the override.
|
|
757
|
+
* An attachment still uploading is waited for, inline (Send says so), never left behind.
|
|
758
|
+
* 2. **Close immediately** (`onClose()`): `startSend()` (see `sendJob.ts`) takes over with a snapshot of the message. From here the only
|
|
759
|
+
* feedback is the Outbox indicator; a failure at any stage is a sticky pop-up with the reason and Retry / Open draft.
|
|
803
760
|
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*/
|
|
807
|
-
async function assembleForSend(
|
|
808
|
-
toRecipients: ComposeRecipientInput[],
|
|
809
|
-
ccRecipients: ComposeRecipientInput[],
|
|
810
|
-
bccRecipients: ComposeRecipientInput[],
|
|
811
|
-
forcePlaintext: boolean,
|
|
812
|
-
): Promise<Message | "blocked"> {
|
|
813
|
-
// Keys are read at the moment they're needed, and again after any await: they can lock (idle
|
|
814
|
-
// timeout, sign-out elsewhere) mid-send, and a destroyed `UnlockedKeys` object must count as locked.
|
|
815
|
-
// Only reachable once a draft exists, which itself requires `mailboxUid` to have resolved.
|
|
816
|
-
function readKeys() {
|
|
817
|
-
const stored = getUnlockedKeys(mailboxUid!);
|
|
818
|
-
const unlocked = stored?.destroyed ? undefined : stored;
|
|
819
|
-
return {
|
|
820
|
-
unlocked,
|
|
821
|
-
// `mailbox` is required to build protected headers (own From address) whenever signing or
|
|
822
|
-
// encrypting - not just guarded on the encryption branch below, since a signed-only message
|
|
823
|
-
// needs it too.
|
|
824
|
-
canSign: signEnabled && !!mailbox && !!unlocked?.signingPrivateKey && !!unlocked.signingCertDer,
|
|
825
|
-
canEncryptSelf: !!unlocked?.encryptionPrivateKey && !!unlocked.encryptionCertDer,
|
|
826
|
-
};
|
|
827
|
-
}
|
|
828
|
-
let keys = readKeys();
|
|
829
|
-
const allRecipients = [...toRecipients, ...ccRecipients, ...bccRecipients];
|
|
830
|
-
|
|
831
|
-
// The Sign toggle was shown (and is still checked), but signing can't actually happen any more.
|
|
832
|
-
const signingLost = () => !forcePlaintext && signEnabled && offeredCryptoRef.current.sign && !keys.canSign;
|
|
833
|
-
if (signingLost()) {
|
|
834
|
-
return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
if (!forcePlaintext && !mailbox) {
|
|
838
|
-
// The mailbox's enrolled keys are what say whether this message could be encrypted at all - with keys
|
|
839
|
-
// that aren't unlocked in this session, a failed mailbox load would otherwise skip encryption silently.
|
|
840
|
-
return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
let wantEncrypt = false;
|
|
844
|
-
let recipientCertDers: Uint8Array[] = [];
|
|
845
|
-
// Entered whenever this message could be encrypted at all - including a mailbox with an enrolled
|
|
846
|
-
// encryption key that this session never unlocked: policy may still auto-encrypt, and that must block
|
|
847
|
-
// (and prompt to unlock) rather than quietly send plaintext.
|
|
848
|
-
if (!forcePlaintext && (keys.canEncryptSelf || offeredCryptoRef.current.encrypt || hasEnrolledEncryptionKey)) {
|
|
849
|
-
if (!encryptionPolicy) {
|
|
850
|
-
// Without it, there's no telling whether policy auto-encrypts this message.
|
|
851
|
-
return blockSend(POLICY_UNAVAILABLE_MESSAGE, "Send without encryption", false);
|
|
852
|
-
}
|
|
853
|
-
// Reachable only with `mailbox` loaded (checked above).
|
|
854
|
-
const ownPrefersMutual = mailbox!.encryptPreference?.preferEncrypt === "mutual";
|
|
855
|
-
const lookups = await Promise.all(
|
|
856
|
-
allRecipients.map((r) =>
|
|
857
|
-
lookupKeys(mailboxUid!, r.address).then(
|
|
858
|
-
(lookup) => ({ lookup }),
|
|
859
|
-
() => null,
|
|
860
|
-
),
|
|
861
|
-
),
|
|
862
|
-
);
|
|
863
|
-
keys = readKeys();
|
|
864
|
-
if (signingLost()) {
|
|
865
|
-
return blockSend(KEYS_LOCKED_SIGN_MESSAGE, "Send without signing or encryption", !keys.unlocked);
|
|
866
|
-
}
|
|
867
|
-
// A failed lookup isn't "no key": treating it as one could send plaintext that policy would encrypt.
|
|
868
|
-
if (lookups.some((result) => !result) && (encryptRequested || policyCanAutoEncrypt(encryptionPolicy))) {
|
|
869
|
-
return blockSend(LOOKUP_UNAVAILABLE_MESSAGE, "Send without encryption", false);
|
|
870
|
-
}
|
|
871
|
-
const statuses = allRecipients.map((r, i) =>
|
|
872
|
-
resolveRecipientEncryption(mailbox!.primarySmtpAddress, ownPrefersMutual, encryptionPolicy, r.address, lookups[i]?.lookup),
|
|
873
|
-
);
|
|
874
|
-
const decision = decideMessageEncryption(statuses);
|
|
875
|
-
wantEncrypt = encryptRequested || decision.autoEncrypt;
|
|
876
|
-
if (wantEncrypt) {
|
|
877
|
-
if (!keys.canEncryptSelf) {
|
|
878
|
-
return blockSend(KEYS_LOCKED_ENCRYPT_MESSAGE, "Send without encryption", !keys.unlocked);
|
|
879
|
-
}
|
|
880
|
-
if (!decision.canEncryptAll) {
|
|
881
|
-
setEncryptionBlocked(decision.blockedRecipients);
|
|
882
|
-
return "blocked";
|
|
883
|
-
}
|
|
884
|
-
if (bccRecipients.length > 0) {
|
|
885
|
-
return blockSend(BCC_ENCRYPTED_MESSAGE, "Send without encryption", false);
|
|
886
|
-
}
|
|
887
|
-
recipientCertDers = [keys.unlocked!.encryptionCertDer!, ...statuses.map((s) => fromBase64(s.encryptCert!.publicKey))];
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
const { canSign } = keys;
|
|
892
|
-
if ((wantEncrypt || canSign) && attachments.length > 0) {
|
|
893
|
-
// Matches BaseMailComposeRoute.assembleRaw()'s own server-side rejection, surfaced here with a
|
|
894
|
-
// clearer explanation than that route's generic 400 rather than via a round trip.
|
|
895
|
-
throw new ApiRequestError("A signed or encrypted message cannot include file attachments yet - remove them before sending.", 400);
|
|
896
|
-
}
|
|
897
|
-
if ((wantEncrypt || canSign) && INLINE_IMAGE_PATTERN.test(html)) {
|
|
898
|
-
throw new ApiRequestError("A signed or encrypted message cannot include inline images yet - remove them before sending.", 400);
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
if (!wantEncrypt && !canSign) {
|
|
902
|
-
// See the old compose page's identical note: `sanitize-html` is Node-oriented and the server-side
|
|
903
|
-
// gate in `BaseMailComposeRoute.assemble()` is the sole authoritative sanitizer regardless, so no
|
|
904
|
-
// client-side pass is done here either.
|
|
905
|
-
return assembleDraft(draft!.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject, html });
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
const domain = mailbox!.primarySmtpAddress.split("@")[1] ?? "localhost";
|
|
909
|
-
const displayName = mailbox!.displayName && !UNSAFE_DISPLAY_NAME_PATTERN.test(mailbox!.displayName) ? mailbox!.displayName : "";
|
|
910
|
-
const protectedHeaders: ProtectedHeaders = {
|
|
911
|
-
from: displayName
|
|
912
|
-
? `"${displayName.replace(/"/g, '\\"')}" <${mailbox!.primarySmtpAddress}>`
|
|
913
|
-
: mailbox!.primarySmtpAddress,
|
|
914
|
-
to: toRecipients.map((r) => r.address).join(", "),
|
|
915
|
-
cc: ccRecipients.length > 0 ? ccRecipients.map((r) => r.address).join(", ") : undefined,
|
|
916
|
-
date: new Date().toUTCString(),
|
|
917
|
-
subject,
|
|
918
|
-
messageId: `<${crypto.randomUUID()}@${domain}>`,
|
|
919
|
-
};
|
|
920
|
-
const bodyContentType = 'text/html; charset="utf-8"';
|
|
921
|
-
const signing = canSign ? { certDer: keys.unlocked!.signingCertDer!, privateKey: keys.unlocked!.signingPrivateKey! } : undefined;
|
|
922
|
-
|
|
923
|
-
// The S/MIME code (PKI.js and the ASN.1/X.509 libraries, over half a megabyte) is only needed here, to sign or
|
|
924
|
-
// encrypt - a plain message never loads it, and it is kept out of the compose window's own chunk.
|
|
925
|
-
const { applyBaselineOuterHeaders, assembleOutboundMime, buildEncryptedMessage, buildSignedOnlyMessage } = await import(
|
|
926
|
-
"@rapidmx/react-shared/crypto/smimeMessage.js"
|
|
927
|
-
);
|
|
928
|
-
const outerHeaders = wantEncrypt ? applyBaselineOuterHeaders(protectedHeaders) : protectedHeaders;
|
|
929
|
-
const mimePart = wantEncrypt
|
|
930
|
-
? await buildEncryptedMessage(bodyContentType, html, protectedHeaders, outerHeaders, recipientCertDers, signing)
|
|
931
|
-
: await buildSignedOnlyMessage(bodyContentType, html, protectedHeaders, signing!.certDer, signing!.privateKey);
|
|
932
|
-
const rawMime = assembleOutboundMime(outerHeaders, mimePart);
|
|
933
|
-
return assembleDraftRaw(draft!.uid, { to: toRecipients, cc: ccRecipients, bcc: bccRecipients, subject: outerHeaders.subject, rawMime });
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
/** Records why a send was refused (see `assembleForSend()`), re-prompting the unlock dialog when the
|
|
937
|
-
* cause is keys that are enrolled but locked. */
|
|
938
|
-
function blockSend(message: string, overrideLabel: string, keysLocked: boolean): "blocked" {
|
|
939
|
-
setSecurityBlock({ message, overrideLabel });
|
|
940
|
-
if (keysLocked && (hasEnrolledSigningKey || hasEnrolledEncryptionKey)) {
|
|
941
|
-
void handleUnlockForCrypto();
|
|
942
|
-
}
|
|
943
|
-
return "blocked";
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
/**
|
|
947
|
-
* Sends (or, given `scheduledSendTimeIso`, schedules) the draft. Same reasoning as `handleFilesSelected`
|
|
948
|
-
* above: Send and the "Send later" caret are both `disabled` until `draft` resolves, so this is never
|
|
949
|
-
* reachable with a null `draft`. A scheduled send assembles the draft first (same as an immediate send),
|
|
950
|
-
* then sets `scheduledSendTime` on the *freshly assembled* copy before calling `sendMessage()` — `send()`
|
|
951
|
-
* itself is what reads the field and defers relay into Outbox instead of sending immediately (see
|
|
952
|
-
* `mailApi.ts`'s own doc comment on `setMessageScheduledSendTime`). A blocked send remembers its schedule
|
|
953
|
-
* time so the banners' override buttons replay it as the same kind of send.
|
|
761
|
+
* Pressed twice (a double click, the shortcut repeated) it sends once: the first press sets `submittingRef` before any `await`, and
|
|
762
|
+
* `startSend()` itself refuses a draft that is already being sent. `forcePlaintext` is the override buttons' choice.
|
|
954
763
|
*/
|
|
955
764
|
async function submit(forcePlaintext: boolean, scheduledSendTimeIso?: string) {
|
|
956
|
-
|
|
957
|
-
|
|
765
|
+
if (submittingRef.current || finishedRef.current) {
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (parseAddresses(latestRef.current.to).length === 0) {
|
|
958
769
|
setSendError("At least one recipient is required.");
|
|
959
770
|
return;
|
|
960
771
|
}
|
|
961
|
-
|
|
962
|
-
setSending(true);
|
|
963
|
-
sendingRef.current = true;
|
|
772
|
+
submittingRef.current = true;
|
|
964
773
|
setSendError(null);
|
|
965
|
-
setSendDetails([]);
|
|
966
774
|
setSecurityBlock(null);
|
|
967
|
-
setEncryptionBlocked(null);
|
|
968
|
-
pendingSaveRef.current = false;
|
|
969
775
|
try {
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
776
|
+
if (uploadsInFlightRef.current > 0) {
|
|
777
|
+
setWaitingForUploads(true);
|
|
778
|
+
await new Promise<void>((resolve) => uploadWaitersRef.current.push(resolve));
|
|
779
|
+
// The editor puts an image it has just uploaded into the message a moment after the upload resolves: let the window catch up.
|
|
780
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
781
|
+
setWaitingForUploads(false);
|
|
782
|
+
}
|
|
783
|
+
const current = latestRef.current;
|
|
784
|
+
const request: SendRequest = {
|
|
785
|
+
draft: current.draft!,
|
|
786
|
+
mailboxUid: current.mailboxUid!,
|
|
787
|
+
mailbox: current.mailbox ?? undefined,
|
|
788
|
+
policy: current.encryptionPolicy ?? undefined,
|
|
789
|
+
toText: current.to,
|
|
790
|
+
ccText: current.cc,
|
|
791
|
+
bccText: current.bcc,
|
|
792
|
+
to: parseAddresses(current.to),
|
|
793
|
+
cc: parseAddresses(current.cc),
|
|
794
|
+
bcc: parseAddresses(current.bcc),
|
|
795
|
+
subject: current.subject,
|
|
796
|
+
html: current.html,
|
|
797
|
+
attachments: current.attachments,
|
|
798
|
+
requestReceipt: current.requestReceipt,
|
|
799
|
+
scheduledSendTime: scheduledSendTimeIso,
|
|
800
|
+
signEnabled: current.signEnabled,
|
|
801
|
+
offeredSign: offeredCryptoRef.current.sign,
|
|
802
|
+
offeredEncrypt: offeredCryptoRef.current.encrypt,
|
|
803
|
+
encryptRequested: current.encryptRequested,
|
|
804
|
+
forcePlaintext,
|
|
805
|
+
saved: saveInFlightRef.current,
|
|
806
|
+
};
|
|
807
|
+
if (request.to.length === 0) {
|
|
808
|
+
setSendError("At least one recipient is required.");
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
const early = decideSend(sendDecisionInput(request, request.mailbox, request.policy, current.statuses, false));
|
|
812
|
+
if (early.action === "rejected") {
|
|
813
|
+
setSendError(early.message);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
if (early.action === "blocked") {
|
|
974
817
|
setBlockedScheduleIso(scheduledSendTimeIso);
|
|
818
|
+
setSecurityBlock(early.block);
|
|
819
|
+
if (early.block.keysLocked && (hasEnrolledSigningKey || hasEnrolledEncryptionKey)) {
|
|
820
|
+
void handleUnlockForCrypto();
|
|
821
|
+
}
|
|
975
822
|
return;
|
|
976
823
|
}
|
|
977
|
-
|
|
978
|
-
// plaintext copy over it if the rest of the send fails.
|
|
979
|
-
lastSavedRef.current = `${assembled.uid}:${contentKey}`;
|
|
980
|
-
const withReceipt = requestReceipt ? await setMessageRequestReceipt(assembled, true) : assembled;
|
|
981
|
-
await sendMessage(withReceipt.uid, scheduledSendTimeIso ? { scheduledSendTime: scheduledSendTimeIso } : undefined);
|
|
824
|
+
pendingSaveRef.current = false;
|
|
982
825
|
finishedRef.current = true;
|
|
826
|
+
if (!startSend(request)) {
|
|
827
|
+
finishedRef.current = false;
|
|
828
|
+
setSendError("This message is already being sent.");
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
983
831
|
onClose();
|
|
984
|
-
} catch (err) {
|
|
985
|
-
// The window stays open with the draft exactly as it was; nothing here closes, discards or saves over it.
|
|
986
|
-
const failure = describeSendFailure(
|
|
987
|
-
err,
|
|
988
|
-
scheduledSendTimeIso ? "Could not schedule this message." : "Could not send this message.",
|
|
989
|
-
);
|
|
990
|
-
setSendError(failure.message);
|
|
991
|
-
setSendDetails(failure.lines);
|
|
992
832
|
} finally {
|
|
993
|
-
|
|
994
|
-
|
|
833
|
+
submittingRef.current = false;
|
|
834
|
+
setWaitingForUploads(false);
|
|
995
835
|
}
|
|
996
836
|
}
|
|
997
837
|
|
|
@@ -1003,7 +843,7 @@ export default function ComposeWindow({
|
|
|
1003
843
|
async function handleUnlockForCrypto() {
|
|
1004
844
|
try {
|
|
1005
845
|
// Only reachable when hasEnrolledSigningKey/hasEnrolledEncryptionKey found a real enrolled key
|
|
1006
|
-
// in mailbox.keys (the unlock button below, or
|
|
846
|
+
// in mailbox.keys (the unlock button below, or a blocked send()), so neither is empty/undefined here.
|
|
1007
847
|
await requestUnlock(mailbox!.uid, mailbox!.keys!);
|
|
1008
848
|
setUnlockRefresh((n) => n + 1);
|
|
1009
849
|
} catch {
|
|
@@ -1018,7 +858,7 @@ export default function ComposeWindow({
|
|
|
1018
858
|
* `latestRef` only once it actually runs, so it saves what was last typed (skipping the request when the
|
|
1019
859
|
* save before it already stored exactly that). Whether a save may happen at all is re-checked at that same
|
|
1020
860
|
* moment: while it waited, the message may have become one that must not be stored as plaintext (Encrypt
|
|
1021
|
-
* turned on, a recipient that auto-encrypts), or the window may have
|
|
861
|
+
* turned on, a recipient that auto-encrypts), or the window may have been sent or finished - then it
|
|
1022
862
|
* resolves `{ skipped: true }` without a request (`message` then being the previous save's result). Never
|
|
1023
863
|
* rejects; `message` is `undefined` when the save failed.
|
|
1024
864
|
*/
|
|
@@ -1030,7 +870,7 @@ export default function ComposeWindow({
|
|
|
1030
870
|
const outcome = (async (): Promise<{ message?: Message; skipped?: boolean }> => {
|
|
1031
871
|
const before = await previous;
|
|
1032
872
|
const current = latestRef.current;
|
|
1033
|
-
if (current.autosaveSuppressed ||
|
|
873
|
+
if (current.autosaveSuppressed || finishedRef.current) {
|
|
1034
874
|
setSaveStatus("idle");
|
|
1035
875
|
// Still the newest copy the server holds, for the version a later delete needs.
|
|
1036
876
|
return { skipped: true, message: before };
|
|
@@ -1150,39 +990,19 @@ export default function ComposeWindow({
|
|
|
1150
990
|
}
|
|
1151
991
|
|
|
1152
992
|
/** "Close": keeps the draft (saving any unsaved edits first, and closing only once that save succeeded) -
|
|
1153
|
-
* unless there's nothing in it worth keeping, or it can't be saved because it's headed for encryption
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
1156
|
-
*
|
|
1157
|
-
|
|
1158
|
-
async function handleClose(sendFailureAcknowledged = false) {
|
|
993
|
+
* unless there's nothing in it worth keeping, or it can't be saved because it's really headed for encryption
|
|
994
|
+
* (asked for, or applied by the loaded policy): that needs the same confirmation as a discard. Whatever is
|
|
995
|
+
* *unknown* (settings loading or failed, a lookup pending) is not a reason to hold the window: it is saved like any
|
|
996
|
+
* other message. A failed save keeps the window open with Discard / Keep editing. */
|
|
997
|
+
async function handleClose() {
|
|
1159
998
|
if (!hasUserContent) {
|
|
1160
999
|
void discardNow();
|
|
1161
1000
|
return;
|
|
1162
1001
|
}
|
|
1163
|
-
if (sendError && !sendFailureAcknowledged) {
|
|
1164
|
-
// Closing would keep the draft and say nothing, which reads as if it had been sent.
|
|
1165
|
-
setClosePrompt({ title: SEND_FAILED_TITLE, message: sendFailedCloseMessage(sendError), closeAnyway: true });
|
|
1166
|
-
return;
|
|
1167
|
-
}
|
|
1168
1002
|
if (encryptionDecided) {
|
|
1169
1003
|
setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
|
|
1170
1004
|
return;
|
|
1171
1005
|
}
|
|
1172
|
-
if (cryptoCheckUnavailable) {
|
|
1173
|
-
setClosePrompt({ title: DISCARD_TITLE, message: CRYPTO_UNAVAILABLE_MESSAGE, retry: true });
|
|
1174
|
-
return;
|
|
1175
|
-
}
|
|
1176
|
-
if (awaitingRecipients) {
|
|
1177
|
-
setClosePrompt({ title: DISCARD_TITLE, message: NO_RECIPIENTS_CLOSE_MESSAGE });
|
|
1178
|
-
return;
|
|
1179
|
-
}
|
|
1180
|
-
if (encryptionUndetermined) {
|
|
1181
|
-
// A recipient typed but never blurred has no lookup running yet.
|
|
1182
|
-
checkRecipientDiscovery([...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)]);
|
|
1183
|
-
setClosePrompt({ title: DISCARD_TITLE, message: CHECKING_CLOSE_MESSAGE });
|
|
1184
|
-
return;
|
|
1185
|
-
}
|
|
1186
1006
|
if (!draft || !contentReady) {
|
|
1187
1007
|
saveErrorRef.current = draftError ?? "the draft hasn't been created yet";
|
|
1188
1008
|
showSaveFailedPrompt();
|
|
@@ -1194,7 +1014,7 @@ export default function ComposeWindow({
|
|
|
1194
1014
|
setClosing(false);
|
|
1195
1015
|
if (outcome.skipped) {
|
|
1196
1016
|
// It became a message that mustn't be saved as plaintext while waiting on an earlier save.
|
|
1197
|
-
setClosePrompt({ title: DISCARD_TITLE, message:
|
|
1017
|
+
setClosePrompt({ title: DISCARD_TITLE, message: ENCRYPTED_CLOSE_MESSAGE });
|
|
1198
1018
|
return;
|
|
1199
1019
|
}
|
|
1200
1020
|
if (!outcome.message) {
|
|
@@ -1206,14 +1026,6 @@ export default function ComposeWindow({
|
|
|
1206
1026
|
onClose();
|
|
1207
1027
|
}
|
|
1208
1028
|
|
|
1209
|
-
function retryCryptoContext() {
|
|
1210
|
-
setClosePrompt(null);
|
|
1211
|
-
setCryptoRetryToken((n) => n + 1);
|
|
1212
|
-
lookupFailuresRef.current = new Map();
|
|
1213
|
-
setExhaustedLookups({});
|
|
1214
|
-
setLookupRetryToken((n) => n + 1);
|
|
1215
|
-
}
|
|
1216
|
-
|
|
1217
1029
|
// A plain read from keySession.ts's module-level session store, not React state - see that module's
|
|
1218
1030
|
// own doc comment. Cheap enough to read fresh on every render rather than caching in state.
|
|
1219
1031
|
const unlockedKeys = mailboxUid ? getUnlockedKeys(mailboxUid) : undefined;
|
|
@@ -1248,29 +1060,65 @@ export default function ComposeWindow({
|
|
|
1248
1060
|
const contentKey = JSON.stringify([to, cc, bcc, subject, html]);
|
|
1249
1061
|
const baselineKey = JSON.stringify([baselineTo ?? "", baselineCc ?? "", "", initialSubject ?? "", seededHtml ?? ""]);
|
|
1250
1062
|
const hasUserContent = contentKey !== baselineKey || hasUploads;
|
|
1251
|
-
//
|
|
1252
|
-
//
|
|
1253
|
-
//
|
|
1254
|
-
//
|
|
1255
|
-
//
|
|
1256
|
-
//
|
|
1063
|
+
// Whether this message is *really* headed for encryption - `encryptionRequirement.ts` decides, and it fails open: the message is plain (its
|
|
1064
|
+
// 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
|
|
1065
|
+
// recipient (whose keys were all looked up). What has not loaded, or failed to, is not held against the message.
|
|
1066
|
+
//
|
|
1067
|
+
// Transitions, when a later load turns a plain message into an encrypted one (the policy arrives, a slow lookup answers, a recipient is
|
|
1068
|
+
// added): (1) autosave stops at once (`autosaveSuppressed`); (2) a plaintext copy this window already saved is *replaced by an empty draft*
|
|
1069
|
+
// (`scrubSavedDraft()` below) so the plaintext doesn't sit on the server; (3) Close asks first ("Encrypted messages aren't saved as drafts");
|
|
1070
|
+
// (4) Send encrypts (or is refused, with the override, when it can't) - never silently plaintext. And back: when it stops being encrypted
|
|
1071
|
+
// (a recipient removed) autosave simply resumes.
|
|
1257
1072
|
const currentAddresses = [...parseAddresses(to), ...parseAddresses(cc), ...parseAddresses(bcc)].map((r) => r.address);
|
|
1258
1073
|
const currentStatuses = currentAddresses.map((address) => recipientStatuses[address]);
|
|
1259
|
-
const
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
const
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
latestRef.current = {
|
|
1074
|
+
const hasEncryptionKey = !!unlockedKeys?.encryptionPrivateKey || hasEnrolledEncryptionKey || offeredCryptoRef.current.encrypt;
|
|
1075
|
+
const requirement = evaluateEncryptionRequirement({
|
|
1076
|
+
encryptRequested,
|
|
1077
|
+
policy: encryptionPolicy ?? undefined,
|
|
1078
|
+
mailboxLoaded: !!mailbox,
|
|
1079
|
+
hasEncryptionKey,
|
|
1080
|
+
recipients: currentStatuses,
|
|
1081
|
+
});
|
|
1082
|
+
const encryptionDecided = requirement.required;
|
|
1083
|
+
const autosaveSuppressed = encryptionDecided;
|
|
1084
|
+
const latestRef = useRef({
|
|
1085
|
+
draft, mailboxUid, mailbox, encryptionPolicy, to, cc, bcc, subject, html, attachments, requestReceipt, signEnabled, encryptRequested,
|
|
1086
|
+
statuses: currentStatuses, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided,
|
|
1087
|
+
});
|
|
1088
|
+
latestRef.current = {
|
|
1089
|
+
draft, mailboxUid, mailbox, encryptionPolicy, to, cc, bcc, subject, html, attachments, requestReceipt, signEnabled, encryptRequested,
|
|
1090
|
+
statuses: currentStatuses, seededHtml, contentKey, saveStatus, hasUserContent, autosaveSuppressed, encryptionDecided,
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
/** 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
|
|
1094
|
+
* uid, so attachments and the folder are undisturbed), chained after any save still on the wire. Best effort - a failure is reported once. */
|
|
1095
|
+
function scrubSavedDraft(target: Message) {
|
|
1096
|
+
lastSavedRef.current = null;
|
|
1097
|
+
setSaveStatus("idle");
|
|
1098
|
+
const previous = saveInFlightRef.current;
|
|
1099
|
+
const scrub = (async (): Promise<Message | undefined> => {
|
|
1100
|
+
await previous;
|
|
1101
|
+
try {
|
|
1102
|
+
// Its answer (the new version) is what a later delete waits for through `saveInFlightRef`, like any save's.
|
|
1103
|
+
return await assembleDraft(target.uid, { to: [], cc: [], bcc: [], subject: "", html: "" });
|
|
1104
|
+
} catch {
|
|
1105
|
+
notify({
|
|
1106
|
+
id: `scrub-draft:${target.uid}`,
|
|
1107
|
+
kind: "warning",
|
|
1108
|
+
title: "An earlier draft copy is still saved",
|
|
1109
|
+
message: "This message needs encryption, but the copy saved before that was known couldn't be replaced. Discarding the draft removes it.",
|
|
1110
|
+
});
|
|
1111
|
+
return undefined;
|
|
1112
|
+
}
|
|
1113
|
+
})();
|
|
1114
|
+
saveInFlightRef.current = scrub;
|
|
1115
|
+
}
|
|
1116
|
+
// Only a message that was really saved (`lastSavedRef` names its draft) has a plaintext copy to replace.
|
|
1117
|
+
useEffect(() => {
|
|
1118
|
+
if (encryptionDecided && draft && lastSavedRef.current?.startsWith(`${draft.uid}:`)) {
|
|
1119
|
+
scrubSavedDraft(draft);
|
|
1120
|
+
}
|
|
1121
|
+
}, [encryptionDecided, draft?.uid]);
|
|
1274
1122
|
|
|
1275
1123
|
// Recipients a reply could only work out after this window opened (Reply All: whoever the original's headers name)
|
|
1276
1124
|
// replace the ones it opened with - but only in a field the user hasn't touched, never over what they typed.
|
|
@@ -1292,8 +1140,6 @@ export default function ComposeWindow({
|
|
|
1292
1140
|
const due =
|
|
1293
1141
|
!!draft &&
|
|
1294
1142
|
contentReady &&
|
|
1295
|
-
cryptoContextReady &&
|
|
1296
|
-
!sending &&
|
|
1297
1143
|
!autosaveSuppressed &&
|
|
1298
1144
|
hasUserContent &&
|
|
1299
1145
|
lastSavedRef.current !== `${draft.uid}:${contentKey}`;
|
|
@@ -1303,7 +1149,7 @@ export default function ComposeWindow({
|
|
|
1303
1149
|
}
|
|
1304
1150
|
const timer = setTimeout(() => void saveDraftNow(), autosaveDelayMs);
|
|
1305
1151
|
return () => clearTimeout(timer);
|
|
1306
|
-
}, [draft, contentReady,
|
|
1152
|
+
}, [draft, contentReady, autosaveSuppressed, hasUserContent, contentKey]);
|
|
1307
1153
|
|
|
1308
1154
|
// Unmounting with an edit still waiting on the debounce (e.g. the whole app shell unmounting) saves it
|
|
1309
1155
|
// right away rather than losing it.
|
|
@@ -1352,7 +1198,7 @@ export default function ComposeWindow({
|
|
|
1352
1198
|
// out - that navigation must not be cancellable.
|
|
1353
1199
|
useEffect(() => {
|
|
1354
1200
|
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
|
1355
|
-
if (finishedRef.current ||
|
|
1201
|
+
if (finishedRef.current || isSigningOut()) {
|
|
1356
1202
|
return;
|
|
1357
1203
|
}
|
|
1358
1204
|
const latest = latestRef.current;
|
|
@@ -1374,7 +1220,7 @@ export default function ComposeWindow({
|
|
|
1374
1220
|
// Where the caret starts, once per session: a reply/forward in the body (at its top), a new message in To - or,
|
|
1375
1221
|
// with To already filled in (e.g. Contacts' "Email"), in Subject. Minimizing unmounts the fields, so restoring a
|
|
1376
1222
|
// window must not move the caret there again.
|
|
1377
|
-
const isReplyOrForward = initialQuotedHtml !== undefined || !!quotePending;
|
|
1223
|
+
const isReplyOrForward = initialQuotedHtml !== undefined || !!quotePending || !!resume;
|
|
1378
1224
|
const fieldsFocusedRef = useRef(false);
|
|
1379
1225
|
const bodyFocusedRef = useRef(false);
|
|
1380
1226
|
const focusToOnMount = !isReplyOrForward && !initialTo && !fieldsFocusedRef.current;
|
|
@@ -1410,13 +1256,15 @@ export default function ComposeWindow({
|
|
|
1410
1256
|
// Keyboard shortcuts, active while the focus is inside this window (`container`; the scope attribute on its root is what tells the
|
|
1411
1257
|
// 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 -
|
|
1412
1258
|
// a shortcut pressed while its button would be disabled is consumed and does nothing.
|
|
1413
|
-
|
|
1259
|
+
// Send never waits for the encryption settings to load (fail open, see `encryptionRequirement.ts`); it only needs the draft to exist. Pressed
|
|
1260
|
+
// while an earlier press is still being handled it does nothing (`submit()` guards that itself).
|
|
1261
|
+
const canSend = !!draft && !closing && !waitingForUploads;
|
|
1414
1262
|
useShortcut(SHORTCUTS.compose.send, () => void (canSend && submit(false)), { container: windowRef });
|
|
1415
1263
|
useShortcut(
|
|
1416
1264
|
SHORTCUTS.compose.saveDraft,
|
|
1417
1265
|
() => {
|
|
1418
|
-
// Autosave's own conditions: never a draft that must not be stored as plaintext
|
|
1419
|
-
if (draft && contentReady &&
|
|
1266
|
+
// Autosave's own conditions: never a draft that must not be stored as plaintext.
|
|
1267
|
+
if (draft && contentReady && !closing && !autosaveSuppressed && hasUserContent) {
|
|
1420
1268
|
void saveDraftNow();
|
|
1421
1269
|
}
|
|
1422
1270
|
},
|
|
@@ -1425,7 +1273,7 @@ export default function ComposeWindow({
|
|
|
1425
1273
|
useShortcut(
|
|
1426
1274
|
SHORTCUTS.compose.close,
|
|
1427
1275
|
() => {
|
|
1428
|
-
if (!
|
|
1276
|
+
if (!closing) {
|
|
1429
1277
|
void handleClose();
|
|
1430
1278
|
}
|
|
1431
1279
|
},
|
|
@@ -1440,25 +1288,7 @@ export default function ComposeWindow({
|
|
|
1440
1288
|
<Modal open={closePrompt !== null} onClose={() => setClosePrompt(null)} title={closePrompt?.title ?? DISCARD_TITLE}>
|
|
1441
1289
|
<p className="text-sm text-text-muted mb-4">{closePrompt?.message}</p>
|
|
1442
1290
|
<div className="flex gap-3">
|
|
1443
|
-
{
|
|
1444
|
-
<Button type="button" onClick={retryCryptoContext} className="!w-auto">
|
|
1445
|
-
Retry
|
|
1446
|
-
</Button>
|
|
1447
|
-
)}
|
|
1448
|
-
{closePrompt?.closeAnyway && (
|
|
1449
|
-
<Button
|
|
1450
|
-
type="button"
|
|
1451
|
-
onClick={() => {
|
|
1452
|
-
setClosePrompt(null);
|
|
1453
|
-
void handleClose(true);
|
|
1454
|
-
}}
|
|
1455
|
-
disabled={sending || closing}
|
|
1456
|
-
className="!w-auto"
|
|
1457
|
-
>
|
|
1458
|
-
Close, keep draft
|
|
1459
|
-
</Button>
|
|
1460
|
-
)}
|
|
1461
|
-
<Button type="button" onClick={() => void discardNow()} disabled={sending || closing} className="!w-auto">
|
|
1291
|
+
<Button type="button" onClick={() => void discardNow()} disabled={closing} className="!w-auto">
|
|
1462
1292
|
Discard
|
|
1463
1293
|
</Button>
|
|
1464
1294
|
<Button type="button" variant="secondary" onClick={() => setClosePrompt(null)} className="!w-auto">
|
|
@@ -1477,10 +1307,9 @@ export default function ComposeWindow({
|
|
|
1477
1307
|
onClick={onToggleMinimize}
|
|
1478
1308
|
>
|
|
1479
1309
|
<span className="text-sm font-medium truncate">{title}</span>
|
|
1480
|
-
{sendError && <span className="shrink-0 text-xs font-bold uppercase tracking-wide">Not sent</span>}
|
|
1481
1310
|
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
|
1482
1311
|
<HeaderButton label="Restore" onClick={onToggleMinimize} icon={HiOutlineArrowsPointingOut} />
|
|
1483
|
-
<HeaderButton label="Discard draft" onClick={handleDiscard} icon={HiOutlineXMark} disabled={
|
|
1312
|
+
<HeaderButton label="Discard draft" onClick={handleDiscard} icon={HiOutlineXMark} disabled={closing} />
|
|
1484
1313
|
</div>
|
|
1485
1314
|
</div>
|
|
1486
1315
|
</div>
|
|
@@ -1541,55 +1370,28 @@ export default function ComposeWindow({
|
|
|
1541
1370
|
icon={expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut}
|
|
1542
1371
|
/>
|
|
1543
1372
|
)}
|
|
1544
|
-
<HeaderButton label="Close" onClick={() => void handleClose()} icon={HiOutlineXMark} disabled={
|
|
1373
|
+
<HeaderButton label="Close" onClick={() => void handleClose()} icon={HiOutlineXMark} disabled={closing} shortcut={SHORTCUTS.compose.close} />
|
|
1545
1374
|
</div>
|
|
1546
1375
|
</div>
|
|
1547
1376
|
|
|
1548
1377
|
<div className="flex-1 min-h-0 flex flex-col">
|
|
1549
1378
|
{(folderError || draftError || sendError || attachError || discardError) && (
|
|
1550
|
-
// Scrolls inside the window when it is tall
|
|
1551
|
-
//
|
|
1379
|
+
// Scrolls inside the window when it is tall, instead of pushing the editor and the Send button out of the window's
|
|
1380
|
+
// fixed height, where they can't be reached.
|
|
1552
1381
|
<div className="px-3 pt-2 shrink-0 max-h-[45%] overflow-y-auto">
|
|
1553
1382
|
{folderError && <Alert>{folderError}</Alert>}
|
|
1554
1383
|
{draftError && <Alert>{draftError}</Alert>}
|
|
1555
|
-
{sendError && <
|
|
1384
|
+
{sendError && <Alert>{sendError}</Alert>}
|
|
1556
1385
|
{attachError && <Alert>{attachError}</Alert>}
|
|
1557
1386
|
{discardError && <Alert>{discardError}</Alert>}
|
|
1558
1387
|
</div>
|
|
1559
1388
|
)}
|
|
1560
1389
|
|
|
1561
|
-
{cryptoCheckUnavailable && (
|
|
1562
|
-
<div className="px-3 pt-2">
|
|
1563
|
-
<Alert>
|
|
1564
|
-
<p className="mb-2">{CRYPTO_UNAVAILABLE_MESSAGE}</p>
|
|
1565
|
-
<Button type="button" variant="secondary" className="!w-auto" onClick={retryCryptoContext}>
|
|
1566
|
-
Retry
|
|
1567
|
-
</Button>
|
|
1568
|
-
</Alert>
|
|
1569
|
-
</div>
|
|
1570
|
-
)}
|
|
1571
|
-
|
|
1572
|
-
{encryptionBlocked && (
|
|
1573
|
-
<div className="px-3 pt-2">
|
|
1574
|
-
<Alert>
|
|
1575
|
-
<p className="mb-2">
|
|
1576
|
-
This message can’t be encrypted for everyone: {encryptionBlocked.map((r) => r.address).join(", ")}
|
|
1577
|
-
{encryptionBlocked[0]?.prohibitedReason ? ` — ${encryptionBlocked[0].prohibitedReason}` : " has no encryption key on file"}.
|
|
1578
|
-
Remove {encryptionBlocked.length > 1 ? "these recipients" : "this recipient"} from To/Cc/Bcc, or send the
|
|
1579
|
-
whole message in plaintext.
|
|
1580
|
-
</p>
|
|
1581
|
-
<Button type="button" disabled={sending} onClick={() => void submit(true, blockedScheduleIso)}>
|
|
1582
|
-
Send without encryption
|
|
1583
|
-
</Button>
|
|
1584
|
-
</Alert>
|
|
1585
|
-
</div>
|
|
1586
|
-
)}
|
|
1587
|
-
|
|
1588
1390
|
{securityBlock && (
|
|
1589
1391
|
<div className="px-3 pt-2">
|
|
1590
1392
|
<Alert>
|
|
1591
1393
|
<p className="mb-2">{securityBlock.message}</p>
|
|
1592
|
-
<Button type="button"
|
|
1394
|
+
<Button type="button" onClick={() => void submit(true, blockedScheduleIso)}>
|
|
1593
1395
|
{securityBlock.overrideLabel}
|
|
1594
1396
|
</Button>
|
|
1595
1397
|
</Alert>
|
|
@@ -1605,7 +1407,7 @@ export default function ComposeWindow({
|
|
|
1605
1407
|
id={`compose-from-${id}`}
|
|
1606
1408
|
className={`${FIELD_INPUT} disabled:opacity-55`}
|
|
1607
1409
|
value={mailboxUid ?? ""}
|
|
1608
|
-
disabled={hasUploads
|
|
1410
|
+
disabled={hasUploads}
|
|
1609
1411
|
title={hasUploads ? "The sender can't be changed after adding attachments or images." : undefined}
|
|
1610
1412
|
onChange={(e) => handleFromChange(e.target.value)}
|
|
1611
1413
|
>
|
|
@@ -1762,7 +1564,7 @@ export default function ComposeWindow({
|
|
|
1762
1564
|
)}
|
|
1763
1565
|
|
|
1764
1566
|
{/* Only rendered once this session actually has a usable signing/encryption key - see
|
|
1765
|
-
`
|
|
1567
|
+
`sendJob.ts`'s signing path for why signing rarely shows today (no signing
|
|
1766
1568
|
certificate exists until RFC 8823 ACME enrollment lands). */}
|
|
1767
1569
|
{unlockedKeys?.signingPrivateKey && (
|
|
1768
1570
|
<label className="flex items-center gap-1.5 px-3 pb-1 text-xs text-text-muted">
|
|
@@ -1784,10 +1586,10 @@ export default function ComposeWindow({
|
|
|
1784
1586
|
onClick={() => void submit(false)}
|
|
1785
1587
|
title={withHint("Send", SHORTCUTS.compose.send, keyEnv)}
|
|
1786
1588
|
aria-keyshortcuts={ariaKeyShortcuts(SHORTCUTS.compose.send, keyEnv)}
|
|
1787
|
-
disabled={!draft ||
|
|
1589
|
+
disabled={!draft || closing || waitingForUploads}
|
|
1788
1590
|
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"
|
|
1789
1591
|
>
|
|
1790
|
-
|
|
1592
|
+
Send
|
|
1791
1593
|
</button>
|
|
1792
1594
|
<button
|
|
1793
1595
|
ref={scheduleButtonRef}
|
|
@@ -1795,7 +1597,7 @@ export default function ComposeWindow({
|
|
|
1795
1597
|
aria-label="Send later"
|
|
1796
1598
|
aria-haspopup="true"
|
|
1797
1599
|
aria-expanded={schedulePickerOpen}
|
|
1798
|
-
disabled={!draft ||
|
|
1600
|
+
disabled={!draft || closing || waitingForUploads}
|
|
1799
1601
|
onClick={() => setSchedulePickerOpen((o) => !o)}
|
|
1800
1602
|
className="py-1.5 px-2 border-l border-white/30 hover:not-disabled:bg-primary-dark disabled:opacity-55 disabled:cursor-not-allowed"
|
|
1801
1603
|
>
|
|
@@ -1820,7 +1622,7 @@ export default function ComposeWindow({
|
|
|
1820
1622
|
</label>
|
|
1821
1623
|
|
|
1822
1624
|
<span role="status" aria-live="polite" className="ml-auto text-xs text-text-muted">
|
|
1823
|
-
{SAVE_STATUS_LABEL[saveStatus]}
|
|
1625
|
+
{waitingForUploads ? "Waiting for attachments to finish uploading…" : SAVE_STATUS_LABEL[saveStatus]}
|
|
1824
1626
|
</span>
|
|
1825
1627
|
|
|
1826
1628
|
<button
|
|
@@ -1828,7 +1630,7 @@ export default function ComposeWindow({
|
|
|
1828
1630
|
aria-label="Discard draft"
|
|
1829
1631
|
title="Discard draft"
|
|
1830
1632
|
onClick={handleDiscard}
|
|
1831
|
-
disabled={
|
|
1633
|
+
disabled={closing}
|
|
1832
1634
|
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"
|
|
1833
1635
|
>
|
|
1834
1636
|
<HiOutlineTrash size={18} />
|