@rapidmx/web-client 0.15.1 → 0.17.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 +381 -373
- package/apps/admin/branding/index.tsx +39 -39
- package/apps/admin/data-requests/index.tsx +482 -480
- package/apps/admin/distribution-lists/new/index.tsx +3 -1
- package/apps/admin/domains/[uid].tsx +166 -164
- package/apps/admin/domains/new/index.tsx +3 -1
- package/apps/admin/escrow-scopes/[uid].tsx +350 -348
- package/apps/admin/escrow-scopes/new/index.tsx +3 -1
- package/apps/admin/index.tsx +129 -124
- package/apps/admin/mailboxes/[uid].tsx +271 -224
- package/apps/admin/mailboxes/new/index.tsx +30 -28
- package/apps/admin/plugins/index.tsx +15 -15
- package/apps/admin/retention-policy/index.tsx +39 -39
- package/apps/admin/signing-certificates/index.tsx +343 -343
- package/apps/admin/transport-rules/new/index.tsx +3 -1
- package/apps/escrow/audit-log/index.tsx +196 -196
- package/apps/escrow/matters/[uid].tsx +621 -621
- package/apps/escrow/matters/new/index.tsx +3 -1
- package/apps/shared/auth/adminAccess.ts +99 -99
- package/apps/shared/components/admin/layout/AdminShell.tsx +381 -378
- package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -0
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
- package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -0
- package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +50 -3
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1545
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
- package/apps/shared/components/admin/setup/SetupWizard.tsx +446 -444
- package/apps/shared/components/admin/usePagedList.tsx +129 -129
- package/apps/shared/components/calendar/EventEditor.tsx +5 -10
- package/apps/shared/components/calendar/EventFormParts.tsx +16 -47
- package/apps/shared/components/calendar/EventModal.tsx +206 -206
- package/apps/shared/components/calendar/GuestInput.tsx +100 -0
- package/apps/shared/components/calendar/MonthView.tsx +185 -185
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
- package/apps/shared/components/calendar/RequestChangeForm.tsx +23 -54
- package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
- package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
- package/apps/shared/components/calendar/allDay.ts +124 -124
- package/apps/shared/components/calendar/eventForm.ts +2 -2
- package/apps/shared/components/calendar/eventFormat.ts +49 -22
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +201 -200
- package/apps/shared/components/contacts/ContactForm.tsx +383 -383
- package/apps/shared/components/contacts/ContactsSidebar.tsx +1 -1
- package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -98
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +218 -216
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
- package/apps/shared/components/layout/AppShell.tsx +461 -480
- package/apps/shared/components/layout/FloatingActionButton.tsx +31 -0
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
- package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
- package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
- package/apps/shared/components/layout/UserMenu.tsx +439 -424
- package/apps/shared/components/mail/ConversationList.tsx +323 -323
- package/apps/shared/components/mail/ConversationThreadPane.tsx +613 -491
- package/apps/shared/components/mail/EmptyFolderBar.tsx +41 -0
- package/apps/shared/components/mail/MailSelectionBar.tsx +240 -227
- package/apps/shared/components/mail/MenuButton.tsx +31 -11
- package/apps/shared/components/mail/MessageDetailPane.tsx +1757 -1559
- package/apps/shared/components/mail/PermanentDeleteDialog.tsx +47 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1677
- package/apps/shared/components/mail/compose/RecipientInput.tsx +54 -11
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
- package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
- package/apps/shared/components/mail/layout/FolderBadgeChip.tsx +21 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +161 -134
- package/apps/shared/components/mail/layout/SidebarSection.tsx +66 -0
- package/apps/shared/components/mail/reading/MessageMoreMenu.tsx +258 -0
- package/apps/shared/components/mail/reading/MessageSourceDialog.tsx +79 -0
- package/apps/shared/components/mail/reading/PendingMessageCard.tsx +68 -0
- package/apps/shared/components/mail/reading/messageExport.ts +59 -0
- package/apps/shared/components/mail/reading/printMessage.ts +99 -0
- package/apps/shared/components/mail/reading/useMessageActions.ts +443 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -125
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
- package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
- package/apps/shared/components/settings/layout/SettingsShell.tsx +257 -254
- package/apps/shared/components/tasks/TasksSidebar.tsx +1 -1
- package/apps/shared/components/tasks/layout/TasksShell.tsx +219 -217
- package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
- package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
- package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
- package/apps/shared/keyboard/dispatch.ts +124 -124
- package/apps/shared/keyboard/format.ts +89 -89
- package/apps/shared/keyboard/keymap.ts +114 -114
- package/apps/shared/keyboard/registry.ts +65 -65
- package/apps/shared/keyboard/targets.ts +79 -79
- package/apps/shared/mail/folderCounts.ts +17 -0
- package/apps/shared/mail/folderOfType.ts +49 -0
- package/apps/shared/mail/folderTree.ts +143 -143
- package/apps/shared/mail/listAllPages.ts +39 -39
- package/apps/shared/mail/newMailNotifications.ts +171 -171
- package/apps/shared/mail/outbox/outgoingReplies.ts +192 -0
- package/apps/shared/mail/outbox/sendJob.ts +19 -1
- package/apps/shared/mail/outbox/sendOutcomes.ts +13 -4
- package/apps/shared/mail/permanentDelete.ts +160 -0
- package/apps/shared/mail/primaryMailbox.ts +71 -0
- package/apps/shared/mail/reportNotices.ts +66 -0
- package/apps/shared/mail/senderBlocking.ts +141 -0
- package/apps/shared/mail/useCollapsedSections.ts +146 -0
- package/apps/shared/mail/useMailConnection.ts +205 -203
- package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
- package/apps/shared/mail/useMailboxUpdateAccess.ts +60 -0
- package/apps/shared/mail/useMarkMessageRead.ts +47 -47
- package/apps/shared/mail/useNewMailNotifications.ts +178 -178
- package/apps/shared/mail/usePermanentDelete.tsx +160 -0
- package/apps/shared/navigation/frameContext.tsx +1 -1
- package/apps/shared/navigation/index.ts +43 -0
- package/apps/shared/navigation/pageTitle.ts +19 -0
- package/apps/shared/notifications/store.ts +560 -560
- package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
- package/apps/shared/search/crossMailboxSearch.ts +169 -0
- package/apps/shared/search/localIndexBuilder.ts +481 -481
- package/apps/shared/signing/enrollmentStorage.ts +33 -33
- package/apps/shared/signing/enrollmentTracker.ts +385 -385
- package/apps/shared/signing/enrollmentView.ts +251 -251
- package/apps/shared/signing/useNow.ts +19 -19
- package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
- package/apps/shared/styles/app.css +396 -386
- package/apps/www/_shell.tsx +82 -0
- package/apps/www/calendar/index.tsx +581 -571
- package/apps/www/contacts/[uid].tsx +112 -109
- package/apps/www/contacts/index.tsx +14 -3
- package/apps/www/index.tsx +2962 -2479
- package/apps/www/messages/[uid].tsx +139 -135
- package/apps/www/settings/appearance/index.tsx +5 -2
- package/apps/www/settings/auto-reply/index.tsx +136 -133
- package/apps/www/settings/blocked-senders/index.tsx +303 -0
- package/apps/www/settings/encryption/index.tsx +1290 -1287
- package/apps/www/settings/filters/[uid].tsx +179 -176
- package/apps/www/settings/filters/index.tsx +105 -102
- package/apps/www/settings/filters/new/index.tsx +165 -140
- package/apps/www/settings/labels/index.tsx +207 -204
- package/apps/www/settings/privacy/index.tsx +495 -492
- package/apps/www/settings/profile/index.tsx +251 -248
- package/apps/www/settings/read-receipts/index.tsx +150 -147
- package/apps/www/settings/sharing/index.tsx +259 -256
- package/apps/www/settings/signatures/[uid].tsx +175 -172
- package/apps/www/settings/signatures/index.tsx +91 -88
- package/apps/www/settings/signatures/new/index.tsx +138 -135
- package/apps/www/tasks/index.tsx +654 -649
- package/dist/apps/admin/data-requests/index.js +1 -1
- package/dist/apps/admin/distribution-lists/new/index.js +3 -1
- package/dist/apps/admin/domains/[uid].js +3 -1
- package/dist/apps/admin/domains/new/index.js +3 -1
- package/dist/apps/admin/escrow-scopes/[uid].js +3 -1
- package/dist/apps/admin/escrow-scopes/new/index.js +3 -1
- package/dist/apps/admin/index.js +5 -2
- package/dist/apps/admin/mailboxes/[uid].js +15 -3
- package/dist/apps/admin/mailboxes/new/index.js +3 -1
- package/dist/apps/admin/transport-rules/new/index.js +3 -1
- package/dist/apps/escrow/matters/new/index.js +3 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +6 -3
- package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.d.ts +35 -0
- package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +115 -0
- package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.d.ts +16 -0
- package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +92 -0
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +25 -3
- package/dist/apps/shared/components/admin/setup/SetupWizard.js +3 -1
- package/dist/apps/shared/components/calendar/EventEditor.js +5 -10
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -2
- package/dist/apps/shared/components/calendar/EventFormParts.js +5 -16
- package/dist/apps/shared/components/calendar/GuestInput.d.ts +45 -0
- package/dist/apps/shared/components/calendar/GuestInput.js +30 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +11 -22
- package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -2
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +21 -8
- package/dist/apps/shared/components/calendar/eventFormat.js +50 -23
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +5 -4
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +1 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -2
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +7 -5
- package/dist/apps/shared/components/layout/AppShell.d.ts +8 -11
- package/dist/apps/shared/components/layout/AppShell.js +24 -33
- package/dist/apps/shared/components/layout/FloatingActionButton.d.ts +13 -0
- package/dist/apps/shared/components/layout/FloatingActionButton.js +9 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +4 -1
- package/dist/apps/shared/components/layout/UserMenu.js +6 -5
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +127 -22
- package/dist/apps/shared/components/mail/EmptyFolderBar.d.ts +18 -0
- package/dist/apps/shared/components/mail/EmptyFolderBar.js +10 -0
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +14 -4
- package/dist/apps/shared/components/mail/MailSelectionBar.js +11 -13
- package/dist/apps/shared/components/mail/MenuButton.d.ts +9 -2
- package/dist/apps/shared/components/mail/MenuButton.js +15 -11
- package/dist/apps/shared/components/mail/MessageDetailPane.d.ts +5 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +147 -7
- package/dist/apps/shared/components/mail/PermanentDeleteDialog.d.ts +19 -0
- package/dist/apps/shared/components/mail/PermanentDeleteDialog.js +11 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -4
- package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +21 -1
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +18 -10
- package/dist/apps/shared/components/mail/layout/FolderBadgeChip.d.ts +6 -0
- package/dist/apps/shared/components/mail/layout/FolderBadgeChip.js +9 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +16 -7
- package/dist/apps/shared/components/mail/layout/MailShell.js +78 -65
- package/dist/apps/shared/components/mail/layout/SidebarSection.d.ts +23 -0
- package/dist/apps/shared/components/mail/layout/SidebarSection.js +16 -0
- package/dist/apps/shared/components/mail/reading/MessageMoreMenu.d.ts +56 -0
- package/dist/apps/shared/components/mail/reading/MessageMoreMenu.js +153 -0
- package/dist/apps/shared/components/mail/reading/MessageSourceDialog.d.ts +27 -0
- package/dist/apps/shared/components/mail/reading/MessageSourceDialog.js +27 -0
- package/dist/apps/shared/components/mail/reading/PendingMessageCard.d.ts +14 -0
- package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +16 -0
- package/dist/apps/shared/components/mail/reading/messageExport.d.ts +19 -0
- package/dist/apps/shared/components/mail/reading/messageExport.js +51 -0
- package/dist/apps/shared/components/mail/reading/printMessage.d.ts +30 -0
- package/dist/apps/shared/components/mail/reading/printMessage.js +70 -0
- package/dist/apps/shared/components/mail/reading/useMessageActions.d.ts +57 -0
- package/dist/apps/shared/components/mail/reading/useMessageActions.js +366 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +10 -7
- package/dist/apps/shared/components/tasks/TasksSidebar.js +1 -1
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +7 -5
- package/dist/apps/shared/keyboard/GlobalShortcuts.js +1 -1
- package/dist/apps/shared/mail/folderCounts.d.ts +10 -0
- package/dist/apps/shared/mail/folderCounts.js +12 -0
- package/dist/apps/shared/mail/folderOfType.d.ts +13 -0
- package/dist/apps/shared/mail/folderOfType.js +40 -0
- package/dist/apps/shared/mail/outbox/outgoingReplies.d.ts +80 -0
- package/dist/apps/shared/mail/outbox/outgoingReplies.js +131 -0
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +5 -1
- package/dist/apps/shared/mail/outbox/sendJob.js +14 -1
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +13 -4
- package/dist/apps/shared/mail/permanentDelete.d.ts +62 -0
- package/dist/apps/shared/mail/permanentDelete.js +127 -0
- package/dist/apps/shared/mail/primaryMailbox.d.ts +29 -0
- package/dist/apps/shared/mail/primaryMailbox.js +61 -0
- package/dist/apps/shared/mail/reportNotices.d.ts +21 -0
- package/dist/apps/shared/mail/reportNotices.js +47 -0
- package/dist/apps/shared/mail/senderBlocking.d.ts +26 -0
- package/dist/apps/shared/mail/senderBlocking.js +92 -0
- package/dist/apps/shared/mail/useCollapsedSections.d.ts +32 -0
- package/dist/apps/shared/mail/useCollapsedSections.js +110 -0
- package/dist/apps/shared/mail/useMailConnection.js +3 -1
- package/dist/apps/shared/mail/useMailboxUpdateAccess.d.ts +11 -0
- package/dist/apps/shared/mail/useMailboxUpdateAccess.js +52 -0
- package/dist/apps/shared/mail/usePermanentDelete.d.ts +35 -0
- package/dist/apps/shared/mail/usePermanentDelete.js +96 -0
- package/dist/apps/shared/navigation/frameContext.d.ts +1 -1
- package/dist/apps/shared/navigation/index.d.ts +16 -0
- package/dist/apps/shared/navigation/index.js +34 -0
- package/dist/apps/shared/navigation/pageTitle.d.ts +11 -0
- package/dist/apps/shared/navigation/pageTitle.js +12 -0
- package/dist/apps/shared/search/crossMailboxSearch.d.ts +72 -0
- package/dist/apps/shared/search/crossMailboxSearch.js +136 -0
- package/dist/apps/shared/styles/app.css +396 -386
- package/dist/apps/www/_shell.d.ts +26 -0
- package/dist/apps/www/_shell.js +31 -0
- package/dist/apps/www/calendar/index.d.ts +7 -2
- package/dist/apps/www/calendar/index.js +7 -4
- package/dist/apps/www/contacts/[uid].d.ts +14 -3
- package/dist/apps/www/contacts/[uid].js +5 -3
- package/dist/apps/www/contacts/index.d.ts +7 -2
- package/dist/apps/www/contacts/index.js +8 -4
- package/dist/apps/www/index.d.ts +7 -2
- package/dist/apps/www/index.js +542 -207
- package/dist/apps/www/messages/[uid].d.ts +14 -3
- package/dist/apps/www/messages/[uid].js +7 -5
- package/dist/apps/www/settings/appearance/index.d.ts +8 -2
- package/dist/apps/www/settings/appearance/index.js +4 -2
- package/dist/apps/www/settings/auto-reply/index.d.ts +7 -2
- package/dist/apps/www/settings/auto-reply/index.js +4 -2
- package/dist/apps/www/settings/blocked-senders/index.d.ts +9 -0
- package/dist/apps/www/settings/blocked-senders/index.js +134 -0
- package/dist/apps/www/settings/encryption/index.d.ts +7 -2
- package/dist/apps/www/settings/encryption/index.js +4 -2
- package/dist/apps/www/settings/filters/[uid].d.ts +7 -2
- package/dist/apps/www/settings/filters/[uid].js +4 -2
- package/dist/apps/www/settings/filters/index.d.ts +7 -2
- package/dist/apps/www/settings/filters/index.js +4 -2
- package/dist/apps/www/settings/filters/new/index.d.ts +7 -2
- package/dist/apps/www/settings/filters/new/index.js +28 -5
- package/dist/apps/www/settings/labels/index.d.ts +7 -2
- package/dist/apps/www/settings/labels/index.js +4 -2
- package/dist/apps/www/settings/privacy/index.d.ts +7 -2
- package/dist/apps/www/settings/privacy/index.js +4 -2
- package/dist/apps/www/settings/profile/index.d.ts +7 -2
- package/dist/apps/www/settings/profile/index.js +4 -2
- package/dist/apps/www/settings/read-receipts/index.d.ts +7 -2
- package/dist/apps/www/settings/read-receipts/index.js +4 -2
- package/dist/apps/www/settings/sharing/index.d.ts +7 -2
- package/dist/apps/www/settings/sharing/index.js +4 -2
- package/dist/apps/www/settings/signatures/[uid].d.ts +7 -2
- package/dist/apps/www/settings/signatures/[uid].js +4 -2
- package/dist/apps/www/settings/signatures/index.d.ts +7 -2
- package/dist/apps/www/settings/signatures/index.js +4 -2
- package/dist/apps/www/settings/signatures/new/index.d.ts +7 -2
- package/dist/apps/www/settings/signatures/new/index.js +5 -3
- package/dist/apps/www/tasks/index.d.ts +7 -2
- package/dist/apps/www/tasks/index.js +5 -3
- package/package.json +4 -2
- package/apps/shared/navigation/AppRouter.tsx +0 -300
- package/apps/shared/navigation/idle.ts +0 -45
- package/apps/shared/navigation/routerContext.tsx +0 -83
- package/apps/shared/navigation/routes.ts +0 -72
- package/apps/www/_routedPage.tsx +0 -24
- package/apps/www/_routes.ts +0 -37
- package/dist/apps/shared/navigation/AppRouter.d.ts +0 -52
- package/dist/apps/shared/navigation/AppRouter.js +0 -242
- package/dist/apps/shared/navigation/idle.d.ts +0 -14
- package/dist/apps/shared/navigation/idle.js +0 -43
- package/dist/apps/shared/navigation/routerContext.d.ts +0 -37
- package/dist/apps/shared/navigation/routerContext.js +0 -56
- package/dist/apps/shared/navigation/routes.d.ts +0 -32
- package/dist/apps/shared/navigation/routes.js +0 -37
- package/dist/apps/www/_routedPage.d.ts +0 -12
- package/dist/apps/www/_routedPage.js +0 -19
- package/dist/apps/www/_routes.d.ts +0 -11
- package/dist/apps/www/_routes.js +0 -31
|
@@ -9,6 +9,7 @@ import { loadOriginalMessage } from "../../components/mail/compose/quotedBody.js
|
|
|
9
9
|
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
10
10
|
import { dismiss, notify } from "../../notifications/store.js";
|
|
11
11
|
import { beginPendingSend, finishPendingSend, setPendingStage } from "./pendingSends.js";
|
|
12
|
+
import { failOutgoing, forgetOutgoing, markOutgoingSending, markOutgoingSent } from "./outgoingReplies.js";
|
|
12
13
|
import { openComposeFromOutside } from "./composeBridge.js";
|
|
13
14
|
import { describeMessage, forgetRetainedRequest, notifySent, openDraftFromRequest, retainedRequest } from "./sendJob.js";
|
|
14
15
|
|
|
@@ -45,6 +46,8 @@ function escapeHtml(text: string): string {
|
|
|
45
46
|
async function openFailedDraft(event: SendEvent): Promise<void> {
|
|
46
47
|
try {
|
|
47
48
|
const moved = await moveBackToDrafts(event.uid, event.mailboxUid ?? "");
|
|
49
|
+
// Back in Drafts it is no longer a message on its way in its conversation.
|
|
50
|
+
forgetOutgoing(event.uid);
|
|
48
51
|
const request = retainedRequest(event.uid);
|
|
49
52
|
if (request) {
|
|
50
53
|
openDraftFromRequest({ ...request, draft: moved });
|
|
@@ -90,6 +93,7 @@ async function retryFailed(event: SendEvent): Promise<void> {
|
|
|
90
93
|
return;
|
|
91
94
|
}
|
|
92
95
|
dismiss(failedId(event.uid));
|
|
96
|
+
markOutgoingSending(event.uid);
|
|
93
97
|
try {
|
|
94
98
|
setPendingStage(event.uid, "queuing");
|
|
95
99
|
const result = await queueMessageSend(event.uid);
|
|
@@ -108,6 +112,8 @@ export function handleSendEvent(event: SendEvent): void {
|
|
|
108
112
|
if (event.action === "send-succeeded") {
|
|
109
113
|
dismiss(retryId(event.uid));
|
|
110
114
|
dismiss(failedId(event.uid));
|
|
115
|
+
// The conversation on screen swaps the message it drew for the Sent Items copy (a message this tab did not send has nothing drawn).
|
|
116
|
+
markOutgoingSent(event.uid);
|
|
111
117
|
// The server reports every message it relays, scheduled ones from long ago included: only a message this tab queued in the background is
|
|
112
118
|
// worth a confirmation (several are one pop-up counting up). Anything else - a send-later message going out, another tab's send - is quiet.
|
|
113
119
|
if (retainedRequest(event.uid)) {
|
|
@@ -132,15 +138,18 @@ export function handleSendEvent(event: SendEvent): void {
|
|
|
132
138
|
return;
|
|
133
139
|
}
|
|
134
140
|
dismiss(retryId(event.uid));
|
|
141
|
+
const actions = [
|
|
142
|
+
{ label: "Retry", onClick: () => void retryFailed(event) },
|
|
143
|
+
{ label: "Open draft", onClick: () => void openFailedDraft(event) },
|
|
144
|
+
];
|
|
135
145
|
notify({
|
|
136
146
|
id: failedId(event.uid),
|
|
137
147
|
kind: "error",
|
|
138
148
|
title: "This message wasn't sent",
|
|
139
149
|
message: `${what}: ${failure.message}`,
|
|
140
150
|
details: failure.lines,
|
|
141
|
-
actions
|
|
142
|
-
{ label: "Retry", onClick: () => void retryFailed(event) },
|
|
143
|
-
{ label: "Open draft", onClick: () => void openFailedDraft(event) },
|
|
144
|
-
],
|
|
151
|
+
actions,
|
|
145
152
|
});
|
|
153
|
+
// The conversation on screen shows the same failure under the message, with the same ways out.
|
|
154
|
+
failOutgoing(event.uid, failure.message, actions);
|
|
146
155
|
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { Message, emptyFolder, listMessages, purgeMessage } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import { displaySubject } from "../components/mail/reading/EncryptedPreview.js";
|
|
8
|
+
import { notifySessionExpired } from "../notifications/apiErrors.js";
|
|
9
|
+
import { notify } from "../notifications/store.js";
|
|
10
|
+
import { LIST_PAGE_SIZE, MAX_LIST_PAGES, listAllPages } from "./listAllPages.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Permanent delete: what "Delete" does to a message that is already in Deleted Items (and what "Empty folder" does to Deleted Items and
|
|
14
|
+
* Junk Email). Unlike a move into Deleted Items it cannot be undone, so it is never reached without a confirmation (see
|
|
15
|
+
* `usePermanentDelete()`, which asks it, runs what is in here and tells the folder badges and the reader).
|
|
16
|
+
*
|
|
17
|
+
* Server side it is `DELETE /mail/messages/:uid?purge=true` (a hard delete - the row and its search document are gone, not a
|
|
18
|
+
* recoverable soft delete) per message, and `DELETE /mail/messages?folderUid=` (`truncate`) for a whole folder. Both refuse a message under
|
|
19
|
+
* an active legal hold with a `409`; the per-message form refuses one message at a time, so a selection is deleted as far as it can be and
|
|
20
|
+
* each refusal is reported (`PurgeFailure`), rather than the first stopping the rest.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** How many purge requests are on the wire at once: a few at a time is quick, and the server publishes each folder's counts as they land. */
|
|
24
|
+
export const PURGE_CONCURRENCY = 4;
|
|
25
|
+
|
|
26
|
+
/** The folder types "Empty folder" is offered for: Outlook's Deleted Items and Junk Email. */
|
|
27
|
+
export const EMPTIABLE_FOLDER_TYPES: readonly string[] = ["deleted_items", "junk"];
|
|
28
|
+
|
|
29
|
+
/** A message the server refused to delete, and what it said. */
|
|
30
|
+
export interface PurgeFailure {
|
|
31
|
+
message: Message;
|
|
32
|
+
/** The server's own words (a legal hold names itself), or a stand-in when it did not answer. */
|
|
33
|
+
reason: string;
|
|
34
|
+
/** The response's status - `undefined` when there was no response. */
|
|
35
|
+
status?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What deleting several messages came to: those that are gone, and those that are not (with why). Together they are all that was asked for. */
|
|
39
|
+
export interface PurgeOutcome {
|
|
40
|
+
deleted: Message[];
|
|
41
|
+
failed: PurgeFailure[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What emptying a folder came to. */
|
|
45
|
+
export interface EmptyFolderOutcome extends PurgeOutcome {
|
|
46
|
+
/** Nothing is left in the folder as far as this knows: the whole-folder request went through, or every message listed was deleted. */
|
|
47
|
+
emptied: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function describeFailure(err: unknown): { reason: string; status?: number } {
|
|
51
|
+
if (err instanceof ApiRequestError) {
|
|
52
|
+
return { reason: err.message || `The server answered ${err.status}.`, status: err.status };
|
|
53
|
+
}
|
|
54
|
+
return { reason: "The server couldn't be reached." };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `1 message`, `3 messages`. */
|
|
58
|
+
export function messageCount(count: number): string {
|
|
59
|
+
return `${count} message${count === 1 ? "" : "s"}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Permanently deletes `messages`, each in its own request, a few at a time. Never rejects: a refusal (a legal hold, a share without the right
|
|
64
|
+
* to delete, a message that is already gone) is recorded against its message and the rest carry on. `deleted` and `failed` keep the order of
|
|
65
|
+
* `messages`.
|
|
66
|
+
*/
|
|
67
|
+
export async function purgeMessages(messages: Message[]): Promise<PurgeOutcome> {
|
|
68
|
+
const failures: (PurgeFailure | null)[] = new Array(messages.length).fill(null);
|
|
69
|
+
let next = 0;
|
|
70
|
+
async function worker(): Promise<void> {
|
|
71
|
+
while (next < messages.length) {
|
|
72
|
+
const index = next++;
|
|
73
|
+
try {
|
|
74
|
+
await purgeMessage(messages[index].uid);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
failures[index] = { message: messages[index], ...describeFailure(err) };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
await Promise.all(Array.from({ length: Math.min(PURGE_CONCURRENCY, messages.length) }, worker));
|
|
81
|
+
return {
|
|
82
|
+
deleted: messages.filter((_, index) => failures[index] === null),
|
|
83
|
+
failed: failures.filter((failure): failure is PurgeFailure => failure !== null),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Permanently deletes everything in `folderUid`: one request for the whole folder, which is all or nothing. The server refuses it as a whole
|
|
89
|
+
* when a message in it is under a legal hold (`409`) or when the caller may delete messages but not empty a folder (`403`, a delegate) - and
|
|
90
|
+
* then this lists the folder and deletes message by message instead, so what may go does and each refusal is reported. Any other failure
|
|
91
|
+
* (the server is down) rejects, and the caller says so.
|
|
92
|
+
*/
|
|
93
|
+
export async function purgeFolder(
|
|
94
|
+
folderUid: string,
|
|
95
|
+
paging: { pageSize: number; maxPages: number } = { pageSize: LIST_PAGE_SIZE, maxPages: MAX_LIST_PAGES },
|
|
96
|
+
): Promise<EmptyFolderOutcome> {
|
|
97
|
+
try {
|
|
98
|
+
await emptyFolder(folderUid);
|
|
99
|
+
return { emptied: true, deleted: [], failed: [] };
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (!(err instanceof ApiRequestError) || (err.status !== 403 && err.status !== 409)) {
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const listed = await listAllPages((page) => listMessages(folderUid, { limit: paging.pageSize, page }), paging.pageSize, paging.maxPages);
|
|
106
|
+
const outcome = await purgeMessages(listed.items);
|
|
107
|
+
return { ...outcome, emptied: outcome.failed.length === 0 && !listed.truncated };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The line for one refusal, in the notification's details. */
|
|
111
|
+
function failureLine(failure: PurgeFailure): string {
|
|
112
|
+
return `${displaySubject(failure.message.subject) || "(no subject)"}: ${failure.reason}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Tells the reader what a delete of several messages came to: "3 messages permanently deleted" (no Undo - there is none), or "2 deleted, 1
|
|
117
|
+
* could not be deleted" with the server's reasons.
|
|
118
|
+
*/
|
|
119
|
+
export function notifyPurged(outcome: PurgeOutcome): void {
|
|
120
|
+
const { deleted, failed } = outcome;
|
|
121
|
+
if (failed.length === 0) {
|
|
122
|
+
notify({ kind: "success", title: `${messageCount(deleted.length)} permanently deleted` });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (failed.some((failure) => failure.status === 401)) {
|
|
126
|
+
notifySessionExpired();
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const reasons = [...new Set(failed.map((failure) => failure.reason))];
|
|
130
|
+
notify({
|
|
131
|
+
kind: deleted.length > 0 ? "warning" : "error",
|
|
132
|
+
title:
|
|
133
|
+
deleted.length > 0
|
|
134
|
+
? `${deleted.length} deleted, ${failed.length} could not be deleted`
|
|
135
|
+
: `Couldn't permanently delete ${failed.length === 1 ? "that message" : "those messages"}`,
|
|
136
|
+
message: reasons.slice(0, 3).join(" ") + (reasons.length > 3 ? ` (and ${reasons.length - 3} more reasons)` : ""),
|
|
137
|
+
details: failed.map(failureLine),
|
|
138
|
+
sticky: true,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Tells the reader what emptying `folderName` came to. `count` is how many messages it held, when that was known: the whole-folder request
|
|
144
|
+
* does not say. A folder that had to be deleted message by message reports as `notifyPurged()` does, plus a word when it was too big to be
|
|
145
|
+
* done in one go.
|
|
146
|
+
*/
|
|
147
|
+
export function notifyFolderEmptied(folderName: string, outcome: EmptyFolderOutcome, count?: number): void {
|
|
148
|
+
if (outcome.deleted.length === 0 && outcome.failed.length === 0 && outcome.emptied) {
|
|
149
|
+
notify({
|
|
150
|
+
kind: "success",
|
|
151
|
+
title: count === undefined ? `${folderName} emptied` : `${messageCount(count)} permanently deleted`,
|
|
152
|
+
message: count === undefined ? "Everything in it was permanently deleted." : `${folderName} is now empty.`,
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
notifyPurged(outcome);
|
|
157
|
+
if (!outcome.emptied && outcome.failed.length === 0) {
|
|
158
|
+
notify({ kind: "info", title: `${folderName} is not empty yet`, message: "It holds more than could be deleted in one go. Empty it again to carry on." });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Which of the caller's mailboxes is "theirs", and the order every list of them is shown in - the one definition the mail, calendar,
|
|
7
|
+
* contacts, tasks and settings shells, the compose window's From picker and the account menu all use, so a shared mailbox that happens
|
|
8
|
+
* to sort first (or that the server lists first) is never the one an app opens on or a picker defaults to.
|
|
9
|
+
*
|
|
10
|
+
* An **own** mailbox is one whose `ownerUserUid` is the signed-in user.
|
|
11
|
+
*
|
|
12
|
+
* A **shared** mailbox is any other one the caller merely has access to: an ownerless (shared/organization) mailbox, or a delegate grant on
|
|
13
|
+
* somebody else's (`isSharedWithMe()`).
|
|
14
|
+
*
|
|
15
|
+
* The **primary** mailbox is the caller's own mailbox - the earliest created, when they own several - else the first mailbox that isn't
|
|
16
|
+
* shared, else the first one there is.
|
|
17
|
+
*
|
|
18
|
+
* `orderMailboxes()` puts the primary mailbox first, then the caller's other mailboxes, then the shared ones, each group alphabetical.
|
|
19
|
+
*/
|
|
20
|
+
import { isSharedWithMe, type Mailbox } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
21
|
+
|
|
22
|
+
/** Whether `mailbox` is the signed-in user's own. */
|
|
23
|
+
export function isOwnMailbox(mailbox: Mailbox, userUid: string | undefined): boolean {
|
|
24
|
+
return !!userUid && mailbox.ownerUserUid === userUid;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether `mailbox` is one the caller only has access to (an ownerless one, or somebody else's shared with them). */
|
|
28
|
+
export function isSharedMailbox(mailbox: Mailbox, userUid: string | undefined): boolean {
|
|
29
|
+
if (isOwnMailbox(mailbox, userUid)) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
// The server says how the caller reaches a mailbox (`accessRole`); an older one doesn't, and then a mailbox that isn't the known
|
|
33
|
+
// caller's own is somebody else's.
|
|
34
|
+
return mailbox.accessRole ? isSharedWithMe(mailbox) : !!userUid || isSharedWithMe(mailbox);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createdAt(mailbox: Mailbox): number {
|
|
38
|
+
const time = Date.parse(mailbox.dateCreated);
|
|
39
|
+
return Number.isNaN(time) ? Infinity : time;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The caller's own mailboxes, earliest created first (the given order among ones created together). */
|
|
43
|
+
export function ownMailboxes(mailboxes: readonly Mailbox[], userUid: string | undefined): Mailbox[] {
|
|
44
|
+
return mailboxes.filter((mailbox) => isOwnMailbox(mailbox, userUid)).sort((a, b) => createdAt(a) - createdAt(b));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The mailbox to open and to default pickers to: see the rules at the top of this file. `undefined` only for no mailboxes at all. */
|
|
48
|
+
export function primaryMailbox(mailboxes: readonly Mailbox[], userUid: string | undefined): Mailbox | undefined {
|
|
49
|
+
return ownMailboxes(mailboxes, userUid)[0] ?? mailboxes.find((mailbox) => !isSharedMailbox(mailbox, userUid)) ?? mailboxes[0];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `primaryMailbox()`'s uid. */
|
|
53
|
+
export function primaryMailboxUid(mailboxes: readonly Mailbox[], userUid: string | undefined): string | undefined {
|
|
54
|
+
return primaryMailbox(mailboxes, userUid)?.uid;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `mailboxes` in display order: the primary mailbox, then the caller's other mailboxes, then the shared ones - each group by name. Never
|
|
58
|
+
* changes the list it is given. */
|
|
59
|
+
export function orderMailboxes(mailboxes: readonly Mailbox[], userUid: string | undefined): Mailbox[] {
|
|
60
|
+
const primary = primaryMailbox(mailboxes, userUid);
|
|
61
|
+
const rank = (mailbox: Mailbox) => (mailbox === primary ? 0 : isSharedMailbox(mailbox, userUid) ? 2 : 1);
|
|
62
|
+
// Tolerates a mailbox that says nothing of its name or address (an older server's, or a test fixture's).
|
|
63
|
+
const name = (mailbox: Mailbox) => mailbox.displayName ?? "";
|
|
64
|
+
const address = (mailbox: Mailbox) => mailbox.primarySmtpAddress ?? "";
|
|
65
|
+
return [...mailboxes].sort(
|
|
66
|
+
(a, b) =>
|
|
67
|
+
rank(a) - rank(b) ||
|
|
68
|
+
name(a).localeCompare(name(b), undefined, { sensitivity: "base" }) ||
|
|
69
|
+
address(a).localeCompare(address(b)),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import type { MessageReportKind, MessageReportResult } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
6
|
+
|
|
7
|
+
/** Where a notification's "Manage blocked senders" goes: the Blocked and safe senders page of the mailbox. */
|
|
8
|
+
export function senderListsHref(mailboxUid: string): string {
|
|
9
|
+
return `/settings/blocked-senders?mailboxUid=${encodeURIComponent(mailboxUid)}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** What a pop-up says about reporting `kind`: the title, and the folder the message goes to. */
|
|
13
|
+
const KINDS: Record<MessageReportKind, { title: string; noun: string; folder: string }> = {
|
|
14
|
+
junk: { title: "Reported as junk", noun: "junk", folder: "Junk Email" },
|
|
15
|
+
phishing: { title: "Reported as phishing", noun: "phishing", folder: "Junk Email" },
|
|
16
|
+
not_junk: { title: "Marked as not junk", noun: "not junk", folder: "the Inbox" },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** The noun of `kind` for a failure's title ("Couldn't report this message as phishing"). */
|
|
20
|
+
export function reportNoun(kind: MessageReportKind): string {
|
|
21
|
+
return KINDS[kind].noun;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The title of a pop-up for a report of `kind`. */
|
|
25
|
+
export function reportTitle(kind: MessageReportKind): string {
|
|
26
|
+
return KINDS[kind].title;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The name of the folder a report of `kind` files the message in. */
|
|
30
|
+
export function reportFolderName(kind: MessageReportKind): string {
|
|
31
|
+
return KINDS[kind].folder;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What a pop-up for a report says. `hint` is the small muted line: only for the two things a reader may want to know about (the filter could not be
|
|
35
|
+
* taught, or the message is encrypted so it was not), never for the ones that are simply how the server is set up. */
|
|
36
|
+
export interface ReportNotice {
|
|
37
|
+
title: string;
|
|
38
|
+
message: string;
|
|
39
|
+
hint?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The words for what the server did with a report (`reportMessage()`'s answer): where the message went, whether the spam filter learned from it, and - for
|
|
44
|
+
* a report of phishing - that the server recorded it. `subject` names the message. `alwaysTrust` says the reader asked for the sender to be trusted.
|
|
45
|
+
*/
|
|
46
|
+
export function reportNotice(result: MessageReportResult, subject: string, alwaysTrust: boolean): ReportNotice {
|
|
47
|
+
const kind = KINDS[result.kind];
|
|
48
|
+
const where = result.moved ? `was moved to ${kind.folder}` : `is already in ${kind.folder}`;
|
|
49
|
+
const learned = result.learned ? " and used to train the spam filter" : "";
|
|
50
|
+
const parts = [`“${subject}” ${where}${learned}.`];
|
|
51
|
+
if (result.kind === "phishing") {
|
|
52
|
+
parts.push("The report was recorded in the audit log.");
|
|
53
|
+
}
|
|
54
|
+
if (result.safeSender) {
|
|
55
|
+
parts.push(`Mail from ${result.safeSender} that passes authentication is no longer sent to Junk Email.`);
|
|
56
|
+
} else if (alwaysTrust) {
|
|
57
|
+
parts.push("The message names no address that could be trusted.");
|
|
58
|
+
}
|
|
59
|
+
const hint =
|
|
60
|
+
result.learnSkipped === "failed"
|
|
61
|
+
? "The spam filter could not be trained from this report."
|
|
62
|
+
: result.learnSkipped === "encrypted"
|
|
63
|
+
? "Not used to train the spam filter: the message is encrypted."
|
|
64
|
+
: undefined;
|
|
65
|
+
return { title: kind.title, message: parts.join(" "), hint };
|
|
66
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import {
|
|
6
|
+
MailFilterRule,
|
|
7
|
+
createMailFilterRule,
|
|
8
|
+
deleteMailFilterRule,
|
|
9
|
+
listMailFilterRules,
|
|
10
|
+
updateMailFilterRule,
|
|
11
|
+
} from "@rapidmx/react-shared/mail/mailFilterRulesApi.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* "Block sender" and "Never block sender", expressed as the mailbox's own mail filter rules - the server has no blocked-senders list.
|
|
15
|
+
* A rule's "From contains" is a case-insensitive substring match on the From header (restapi's `matchesConditions()`), which is the
|
|
16
|
+
* closest thing there is to "from equals": it also matches a longer address that ends in this one (`joann@x.com` for `ann@x.com`).
|
|
17
|
+
*
|
|
18
|
+
* - **Block** is `From contains <address>` -> move to the mailbox's Junk Email, stop processing more rules, ahead of every other rule
|
|
19
|
+
* (a sequence below all of them), so nothing else files the sender's mail first.
|
|
20
|
+
* - **Never block** is `From contains <address>` -> move to the Inbox, stop processing, likewise first. It keeps the sender's mail out of
|
|
21
|
+
* the reader's own blocking and filing rules. It cannot keep it out of Junk when the spam filter sends it there: mail the spam filter
|
|
22
|
+
* has judged junk skips every rule (restapi's `ScanQueueJob.deliverMessage()`).
|
|
23
|
+
*
|
|
24
|
+
* Rules are found by what they do, not by their name, so one the reader renamed or made by hand is still the rule for that sender.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Every rule listing asks for up to this many, which is the most the mail server reads when it filters a delivery. */
|
|
28
|
+
const RULE_LIMIT = 500;
|
|
29
|
+
|
|
30
|
+
export const BLOCK_RULE_PREFIX = "Block ";
|
|
31
|
+
export const NEVER_BLOCK_RULE_PREFIX = "Never block ";
|
|
32
|
+
|
|
33
|
+
/** `addresses` trimmed, lowercased and without repeats, keeping only what has an `@`. */
|
|
34
|
+
export function normalizeAddresses(addresses: (string | undefined)[]): string[] {
|
|
35
|
+
return [...new Set(addresses.map((address) => address?.trim().toLowerCase() ?? "").filter((address) => address.includes("@")))];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Whether `rule` is "From contains ..." (every address of `addresses` among them, and nothing else) and moves to `folderUid` and stops. */
|
|
39
|
+
function isSenderRule(rule: MailFilterRule, addresses: string[], folderUid: string): boolean {
|
|
40
|
+
const conditions = rule.conditions as Record<string, unknown>;
|
|
41
|
+
const only = Object.keys(conditions).every((key) => key === "fromContains" || conditions[key] === undefined);
|
|
42
|
+
const from = (rule.conditions.fromContains ?? []).map((entry) => entry.toLowerCase());
|
|
43
|
+
return (
|
|
44
|
+
only &&
|
|
45
|
+
addresses.every((address) => from.includes(address)) &&
|
|
46
|
+
rule.stopProcessingRules &&
|
|
47
|
+
rule.actions.length === 1 &&
|
|
48
|
+
rule.actions[0].type === "move_to_folder" &&
|
|
49
|
+
rule.actions[0].folderUid === folderUid
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What blocking a sender did: `created` the rule, found it `existing` (and on), or turned an existing one back on. */
|
|
54
|
+
export interface BlockResult {
|
|
55
|
+
outcome: "created" | "existing" | "enabled";
|
|
56
|
+
rule: MailFilterRule;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** What "Never block" did: the block rules it took away, and the rule that keeps the sender in the Inbox (`created` now or found). */
|
|
60
|
+
export interface NeverBlockResult {
|
|
61
|
+
removed: MailFilterRule[];
|
|
62
|
+
outcome: "created" | "existing" | "enabled";
|
|
63
|
+
rule: MailFilterRule;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A sequence that puts a new rule ahead of every rule the mailbox has. */
|
|
67
|
+
function firstSequence(rules: MailFilterRule[]): number {
|
|
68
|
+
return Math.min(0, ...rules.map((rule) => rule.sequence)) - 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Finds the rule that does `folderUid` for `addresses`, makes one (named `${prefix}${addresses[0]}`) if there is none, and turns a found one on. */
|
|
72
|
+
async function ensureRule(
|
|
73
|
+
rules: MailFilterRule[],
|
|
74
|
+
mailboxUid: string,
|
|
75
|
+
addresses: string[],
|
|
76
|
+
folderUid: string,
|
|
77
|
+
prefix: string,
|
|
78
|
+
): Promise<{ outcome: "created" | "existing" | "enabled"; rule: MailFilterRule }> {
|
|
79
|
+
const found = rules.find((rule) => isSenderRule(rule, addresses, folderUid));
|
|
80
|
+
if (found) {
|
|
81
|
+
if (found.enabled) {
|
|
82
|
+
return { outcome: "existing", rule: found };
|
|
83
|
+
}
|
|
84
|
+
// The whole rule, as the filters page saves one: the update replaces the record.
|
|
85
|
+
const { uid, version, name, sequence, stopProcessingRules, conditions, actions } = found;
|
|
86
|
+
return { outcome: "enabled", rule: await updateMailFilterRule({ uid, version, name, enabled: true, sequence, stopProcessingRules, conditions, actions }) };
|
|
87
|
+
}
|
|
88
|
+
const rule = await createMailFilterRule({
|
|
89
|
+
mailboxUid,
|
|
90
|
+
name: `${prefix}${addresses[0]}`,
|
|
91
|
+
sequence: firstSequence(rules),
|
|
92
|
+
stopProcessingRules: true,
|
|
93
|
+
conditions: { fromContains: addresses },
|
|
94
|
+
actions: [{ type: "move_to_folder", folderUid }],
|
|
95
|
+
});
|
|
96
|
+
return { outcome: "created", rule };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Takes away every rule that is `folderUid` for `addresses`. */
|
|
100
|
+
async function removeRules(rules: MailFilterRule[], addresses: string[], folderUid: string): Promise<MailFilterRule[]> {
|
|
101
|
+
const doomed = rules.filter((rule) => isSenderRule(rule, addresses, folderUid));
|
|
102
|
+
for (const rule of doomed) {
|
|
103
|
+
await deleteMailFilterRule(rule.uid, rule.version);
|
|
104
|
+
}
|
|
105
|
+
return doomed;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Blocks a sender: mail from `addresses` (its envelope sender and From header address, when they differ - a mailing list or a bulk sender
|
|
110
|
+
* usually has both) goes to `junkFolderUid` from now on. Idempotent: the rule is made once, and a rule that was turned off is turned on.
|
|
111
|
+
* A "Never block" rule for the same sender is taken away, since the two would fight.
|
|
112
|
+
*/
|
|
113
|
+
export async function blockSender(mailboxUid: string, addresses: string[], junkFolderUid: string, inboxFolderUid: string): Promise<BlockResult> {
|
|
114
|
+
const rules = await listMailFilterRules(mailboxUid, { limit: RULE_LIMIT });
|
|
115
|
+
await removeRules(rules, addresses, inboxFolderUid);
|
|
116
|
+
return ensureRule(rules, mailboxUid, addresses, junkFolderUid, BLOCK_RULE_PREFIX);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Never blocks a sender: takes away the block rule for `addresses` and makes (once) the rule that keeps their mail in the Inbox. */
|
|
120
|
+
export async function neverBlockSender(
|
|
121
|
+
mailboxUid: string,
|
|
122
|
+
addresses: string[],
|
|
123
|
+
junkFolderUid: string,
|
|
124
|
+
inboxFolderUid: string,
|
|
125
|
+
): Promise<NeverBlockResult> {
|
|
126
|
+
const rules = await listMailFilterRules(mailboxUid, { limit: RULE_LIMIT });
|
|
127
|
+
const removed = await removeRules(rules, addresses, junkFolderUid);
|
|
128
|
+
const kept = await ensureRule(
|
|
129
|
+
rules.filter((rule) => !removed.includes(rule)),
|
|
130
|
+
mailboxUid,
|
|
131
|
+
addresses,
|
|
132
|
+
inboxFolderUid,
|
|
133
|
+
NEVER_BLOCK_RULE_PREFIX,
|
|
134
|
+
);
|
|
135
|
+
return { removed, ...kept };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Takes back a rule this module made (the notification's Undo). */
|
|
139
|
+
export function removeSenderRule(rule: MailFilterRule): Promise<void> {
|
|
140
|
+
return deleteMailFilterRule(rule.uid, rule.version);
|
|
141
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* Which of the Mail sidebar's sections - "All mailboxes" and one per mailbox - are open, and the reader's own choices about it.
|
|
7
|
+
*
|
|
8
|
+
* A section the reader has never touched follows the **default rule**: "All mailboxes" and the signed-in user's primary mailbox
|
|
9
|
+
* (`primaryMailboxUid()`) are open, every other mailbox is collapsed - so a mailbox that is newly shared with them arrives closed.
|
|
10
|
+
* Once they toggle a section the choice is remembered for that section, replacing the default, whatever the default later says.
|
|
11
|
+
*
|
|
12
|
+
* **Every** section can be collapsed, the one holding the open folder and the primary mailbox included, and a collapsed one stays collapsed
|
|
13
|
+
* across reloads even while its own folder is the selected one: the toggle always toggles and always remembers.
|
|
14
|
+
*
|
|
15
|
+
* The one exception is *navigation*: when the section holding the open folder (`activeId`) **changes** - the reader picks a folder in
|
|
16
|
+
* another section, or a search result or a link lands in one - that section is shown open, so the folder just highlighted is not hidden
|
|
17
|
+
* from them. That is an in-memory override of this page view, never written as the reader's choice: it does not apply to the first
|
|
18
|
+
* section the page resolves (a reload lands on whatever was chosen), and it ends when they navigate to yet another section. Toggling
|
|
19
|
+
* the section it opened forgets the override and remembers the reader's new choice (the opposite of what was showing).
|
|
20
|
+
*
|
|
21
|
+
* The choices are kept per signed-in user, per device, in `localStorage` (`rapidmx:mail-sidebar-collapsed:<userUid>`), the way
|
|
22
|
+
* `listPreferences.ts` keeps the list arrangement: `{ "<section id>": <collapsed> }`. Every read tolerates a missing, corrupt or
|
|
23
|
+
* blocked store (and keeps only the booleans of a partly-valid one), every write swallows its failure - a blocked store only means the
|
|
24
|
+
* choices do not survive a reload.
|
|
25
|
+
*/
|
|
26
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
27
|
+
|
|
28
|
+
const STORAGE_KEY_PREFIX = "rapidmx:mail-sidebar-collapsed:";
|
|
29
|
+
|
|
30
|
+
/** The id of the "All mailboxes" section; every mailbox's section is its uid. */
|
|
31
|
+
export const ALL_MAILBOXES_SECTION = "all";
|
|
32
|
+
|
|
33
|
+
/** What the reader chose per section: `true` collapsed, `false` open. A section that is not there follows the default rule. */
|
|
34
|
+
export type SectionChoices = Record<string, boolean>;
|
|
35
|
+
|
|
36
|
+
/** The `localStorage` key one user's choices are kept under. */
|
|
37
|
+
export function collapsedSectionsKey(userUid: string): string {
|
|
38
|
+
return `${STORAGE_KEY_PREFIX}${userUid}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Reads a user's stored choices - none for an unknown user, a missing entry, unparsable JSON, something that is not an object, or a store that throws. */
|
|
42
|
+
export function readSectionChoices(userUid: string | undefined): SectionChoices {
|
|
43
|
+
if (!userUid) {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
let stored: unknown;
|
|
47
|
+
try {
|
|
48
|
+
const raw = localStorage.getItem(collapsedSectionsKey(userUid));
|
|
49
|
+
stored = raw === null ? null : JSON.parse(raw);
|
|
50
|
+
} catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
if (!stored || typeof stored !== "object" || Array.isArray(stored)) {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
// Only what is a real choice: a value that is not a boolean falls back to the default for its own section, not for all of them.
|
|
57
|
+
return Object.fromEntries(Object.entries(stored).filter((entry): entry is [string, boolean] => typeof entry[1] === "boolean"));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Stores a user's choices. Best-effort - see this module's own doc comment. */
|
|
61
|
+
export function writeSectionChoices(userUid: string | undefined, choices: SectionChoices): void {
|
|
62
|
+
if (!userUid) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
localStorage.setItem(collapsedSectionsKey(userUid), JSON.stringify(choices));
|
|
67
|
+
} catch {
|
|
68
|
+
// Best-effort - see this module's own doc comment.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Whether a section is open by the reader's choice, or by the default rule where they have made none (a navigation's override not considered). */
|
|
73
|
+
export function sectionChosenOpen(id: string, choices: SectionChoices, primaryUid: string | undefined): boolean {
|
|
74
|
+
const collapsed = choices[id];
|
|
75
|
+
return collapsed === undefined ? id === ALL_MAILBOXES_SECTION || id === primaryUid : !collapsed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface SidebarSectionsOptions {
|
|
79
|
+
/** The signed-in user, whose choices these are; `undefined` keeps them for this page view only. */
|
|
80
|
+
userUid: string | undefined;
|
|
81
|
+
/** The user's primary mailbox, which - with "All mailboxes" - is open until they choose otherwise. */
|
|
82
|
+
primaryUid: string | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* The section holding the folder that is open: `ALL_MAILBOXES_SECTION` for an aggregate view, else that mailbox's uid; `undefined`
|
|
85
|
+
* while that is not known yet (the address not read, the mailboxes not loaded). Moving from one section to another opens the new one
|
|
86
|
+
* (see this module's doc comment); the first section that becomes known does not.
|
|
87
|
+
*/
|
|
88
|
+
activeId: string | undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SidebarSections {
|
|
92
|
+
/** Whether the section shows its folders. */
|
|
93
|
+
isExpanded: (id: string) => boolean;
|
|
94
|
+
/** Opens a collapsed section, collapses an open one, and remembers it as the reader's choice. */
|
|
95
|
+
toggle: (id: string) => void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** See this module's doc comment. */
|
|
99
|
+
export function useSidebarSections({ userUid, primaryUid, activeId }: SidebarSectionsOptions): SidebarSections {
|
|
100
|
+
const [loaded, setLoaded] = useState(() => ({ userUid, choices: readSectionChoices(userUid) }));
|
|
101
|
+
let current = loaded;
|
|
102
|
+
if (loaded.userUid !== userUid) {
|
|
103
|
+
// Another user signed in on this page: theirs are not the last one's choices.
|
|
104
|
+
current = { userUid, choices: readSectionChoices(userUid) };
|
|
105
|
+
setLoaded(current);
|
|
106
|
+
}
|
|
107
|
+
// The section the last navigation opened, if any: derived while rendering from the change of `activeId` (no effect, so the section is
|
|
108
|
+
// already open in the render that highlights its folder). A first `activeId` (from undefined) is not a navigation.
|
|
109
|
+
const [nav, setNav] = useState<{ activeId: string | undefined; opened: string | undefined }>({ activeId, opened: undefined });
|
|
110
|
+
let currentNav = nav;
|
|
111
|
+
if (nav.activeId !== activeId) {
|
|
112
|
+
currentNav = { activeId, opened: nav.activeId === undefined ? undefined : activeId };
|
|
113
|
+
setNav(currentNav);
|
|
114
|
+
}
|
|
115
|
+
// Kept in step with what was last rendered, and moved on by a toggle at once, so two toggles before a re-render both count.
|
|
116
|
+
const choicesRef = useRef(current.choices);
|
|
117
|
+
choicesRef.current = current.choices;
|
|
118
|
+
const openedRef = useRef(currentNav.opened);
|
|
119
|
+
openedRef.current = currentNav.opened;
|
|
120
|
+
|
|
121
|
+
const toggle = useCallback(
|
|
122
|
+
(id: string) => {
|
|
123
|
+
const expanded = openedRef.current === id || sectionChosenOpen(id, choicesRef.current, primaryUid);
|
|
124
|
+
const next = { ...choicesRef.current, [id]: expanded };
|
|
125
|
+
choicesRef.current = next;
|
|
126
|
+
setLoaded({ userUid, choices: next });
|
|
127
|
+
writeSectionChoices(userUid, next);
|
|
128
|
+
if (openedRef.current === id) {
|
|
129
|
+
// The reader's own choice replaces the override that had opened it.
|
|
130
|
+
openedRef.current = undefined;
|
|
131
|
+
setNav({ activeId, opened: undefined });
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
[activeId, primaryUid, userUid],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const choices = current.choices;
|
|
138
|
+
const opened = currentNav.opened;
|
|
139
|
+
return useMemo(
|
|
140
|
+
() => ({
|
|
141
|
+
isExpanded: (id: string) => id === opened || sectionChosenOpen(id, choices, primaryUid),
|
|
142
|
+
toggle,
|
|
143
|
+
}),
|
|
144
|
+
[opened, choices, primaryUid, toggle],
|
|
145
|
+
);
|
|
146
|
+
}
|