@remit/web-client 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/harness/build.mjs +84 -0
- package/harness/index.html +37 -0
- package/harness/vite-preset.ts +29 -0
- package/index.html +47 -0
- package/package.json +147 -0
- package/public/config.js +5 -0
- package/public/locales/de/common.json +1 -0
- package/public/locales/de/errors.json +1 -0
- package/public/locales/de/mail.json +13 -0
- package/public/locales/de/settings.json +1 -0
- package/public/locales/en/common.json +26 -0
- package/public/locales/en/errors.json +1 -0
- package/public/locales/en/mail.json +69 -0
- package/public/locales/en/settings.json +1 -0
- package/public/locales/fr/common.json +1 -0
- package/public/locales/fr/errors.json +1 -0
- package/public/locales/fr/mail.json +13 -0
- package/public/locales/fr/settings.json +1 -0
- package/public/locales/nl/common.json +1 -0
- package/public/locales/nl/errors.json +1 -0
- package/public/locales/nl/mail.json +13 -0
- package/public/locales/nl/settings.json +1 -0
- package/src/auth/AccountMenu.tsx +58 -0
- package/src/auth/AccountSession.tsx +19 -0
- package/src/auth/BetterAuthShell.tsx +198 -0
- package/src/auth/account-menu-mode.test.ts +64 -0
- package/src/auth/account-menu-mode.ts +28 -0
- package/src/auth/amplify-config.test.ts +158 -0
- package/src/auth/amplify-config.ts +60 -0
- package/src/auth/auth-error.test.ts +109 -0
- package/src/auth/auth-error.ts +107 -0
- package/src/auth/auth-interceptor.test.ts +86 -0
- package/src/auth/auth-interceptor.ts +23 -0
- package/src/auth/auth.css +334 -0
- package/src/auth/better-auth/BetterAuthAccount.tsx +25 -0
- package/src/auth/better-auth-config.ts +85 -0
- package/src/auth/better-auth-provider.tsx +20 -0
- package/src/auth/cognito/CognitoAccount.tsx +48 -0
- package/src/auth/cognito/CognitoShell.tsx +266 -0
- package/src/auth/cognito/cognito-token.test.ts +110 -0
- package/src/auth/cognito/cognito-token.ts +47 -0
- package/src/auth/cognito/cognito.css +21 -0
- package/src/auth/cognito-provider.tsx +22 -0
- package/src/auth/combined-provider.tsx +42 -0
- package/src/auth/composition.test.ts +136 -0
- package/src/auth/provider.tsx +63 -0
- package/src/auth/sign-out-visibility.test.ts +40 -0
- package/src/auth/sign-out-visibility.ts +10 -0
- package/src/auth/sign-up-disabled.test.ts +23 -0
- package/src/components/compose/AddressField.tsx +237 -0
- package/src/components/compose/ComposeBody.tsx +36 -0
- package/src/components/compose/ComposeForm.tsx +676 -0
- package/src/components/compose/ComposeProvider.tsx +133 -0
- package/src/components/compose/ComposeSmtpMissingBanner.tsx +49 -0
- package/src/components/compose/FromSelector.tsx +67 -0
- package/src/components/compose/FullCompose.tsx +82 -0
- package/src/components/compose/InlineCompose.tsx +29 -0
- package/src/components/compose/MobileComposeSheet.tsx +152 -0
- package/src/components/compose/PlateEditor.tsx +86 -0
- package/src/components/compose/PlateToolbar.tsx +110 -0
- package/src/components/compose/SubjectField.tsx +19 -0
- package/src/components/compose/draft-discard-banner.test.ts +76 -0
- package/src/components/compose/index.ts +6 -0
- package/src/components/compose/sanitize-quote-html.ts +23 -0
- package/src/components/layout/AppShellSkeleton.tsx +53 -0
- package/src/components/layout/ComposeFab.tsx +86 -0
- package/src/components/layout/Drawer.tsx +105 -0
- package/src/components/mail/AutoMovedIndicator.tsx +48 -0
- package/src/components/mail/BlockedImagesNotice.render.test.ts +65 -0
- package/src/components/mail/BlockedImagesNotice.tsx +41 -0
- package/src/components/mail/BriefPane.tsx +289 -0
- package/src/components/mail/ConversationView.tsx +462 -0
- package/src/components/mail/DailyBrief.tsx +451 -0
- package/src/components/mail/DraftsView.tsx +279 -0
- package/src/components/mail/FlaggedList.tsx +170 -0
- package/src/components/mail/FlaggedPane.tsx +217 -0
- package/src/components/mail/IntelligencePane.test.ts +123 -0
- package/src/components/mail/IntelligencePane.tsx +441 -0
- package/src/components/mail/MailListHeader.tsx +187 -0
- package/src/components/mail/MailNav.tsx +70 -0
- package/src/components/mail/MailSidebarAdapter.tsx +351 -0
- package/src/components/mail/MailViewChrome.tsx +95 -0
- package/src/components/mail/MailboxPane.tsx +1219 -0
- package/src/components/mail/MessageActionMenu.tsx +368 -0
- package/src/components/mail/MessageBody.tsx +207 -0
- package/src/components/mail/MessageBodyErrorBanner.render.test.ts +85 -0
- package/src/components/mail/MessageBodyErrorBanner.test.ts +86 -0
- package/src/components/mail/MessageBodyErrorBanner.tsx +90 -0
- package/src/components/mail/MessageCard.tsx +401 -0
- package/src/components/mail/MessageDetail.tsx +153 -0
- package/src/components/mail/MessageList.tsx +804 -0
- package/src/components/mail/MessageListItem.tsx +267 -0
- package/src/components/mail/MessageToolbar.render.test.ts +57 -0
- package/src/components/mail/MessageToolbar.tsx +166 -0
- package/src/components/mail/MobileMessageBar.tsx +142 -0
- package/src/components/mail/MoveToTrigger.tsx +250 -0
- package/src/components/mail/NavMenuButton.tsx +25 -0
- package/src/components/mail/OutboxPane.tsx +531 -0
- package/src/components/mail/PullToRefresh.tsx +24 -0
- package/src/components/mail/RawMessageView.tsx +84 -0
- package/src/components/mail/ReadingPaneEmpty.render.test.ts +42 -0
- package/src/components/mail/SelectionToolbar.tsx +139 -0
- package/src/components/mail/SpamRescue.render.test.ts +35 -0
- package/src/components/mail/SpamRescue.tsx +138 -0
- package/src/components/mail/SwipeableMessageRow.tsx +148 -0
- package/src/components/mail/message-body-error-banner-content.ts +87 -0
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +37 -0
- package/src/components/mail/organize/OrganizeDialog.tsx +83 -0
- package/src/components/mail/organize/OrganizePanel.render.test.ts +102 -0
- package/src/components/mail/organize/OrganizePanel.tsx +344 -0
- package/src/components/onboarding/OnboardingWizard.tsx +1547 -0
- package/src/components/settings/AccountFormPanel.test.ts +72 -0
- package/src/components/settings/AccountFormPanel.tsx +942 -0
- package/src/components/settings/DangerZone.tsx +30 -0
- package/src/components/settings/DeleteAccountDialog.render.test.ts +47 -0
- package/src/components/settings/DeleteAccountDialog.tsx +224 -0
- package/src/components/settings/FiltersList.render.test.ts +71 -0
- package/src/components/settings/FiltersList.tsx +93 -0
- package/src/components/settings/account-form-helpers.test.ts +172 -0
- package/src/components/settings/account-form-helpers.ts +105 -0
- package/src/components/ui/AppVersion.tsx +50 -0
- package/src/components/ui/BugReportButton.tsx +45 -0
- package/src/components/ui/ConfirmDialog.tsx +125 -0
- package/src/components/ui/DropdownMenu.tsx +105 -0
- package/src/components/ui/EmptyState.tsx +11 -0
- package/src/components/ui/ErrorBanner.tsx +88 -0
- package/src/components/ui/ErrorBannerProvider.tsx +77 -0
- package/src/components/ui/ErrorBannerStack.tsx +34 -0
- package/src/components/ui/ErrorState.tsx +80 -0
- package/src/components/ui/FatalErrorOverlay.render.test.ts +66 -0
- package/src/components/ui/FatalErrorOverlay.tsx +151 -0
- package/src/components/ui/KeyboardShortcutsModal.tsx +120 -0
- package/src/components/ui/SlidePanel.tsx +72 -0
- package/src/components/ui/error-banners.test.ts +207 -0
- package/src/components/ui/error-banners.ts +111 -0
- package/src/hooks/queries/keys.ts +38 -0
- package/src/hooks/useArchiveMailbox.ts +104 -0
- package/src/hooks/useAutoMovedBadge.ts +77 -0
- package/src/hooks/useCurrentMailboxName.ts +99 -0
- package/src/hooks/useDebouncedValue.ts +12 -0
- package/src/hooks/useDeleteMessages.test.ts +103 -0
- package/src/hooks/useDeleteMessages.ts +226 -0
- package/src/hooks/useFilters.ts +112 -0
- package/src/hooks/useIntelligenceData.test.ts +269 -0
- package/src/hooks/useIntelligenceData.ts +284 -0
- package/src/hooks/useIsDark.ts +27 -0
- package/src/hooks/useKeyboardNavigation.ts +192 -0
- package/src/hooks/useLayoutTier.test.ts +47 -0
- package/src/hooks/useLayoutTier.ts +44 -0
- package/src/hooks/useLongPress.test.ts +291 -0
- package/src/hooks/useLongPress.ts +87 -0
- package/src/hooks/useMailboxAccount.ts +62 -0
- package/src/hooks/useMailboxNameIndex.ts +32 -0
- package/src/hooks/useMarkAsRead.test.ts +165 -0
- package/src/hooks/useMarkAsRead.ts +319 -0
- package/src/hooks/useMediaQuery.ts +39 -0
- package/src/hooks/useMessageBodyContent.test.ts +370 -0
- package/src/hooks/useMessageBodyContent.ts +380 -0
- package/src/hooks/useMoveMessages.test.ts +63 -0
- package/src/hooks/useMoveMessages.ts +221 -0
- package/src/hooks/useOrganizeJob.ts +90 -0
- package/src/hooks/useOrganizePreview.ts +40 -0
- package/src/hooks/useRescueCandidates.test.ts +217 -0
- package/src/hooks/useRescueCandidates.ts +48 -0
- package/src/hooks/useSaveDraft.ts +118 -0
- package/src/hooks/useSelection.test.ts +75 -0
- package/src/hooks/useSelection.ts +211 -0
- package/src/hooks/useSemanticSearch.ts +94 -0
- package/src/hooks/useSignature.ts +69 -0
- package/src/hooks/useStaleAccountSync.test.ts +211 -0
- package/src/hooks/useStaleAccountSync.ts +246 -0
- package/src/hooks/useSwipeNavigation.test.ts +35 -0
- package/src/hooks/useSwipeNavigation.ts +99 -0
- package/src/hooks/useToggleStar.ts +187 -0
- package/src/hooks/useToggleTrusted.test.ts +102 -0
- package/src/hooks/useToggleTrusted.ts +161 -0
- package/src/hooks/useTriageKeyboard.ts +103 -0
- package/src/hooks/useTriggerSync.test.ts +24 -0
- package/src/hooks/useTriggerSync.ts +58 -0
- package/src/hooks/useUpdateAddressFlags.ts +125 -0
- package/src/hooks/useVisualViewport.test.ts +127 -0
- package/src/hooks/useVisualViewport.ts +51 -0
- package/src/index.css +19 -0
- package/src/lib/account-order.test.ts +84 -0
- package/src/lib/account-order.ts +13 -0
- package/src/lib/adjacent-message.test.ts +43 -0
- package/src/lib/adjacent-message.ts +20 -0
- package/src/lib/api.ts +82 -0
- package/src/lib/app-info.ts +15 -0
- package/src/lib/auto-moved.test.ts +140 -0
- package/src/lib/auto-moved.ts +68 -0
- package/src/lib/autodiscovery.test.ts +94 -0
- package/src/lib/autodiscovery.ts +354 -0
- package/src/lib/brief.test.ts +366 -0
- package/src/lib/brief.ts +180 -0
- package/src/lib/bug-report.test.ts +213 -0
- package/src/lib/bug-report.ts +176 -0
- package/src/lib/client.ts +36 -0
- package/src/lib/console-errors.test.ts +64 -0
- package/src/lib/console-errors.ts +92 -0
- package/src/lib/conversation-target.test.ts +68 -0
- package/src/lib/conversation-target.ts +59 -0
- package/src/lib/display-category.ts +20 -0
- package/src/lib/drafts.test.ts +241 -0
- package/src/lib/drafts.ts +121 -0
- package/src/lib/error-classifier.test.ts +133 -0
- package/src/lib/error-classifier.ts +96 -0
- package/src/lib/fatal-error.test.ts +97 -0
- package/src/lib/fatal-error.ts +165 -0
- package/src/lib/folder-roles.test.ts +113 -0
- package/src/lib/folder-roles.ts +112 -0
- package/src/lib/format.test.ts +115 -0
- package/src/lib/format.ts +198 -0
- package/src/lib/i18n.ts +36 -0
- package/src/lib/intelligence-pref.test.ts +57 -0
- package/src/lib/intelligence-pref.ts +25 -0
- package/src/lib/keymap-dispatch.test.ts +178 -0
- package/src/lib/keymap-dispatch.ts +187 -0
- package/src/lib/keymap.test.ts +49 -0
- package/src/lib/keymap.ts +178 -0
- package/src/lib/mail-context.ts +64 -0
- package/src/lib/mail-route.test.ts +80 -0
- package/src/lib/mail-route.ts +44 -0
- package/src/lib/message-body-source.test.ts +98 -0
- package/src/lib/message-body-source.ts +48 -0
- package/src/lib/move-targets.test.ts +195 -0
- package/src/lib/move-targets.ts +74 -0
- package/src/lib/onboarding-completion.test.ts +64 -0
- package/src/lib/onboarding-completion.ts +23 -0
- package/src/lib/organize/filter-status.test.ts +99 -0
- package/src/lib/organize/filter-status.ts +86 -0
- package/src/lib/organize/organize-copy.test.ts +93 -0
- package/src/lib/organize/organize-copy.ts +61 -0
- package/src/lib/organize/organize-model.test.ts +104 -0
- package/src/lib/organize/organize-model.ts +105 -0
- package/src/lib/organize/organize-poll.test.ts +37 -0
- package/src/lib/organize/organize-poll.ts +28 -0
- package/src/lib/outbox-status.test.ts +64 -0
- package/src/lib/outbox-status.ts +75 -0
- package/src/lib/plate-serializer.ts +164 -0
- package/src/lib/provider-presets.test.ts +96 -0
- package/src/lib/provider-presets.ts +81 -0
- package/src/lib/query-error-handler.test.ts +144 -0
- package/src/lib/query-error-handler.ts +34 -0
- package/src/lib/query-escalation.integration.test.ts +138 -0
- package/src/lib/recent-searches.test.ts +53 -0
- package/src/lib/recent-searches.ts +34 -0
- package/src/lib/rescue-candidates.test.ts +94 -0
- package/src/lib/rescue-candidates.ts +35 -0
- package/src/lib/rescue-telemetry.test.ts +75 -0
- package/src/lib/rescue-telemetry.ts +55 -0
- package/src/lib/rum-adapter.test.ts +299 -0
- package/src/lib/rum-adapter.ts +107 -0
- package/src/lib/saved-searches.test.ts +80 -0
- package/src/lib/saved-searches.ts +52 -0
- package/src/lib/search-pending.test.ts +61 -0
- package/src/lib/search-pending.ts +24 -0
- package/src/lib/search-query.test.ts +35 -0
- package/src/lib/search-query.ts +11 -0
- package/src/lib/search-result.test.ts +160 -0
- package/src/lib/search-result.ts +102 -0
- package/src/lib/search-schemas.ts +34 -0
- package/src/lib/search-token-index.test.ts +112 -0
- package/src/lib/search-token-index.ts +56 -0
- package/src/lib/search-tokens.test.ts +218 -0
- package/src/lib/search-tokens.ts +133 -0
- package/src/lib/telemetry-context.tsx +9 -0
- package/src/lib/telemetry.ts +17 -0
- package/src/lib/theme-preference.ts +46 -0
- package/src/lib/theme.ts +33 -0
- package/src/lib/utils.ts +4 -0
- package/src/main.tsx +10 -0
- package/src/routeTree.gen.ts +392 -0
- package/src/router.test.ts +81 -0
- package/src/router.tsx +31 -0
- package/src/routes/__root.tsx +69 -0
- package/src/routes/index.tsx +7 -0
- package/src/routes/mail/$mailboxId.tsx +27 -0
- package/src/routes/mail/flagged.tsx +37 -0
- package/src/routes/mail/index.tsx +41 -0
- package/src/routes/mail/outbox.tsx +13 -0
- package/src/routes/mail.tsx +413 -0
- package/src/routes/onboarding.tsx +35 -0
- package/src/routes/settings/-accounts.test.ts +65 -0
- package/src/routes/settings/accounts.tsx +542 -0
- package/src/routes/settings/advanced.tsx +55 -0
- package/src/routes/settings/appearance.tsx +140 -0
- package/src/routes/settings/filters.tsx +141 -0
- package/src/routes/settings/folders.tsx +216 -0
- package/src/routes/settings/index.tsx +5 -0
- package/src/routes/settings/senders.tsx +466 -0
- package/src/routes/settings/suggested-vips.tsx +10 -0
- package/src/routes/settings.tsx +66 -0
- package/src/runtime-config.ts +75 -0
- package/src/shell/index.tsx +98 -0
- package/src/types/index.ts +73 -0
- package/src/vite-env.d.ts +4 -0
- package/vite.base.ts +43 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { messageOperationsDescribeMessageQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { useAuthProvider } from "@/auth/provider";
|
|
5
|
+
import {
|
|
6
|
+
type BodyContentKind,
|
|
7
|
+
pickRenderablePart,
|
|
8
|
+
type RenderableBodyPart,
|
|
9
|
+
} from "@/lib/message-body-source";
|
|
10
|
+
import { useTelemetry } from "@/lib/telemetry-context";
|
|
11
|
+
import { messageKeys } from "./queries/keys";
|
|
12
|
+
|
|
13
|
+
export interface MessageBodyContent {
|
|
14
|
+
kind: BodyContentKind;
|
|
15
|
+
body: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Discriminated reason for a body-fetch failure. The SPA uses this to render
|
|
20
|
+
* a diagnostic banner instead of a generic "Failed to load (403 Forbidden)"
|
|
21
|
+
* string (issue #401 / postmortem #394).
|
|
22
|
+
*
|
|
23
|
+
* - `auth` — Lambda@Edge denied the request (missing/invalid/expired token,
|
|
24
|
+
* cross-tenant). The edge sets `x-remit-403-reason` on the deny
|
|
25
|
+
* response; the user needs to sign in again.
|
|
26
|
+
* - `body-missing` — CloudFront returned a 403/404 with no edge reason header,
|
|
27
|
+
* which means the request passed the JWT check and S3 had no
|
|
28
|
+
* object at that key. The S3 bucket policy (OAC) only grants
|
|
29
|
+
* `s3:GetObject`, so a missing key surfaces as 403 instead of 404
|
|
30
|
+
* — both shapes are normalised to `body-missing` here.
|
|
31
|
+
* - `content-type-mismatch` — server returned text/html for a plain-text part
|
|
32
|
+
* (defensive guard against a misconfigured edge or bucket-policy
|
|
33
|
+
* bypass smuggling HTML into the renderer).
|
|
34
|
+
* - `spa-shell-leak` — CloudFront 403/404 fallback rewrote the response into
|
|
35
|
+
* the SPA shell HTML. The infra fix (#310) prevents this; this
|
|
36
|
+
* guard catches a regression.
|
|
37
|
+
* - `not-ready` — the body is not synced yet. The content route answers 202 +
|
|
38
|
+
* `Retry-After` while the worker (re)fetches it from IMAP; the query
|
|
39
|
+
* retries on this reason and only surfaces a banner if it never
|
|
40
|
+
* lands.
|
|
41
|
+
* - `generic` — any other non-2xx, or a fetch-level failure (e.g. offline).
|
|
42
|
+
*/
|
|
43
|
+
export type BodyFetchReason =
|
|
44
|
+
| "auth"
|
|
45
|
+
| "body-missing"
|
|
46
|
+
| "content-type-mismatch"
|
|
47
|
+
| "spa-shell-leak"
|
|
48
|
+
| "not-ready"
|
|
49
|
+
| "generic";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Error thrown by `fetchBodyContent`. Carries a discriminated `reason` so the
|
|
53
|
+
* UI can render a specific banner (see MessageBody). Also exposes the HTTP
|
|
54
|
+
* `status` (when applicable) for log breadcrumbs / debugging.
|
|
55
|
+
*/
|
|
56
|
+
export class BodyFetchError extends Error {
|
|
57
|
+
readonly reason: BodyFetchReason;
|
|
58
|
+
readonly status?: number;
|
|
59
|
+
/** Seconds to wait before retrying — set from `Retry-After` on a 202. */
|
|
60
|
+
readonly retryAfterSeconds?: number;
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
reason: BodyFetchReason,
|
|
64
|
+
message: string,
|
|
65
|
+
status?: number,
|
|
66
|
+
retryAfterSeconds?: number,
|
|
67
|
+
) {
|
|
68
|
+
super(message);
|
|
69
|
+
this.name = "BodyFetchError";
|
|
70
|
+
this.reason = reason;
|
|
71
|
+
this.status = status;
|
|
72
|
+
this.retryAfterSeconds = retryAfterSeconds;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse a `Retry-After` header (delta-seconds form) into a bounded number of
|
|
78
|
+
* seconds. Falls back to 1s when the header is absent or unparseable, and caps
|
|
79
|
+
* the delay so a hostile/huge value can't wedge the query.
|
|
80
|
+
*/
|
|
81
|
+
export const parseRetryAfterSeconds = (headerValue: string | null): number => {
|
|
82
|
+
const parsed = Number.parseInt(headerValue ?? "", 10);
|
|
83
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return 1;
|
|
84
|
+
return Math.min(parsed, 30);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Max 202 retries before the body-fetch surfaces a `not-ready` banner. */
|
|
88
|
+
export const MAX_NOT_READY_RETRIES = 8;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The header set by the Lambda@Edge JWT verifier on its deny responses. When
|
|
92
|
+
* present on a 401/403 the failure was an edge-level auth denial; when absent
|
|
93
|
+
* on a 403/404 the failure originated from the S3 origin (object missing).
|
|
94
|
+
* See `infra/constructs/cloudfront/remit-content-delivery/lambda-edge/jwt.ts`.
|
|
95
|
+
*/
|
|
96
|
+
const REASON_HEADER = "x-remit-403-reason";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Classify a fetch response into a `BodyFetchReason`. Pure so the
|
|
100
|
+
* discrimination can be unit-tested without mocking the network.
|
|
101
|
+
*
|
|
102
|
+
* - 401 → always `auth` (Lambda@Edge denies missing/invalid tokens with 401).
|
|
103
|
+
* - 403 with `x-remit-403-reason` → `auth` (Lambda@Edge tenant-mismatch etc).
|
|
104
|
+
* - 403/404 without the reason header → `body-missing` (S3 origin response).
|
|
105
|
+
* - Anything else → `generic`.
|
|
106
|
+
*
|
|
107
|
+
* Known corner case: CloudFront itself can emit a bare 403 for WAF block,
|
|
108
|
+
* geo-restriction, or signed-URL/cookie failures (none of which we use
|
|
109
|
+
* today, but a future WAF attachment would change that). Those bare-403s
|
|
110
|
+
* also lack `x-remit-403-reason` and therefore classify as `body-missing` —
|
|
111
|
+
* misleading copy, but the user-facing fix is the same in practice
|
|
112
|
+
* (contact support / check status). If WAF is ever added on `/content/*`,
|
|
113
|
+
* revisit and either set a distinct header on the WAF deny or branch on
|
|
114
|
+
* the `X-Amz-Cf-Id` / `Server: CloudFront` headers.
|
|
115
|
+
*/
|
|
116
|
+
export const classifyBodyFetchFailure = (
|
|
117
|
+
status: number,
|
|
118
|
+
reasonHeader: string | null,
|
|
119
|
+
): BodyFetchReason => {
|
|
120
|
+
const edgeReason = reasonHeader?.trim() ?? "";
|
|
121
|
+
if (status === 401) return "auth";
|
|
122
|
+
if (status === 403) return edgeReason.length > 0 ? "auth" : "body-missing";
|
|
123
|
+
if (status === 404) return "body-missing";
|
|
124
|
+
return "generic";
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Defensive guard against the CloudFront-rewrites-403/404-to-/index.html
|
|
129
|
+
* edge case. The infra fix (#310 review) already scopes the SPA-fallback
|
|
130
|
+
* rewrite to the default behaviour only, so `/content/*` 403/404 responses
|
|
131
|
+
* propagate as their real status. This guard is belt-and-suspenders: if a
|
|
132
|
+
* future infra change re-introduces a distribution-wide errorResponse the
|
|
133
|
+
* hook still refuses to hand the SPA shell to the email renderer.
|
|
134
|
+
*
|
|
135
|
+
* Pure (no React, no fetch) so the assertion can be unit-tested without
|
|
136
|
+
* mocking the network.
|
|
137
|
+
*/
|
|
138
|
+
export const isSpaShellResponse = (
|
|
139
|
+
body: string,
|
|
140
|
+
contentType: string | null,
|
|
141
|
+
): boolean => {
|
|
142
|
+
const ct = (contentType ?? "").toLowerCase();
|
|
143
|
+
if (!ct.startsWith("text/html")) return false;
|
|
144
|
+
// `<div id="root"></div>` is the SPA's React mount node and uniquely
|
|
145
|
+
// identifies the index.html shell. The marker is stable across builds
|
|
146
|
+
// (it's the documented React 19 mount target) and absent from any
|
|
147
|
+
// well-formed inbound email.
|
|
148
|
+
return /<div\s+id=["']root["']/i.test(body);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Validate that the Content-Type matches what the picker expected. text/plain
|
|
153
|
+
* parts must never come back as text/html — the dev-server emits
|
|
154
|
+
* application/octet-stream and CloudFront serves whatever Content-Type S3
|
|
155
|
+
* stored, neither of which is text/html for a plain-text part. Catching the
|
|
156
|
+
* mismatch here means a misconfigured edge or a bucket-policy bypass can't
|
|
157
|
+
* smuggle HTML into the renderer.
|
|
158
|
+
*/
|
|
159
|
+
export const isContentTypeMismatch = (
|
|
160
|
+
expected: BodyContentKind,
|
|
161
|
+
contentType: string | null,
|
|
162
|
+
): boolean => {
|
|
163
|
+
const ct = (contentType ?? "").toLowerCase();
|
|
164
|
+
if (expected === "text" && ct.startsWith("text/html")) return true;
|
|
165
|
+
return false;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Fetch the renderable body part (HTML preferred, plain-text fallback) for a
|
|
170
|
+
* message via CloudFront. The Lambda@Edge JWT verifier (`/content/*`) accepts
|
|
171
|
+
* both `Cookie: id_token=…` and `Authorization: Bearer …`; same-origin
|
|
172
|
+
* cookies aren't set in the SPA today, so we forward the session bearer token
|
|
173
|
+
* from the auth seam via the Authorization header — same pattern as the API
|
|
174
|
+
* auth interceptor.
|
|
175
|
+
*
|
|
176
|
+
* Throws on non-2xx so the surrounding `useQuery` exposes `isError` and the
|
|
177
|
+
* caller can render an alert banner. Never silently substitutes an empty
|
|
178
|
+
* body — the user must know the difference between "empty" and "failed"
|
|
179
|
+
* (memory: feedback_never_hide_failure).
|
|
180
|
+
*/
|
|
181
|
+
export const fetchBodyContent = async (
|
|
182
|
+
url: string,
|
|
183
|
+
expected: BodyContentKind,
|
|
184
|
+
getToken: () => Promise<string | null> = async () => null,
|
|
185
|
+
): Promise<string> => {
|
|
186
|
+
const headers: Record<string, string> = {};
|
|
187
|
+
const token = await getToken();
|
|
188
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
189
|
+
const response = await fetch(url, { headers });
|
|
190
|
+
// 202 = the body is not synced yet. The content route has re-armed the sync
|
|
191
|
+
// cue; retry after `Retry-After` seconds rather than rendering the placeholder
|
|
192
|
+
// body (a 202 is `ok`, so this must be caught before the success path).
|
|
193
|
+
if (response.status === 202) {
|
|
194
|
+
throw new BodyFetchError(
|
|
195
|
+
"not-ready",
|
|
196
|
+
"Message body is still syncing",
|
|
197
|
+
202,
|
|
198
|
+
parseRetryAfterSeconds(response.headers.get("Retry-After")),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
const edgeReason = response.headers.get(REASON_HEADER);
|
|
203
|
+
const reason = classifyBodyFetchFailure(response.status, edgeReason);
|
|
204
|
+
throw new BodyFetchError(
|
|
205
|
+
reason,
|
|
206
|
+
`Failed to load message body (${response.status} ${response.statusText})`,
|
|
207
|
+
response.status,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
const contentType = response.headers.get("content-type");
|
|
211
|
+
if (isContentTypeMismatch(expected, contentType)) {
|
|
212
|
+
throw new BodyFetchError(
|
|
213
|
+
"content-type-mismatch",
|
|
214
|
+
`Refusing to render message body — expected ${expected}, got Content-Type ${contentType}`,
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
const body = await response.text();
|
|
218
|
+
if (isSpaShellResponse(body, contentType)) {
|
|
219
|
+
throw new BodyFetchError(
|
|
220
|
+
"spa-shell-leak",
|
|
221
|
+
"Refusing to render message body — response looks like the SPA shell (CloudFront 403/404 fallback leaked through to /content/*)",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return body;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
export interface BodyContentAttemptDeps {
|
|
228
|
+
fetchContent: (url: string, kind: BodyContentKind) => Promise<string>;
|
|
229
|
+
refetchDescribeMessage: () => Promise<unknown>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Fetches one attempt at the renderable body part. `isRetryAfterNotReady`
|
|
234
|
+
* marks an attempt that follows a 202 `not-ready` response (after honoring
|
|
235
|
+
* `Retry-After`) — on that attempt, refetch the describe query *before*
|
|
236
|
+
* re-hitting the content URL.
|
|
237
|
+
*
|
|
238
|
+
* Under `DEFER_BODY_PARTS` (default on) the body-sync worker only writes
|
|
239
|
+
* `body.eml`; per-part storage objects are materialized as a side effect of
|
|
240
|
+
* the describe read path (`materializeBodyParts`, backend `describeMessage`).
|
|
241
|
+
* Re-hitting the same part URL alone can never progress past a 202 within one
|
|
242
|
+
* open — the describe refetch is what actually creates the missing object
|
|
243
|
+
* server-side (remit-mail/remit#1240).
|
|
244
|
+
*/
|
|
245
|
+
export const fetchBodyContentAttempt = async (
|
|
246
|
+
deps: BodyContentAttemptDeps,
|
|
247
|
+
args: {
|
|
248
|
+
contentUrl: string;
|
|
249
|
+
kind: BodyContentKind;
|
|
250
|
+
isRetryAfterNotReady: boolean;
|
|
251
|
+
},
|
|
252
|
+
): Promise<string> => {
|
|
253
|
+
if (args.isRetryAfterNotReady) {
|
|
254
|
+
await deps.refetchDescribeMessage();
|
|
255
|
+
}
|
|
256
|
+
return deps.fetchContent(args.contentUrl, args.kind);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
interface UseMessageBodyContentOptions {
|
|
260
|
+
messageId?: string;
|
|
261
|
+
bodyParts?: readonly RenderableBodyPart[];
|
|
262
|
+
enabled?: boolean;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* React-query wrapper that picks the renderable body part for a message and
|
|
267
|
+
* fetches it. Returns `picked = null` (no error) when the message has no
|
|
268
|
+
* renderable text/html part — e.g. an attachment-only message — so the
|
|
269
|
+
* caller can render an empty-state affordance without surfacing an error.
|
|
270
|
+
*/
|
|
271
|
+
export const useMessageBodyContent = ({
|
|
272
|
+
messageId,
|
|
273
|
+
bodyParts,
|
|
274
|
+
enabled = true,
|
|
275
|
+
}: UseMessageBodyContentOptions) => {
|
|
276
|
+
const picked = bodyParts ? pickRenderablePart(bodyParts) : null;
|
|
277
|
+
const telemetry = useTelemetry();
|
|
278
|
+
const { getToken } = useAuthProvider();
|
|
279
|
+
const loadStartRef = useRef<number | null>(null);
|
|
280
|
+
const queryClient = useQueryClient();
|
|
281
|
+
// Set by `retryDelay` when a `not-ready` (202) failure is being retried, and
|
|
282
|
+
// consumed by the next `queryFn` call — see `fetchBodyContentAttempt`.
|
|
283
|
+
const pendingDescribeRefetchRef = useRef(false);
|
|
284
|
+
|
|
285
|
+
// A new message/part invalidates any pending describe-refetch left over from
|
|
286
|
+
// the previous query — it belongs to a retry sequence that no longer
|
|
287
|
+
// applies. Adjusted during render (React's documented pattern for resetting
|
|
288
|
+
// state derived from a changing prop) rather than a useEffect, so the reset
|
|
289
|
+
// is visible to the very next queryFn call instead of racing it.
|
|
290
|
+
const bodyQueryKeyRef = useRef<string | null>(null);
|
|
291
|
+
const bodyQueryKey =
|
|
292
|
+
messageId && picked ? `${messageId}:${picked.contentUrl}` : null;
|
|
293
|
+
if (bodyQueryKeyRef.current !== bodyQueryKey) {
|
|
294
|
+
bodyQueryKeyRef.current = bodyQueryKey;
|
|
295
|
+
pendingDescribeRefetchRef.current = false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const query = useQuery({
|
|
299
|
+
queryKey: messageId
|
|
300
|
+
? [...messageKeys.body(messageId), picked?.contentUrl ?? null]
|
|
301
|
+
: ["messages", "body", "noop"],
|
|
302
|
+
queryFn: () => {
|
|
303
|
+
if (!picked) throw new Error("No renderable body part");
|
|
304
|
+
if (!messageId) throw new Error("No messageId");
|
|
305
|
+
const isRetryAfterNotReady = pendingDescribeRefetchRef.current;
|
|
306
|
+
pendingDescribeRefetchRef.current = false;
|
|
307
|
+
return fetchBodyContentAttempt(
|
|
308
|
+
{
|
|
309
|
+
fetchContent: (url, kind) => fetchBodyContent(url, kind, getToken),
|
|
310
|
+
refetchDescribeMessage: () =>
|
|
311
|
+
queryClient.refetchQueries({
|
|
312
|
+
queryKey: messageOperationsDescribeMessageQueryKey({
|
|
313
|
+
path: { messageId },
|
|
314
|
+
}),
|
|
315
|
+
}),
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
contentUrl: picked.contentUrl,
|
|
319
|
+
kind: picked.kind,
|
|
320
|
+
isRetryAfterNotReady,
|
|
321
|
+
},
|
|
322
|
+
);
|
|
323
|
+
},
|
|
324
|
+
enabled: enabled && !!messageId && !!picked,
|
|
325
|
+
staleTime: 5 * 60 * 1000,
|
|
326
|
+
gcTime: 30 * 60 * 1000,
|
|
327
|
+
// A `not-ready` (202) body is still syncing — keep retrying with the
|
|
328
|
+
// server's `Retry-After` delay until it lands. Every other error keeps the
|
|
329
|
+
// app-wide default (retry once) so a real failure surfaces its banner
|
|
330
|
+
// promptly instead of hanging on repeated attempts.
|
|
331
|
+
retry: (failureCount, error) => {
|
|
332
|
+
if (error instanceof BodyFetchError && error.reason === "not-ready") {
|
|
333
|
+
return failureCount < MAX_NOT_READY_RETRIES;
|
|
334
|
+
}
|
|
335
|
+
return failureCount < 1;
|
|
336
|
+
},
|
|
337
|
+
retryDelay: (_attempt, error) => {
|
|
338
|
+
if (error instanceof BodyFetchError && error.reason === "not-ready") {
|
|
339
|
+
pendingDescribeRefetchRef.current = true;
|
|
340
|
+
return (error.retryAfterSeconds ?? 1) * 1000;
|
|
341
|
+
}
|
|
342
|
+
return 1000;
|
|
343
|
+
},
|
|
344
|
+
// A missing/forbidden body (404/403 body-missing, auth) renders the inline
|
|
345
|
+
// MessageBodyErrorBanner below — a single sub-resource failure must not nuke
|
|
346
|
+
// the whole app to the fatal overlay. A 5xx still escalates globally
|
|
347
|
+
// (meta.softError is ignored for 5xx — #1059 / #1231 / #1232).
|
|
348
|
+
meta: { softError: true },
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const isLoading = query.isLoading && !!picked;
|
|
352
|
+
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
if (isLoading) {
|
|
355
|
+
loadStartRef.current = performance.now();
|
|
356
|
+
}
|
|
357
|
+
}, [isLoading]);
|
|
358
|
+
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
if (query.isSuccess && loadStartRef.current !== null) {
|
|
361
|
+
telemetry.recordTiming(
|
|
362
|
+
"message.body.load",
|
|
363
|
+
Math.round(performance.now() - loadStartRef.current),
|
|
364
|
+
);
|
|
365
|
+
loadStartRef.current = null;
|
|
366
|
+
}
|
|
367
|
+
}, [query.isSuccess, telemetry]);
|
|
368
|
+
|
|
369
|
+
const data: MessageBodyContent | undefined =
|
|
370
|
+
query.data && picked ? { kind: picked.kind, body: query.data } : undefined;
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
picked,
|
|
374
|
+
data,
|
|
375
|
+
isLoading,
|
|
376
|
+
isError: query.isError,
|
|
377
|
+
error: query.error,
|
|
378
|
+
refetch: query.refetch,
|
|
379
|
+
};
|
|
380
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import { removeMovedMessagesFromItems } from "./useMoveMessages.js";
|
|
5
|
+
|
|
6
|
+
const make = (
|
|
7
|
+
overrides: Partial<RemitImapThreadMessageResponse> & {
|
|
8
|
+
messageId: string;
|
|
9
|
+
threadMessageId: string;
|
|
10
|
+
},
|
|
11
|
+
): RemitImapThreadMessageResponse =>
|
|
12
|
+
({
|
|
13
|
+
threadId: "t1",
|
|
14
|
+
mailboxId: "mb1",
|
|
15
|
+
accountConfigId: "acc-1",
|
|
16
|
+
subject: "s",
|
|
17
|
+
fromName: "n",
|
|
18
|
+
fromEmail: "e",
|
|
19
|
+
sentDate: "2025-01-01T00:00:00Z",
|
|
20
|
+
snippet: "",
|
|
21
|
+
hasAttachment: false,
|
|
22
|
+
hasStars: false,
|
|
23
|
+
isRead: true,
|
|
24
|
+
...overrides,
|
|
25
|
+
}) as RemitImapThreadMessageResponse;
|
|
26
|
+
|
|
27
|
+
describe("removeMovedMessagesFromItems (#236)", () => {
|
|
28
|
+
test("drops items whose messageId is in the set", () => {
|
|
29
|
+
const items = [
|
|
30
|
+
make({ messageId: "m1", threadMessageId: "tm1" }),
|
|
31
|
+
make({ messageId: "m2", threadMessageId: "tm2" }),
|
|
32
|
+
make({ messageId: "m3", threadMessageId: "tm3" }),
|
|
33
|
+
];
|
|
34
|
+
const got = removeMovedMessagesFromItems(items, new Set(["m2"]));
|
|
35
|
+
assert.deepStrictEqual(
|
|
36
|
+
got.map((i) => i.messageId),
|
|
37
|
+
["m1", "m3"],
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returns the input unchanged when nothing matches", () => {
|
|
42
|
+
const items = [
|
|
43
|
+
make({ messageId: "m1", threadMessageId: "tm1" }),
|
|
44
|
+
make({ messageId: "m2", threadMessageId: "tm2" }),
|
|
45
|
+
];
|
|
46
|
+
const got = removeMovedMessagesFromItems(items, new Set(["nope"]));
|
|
47
|
+
assert.equal(got.length, 2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("returns an empty array when all items match", () => {
|
|
51
|
+
const items = [
|
|
52
|
+
make({ messageId: "m1", threadMessageId: "tm1" }),
|
|
53
|
+
make({ messageId: "m2", threadMessageId: "tm2" }),
|
|
54
|
+
];
|
|
55
|
+
const got = removeMovedMessagesFromItems(items, new Set(["m1", "m2"]));
|
|
56
|
+
assert.deepStrictEqual(got, []);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("returns an empty array for empty input", () => {
|
|
60
|
+
const got = removeMovedMessagesFromItems([], new Set(["m1"]));
|
|
61
|
+
assert.deepStrictEqual(got, []);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mailboxOperationsListMailboxesQueryKey,
|
|
3
|
+
messageBulkOperationsMoveMessagesMutation,
|
|
4
|
+
threadDetailOperationsListThreadMessagesQueryKey,
|
|
5
|
+
threadOperationsListThreadsQueryKey,
|
|
6
|
+
threadOperationsSearchThreadsQueryKey,
|
|
7
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
8
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
9
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
10
|
+
import { useCallback } from "react";
|
|
11
|
+
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
12
|
+
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
13
|
+
|
|
14
|
+
interface UseMoveMessagesOptions {
|
|
15
|
+
mailboxId: string;
|
|
16
|
+
threadId?: string;
|
|
17
|
+
accountId?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Called once the optimistic removal has been applied. Use this to
|
|
20
|
+
* navigate away from a now-empty thread (e.g. clear `selectedMessageId`)
|
|
21
|
+
* before the server response arrives.
|
|
22
|
+
*/
|
|
23
|
+
onAfterOptimisticRemove?: (messageIds: string[]) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ThreadMessagesData {
|
|
27
|
+
items: RemitImapThreadMessageResponse[];
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ThreadsListPage {
|
|
32
|
+
items: RemitImapThreadMessageResponse[];
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ThreadsListData {
|
|
37
|
+
pages: ThreadsListPage[];
|
|
38
|
+
pageParams: Array<string | undefined>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface SnapshotEntry<T> {
|
|
42
|
+
queryKey: readonly unknown[];
|
|
43
|
+
data: T;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface MoveContext {
|
|
47
|
+
threadMessagesPrefix: readonly unknown[];
|
|
48
|
+
threadsListPrefix: readonly unknown[];
|
|
49
|
+
threadsSearchPrefix: readonly unknown[];
|
|
50
|
+
previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
|
|
51
|
+
previousThreadsList: SnapshotEntry<ThreadsListData>[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pure helper: drop the messages in `messageIds` from a single page's items.
|
|
56
|
+
*
|
|
57
|
+
* Mirrors `removeMessagesFromItems` in `useDeleteMessages` so the optimistic
|
|
58
|
+
* patcher can be unit-tested without rendering React. The move flow needs the
|
|
59
|
+
* same surface — rows leave the source view immediately and only restore on
|
|
60
|
+
* server failure.
|
|
61
|
+
*/
|
|
62
|
+
export const removeMovedMessagesFromItems = (
|
|
63
|
+
items: RemitImapThreadMessageResponse[],
|
|
64
|
+
messageIds: Set<string>,
|
|
65
|
+
): RemitImapThreadMessageResponse[] =>
|
|
66
|
+
items.filter((item) => !messageIds.has(item.messageId));
|
|
67
|
+
|
|
68
|
+
export const useMoveMessages = ({
|
|
69
|
+
mailboxId,
|
|
70
|
+
threadId,
|
|
71
|
+
accountId,
|
|
72
|
+
onAfterOptimisticRemove,
|
|
73
|
+
}: UseMoveMessagesOptions) => {
|
|
74
|
+
const queryClient = useQueryClient();
|
|
75
|
+
const { pushError } = useErrorBanners();
|
|
76
|
+
|
|
77
|
+
const { mutate, isPending } = useMutation({
|
|
78
|
+
...messageBulkOperationsMoveMessagesMutation(),
|
|
79
|
+
onMutate: async (variables): Promise<MoveContext> => {
|
|
80
|
+
const messageIds = new Set(variables.body.messageIds ?? []);
|
|
81
|
+
|
|
82
|
+
const threadMessagesPrefix = threadId
|
|
83
|
+
? threadDetailOperationsListThreadMessagesQueryKey({
|
|
84
|
+
path: { threadId },
|
|
85
|
+
})
|
|
86
|
+
: [];
|
|
87
|
+
const threadsListPrefix = threadOperationsListThreadsQueryKey({
|
|
88
|
+
path: { mailboxId },
|
|
89
|
+
});
|
|
90
|
+
const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
|
|
91
|
+
path: { mailboxId },
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Only cancel the thread-messages query when we actually have a
|
|
95
|
+
// threadId — passing an empty queryKey here would match every
|
|
96
|
+
// query in the cache and cancel unrelated work (Copilot review,
|
|
97
|
+
// PR #287). The list/search prefixes are always scoped to the
|
|
98
|
+
// current mailbox so they're safe to cancel as-is.
|
|
99
|
+
await Promise.all([
|
|
100
|
+
...(threadId
|
|
101
|
+
? [queryClient.cancelQueries({ queryKey: threadMessagesPrefix })]
|
|
102
|
+
: []),
|
|
103
|
+
queryClient.cancelQueries({ queryKey: threadsListPrefix }),
|
|
104
|
+
queryClient.cancelQueries({ queryKey: threadsSearchPrefix }),
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
const previousThreadMessages = threadId
|
|
108
|
+
? queryClient
|
|
109
|
+
.getQueriesData<ThreadMessagesData>({
|
|
110
|
+
queryKey: threadMessagesPrefix,
|
|
111
|
+
})
|
|
112
|
+
.filter(
|
|
113
|
+
(entry): entry is [readonly unknown[], ThreadMessagesData] =>
|
|
114
|
+
entry[1] !== undefined,
|
|
115
|
+
)
|
|
116
|
+
.map(([queryKey, data]) => ({ queryKey, data }))
|
|
117
|
+
: [];
|
|
118
|
+
|
|
119
|
+
const previousThreadsList = queryClient
|
|
120
|
+
.getQueriesData<ThreadsListData>({ queryKey: threadsListPrefix })
|
|
121
|
+
.concat(
|
|
122
|
+
queryClient.getQueriesData<ThreadsListData>({
|
|
123
|
+
queryKey: threadsSearchPrefix,
|
|
124
|
+
}),
|
|
125
|
+
)
|
|
126
|
+
.filter(
|
|
127
|
+
(entry): entry is [readonly unknown[], ThreadsListData] =>
|
|
128
|
+
entry[1] !== undefined,
|
|
129
|
+
)
|
|
130
|
+
.map(([queryKey, data]) => ({ queryKey, data }));
|
|
131
|
+
|
|
132
|
+
if (threadId) {
|
|
133
|
+
queryClient.setQueriesData<ThreadMessagesData>(
|
|
134
|
+
{ queryKey: threadMessagesPrefix },
|
|
135
|
+
(old) => {
|
|
136
|
+
if (!old) return old;
|
|
137
|
+
return {
|
|
138
|
+
...old,
|
|
139
|
+
items: removeMovedMessagesFromItems(old.items, messageIds),
|
|
140
|
+
};
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const patchListData = (old: ThreadsListData | undefined) => {
|
|
146
|
+
if (!old) return old;
|
|
147
|
+
return {
|
|
148
|
+
...old,
|
|
149
|
+
pages: old.pages.map((page) => ({
|
|
150
|
+
...page,
|
|
151
|
+
items: removeMovedMessagesFromItems(page.items, messageIds),
|
|
152
|
+
})),
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
queryClient.setQueriesData<ThreadsListData>(
|
|
157
|
+
{ queryKey: threadsListPrefix },
|
|
158
|
+
patchListData,
|
|
159
|
+
);
|
|
160
|
+
queryClient.setQueriesData<ThreadsListData>(
|
|
161
|
+
{ queryKey: threadsSearchPrefix },
|
|
162
|
+
patchListData,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
onAfterOptimisticRemove?.(Array.from(messageIds));
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
threadMessagesPrefix,
|
|
169
|
+
threadsListPrefix,
|
|
170
|
+
threadsSearchPrefix,
|
|
171
|
+
previousThreadMessages,
|
|
172
|
+
previousThreadsList,
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
onError: (err, vars, context) => {
|
|
176
|
+
if (context) {
|
|
177
|
+
for (const entry of context.previousThreadMessages) {
|
|
178
|
+
queryClient.setQueryData(entry.queryKey, entry.data);
|
|
179
|
+
}
|
|
180
|
+
for (const entry of context.previousThreadsList) {
|
|
181
|
+
queryClient.setQueryData(entry.queryKey, entry.data);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const count = vars.body.messageIds?.length ?? 0;
|
|
185
|
+
pushError({
|
|
186
|
+
title:
|
|
187
|
+
count > 1
|
|
188
|
+
? `Couldn't move ${count} messages`
|
|
189
|
+
: "Couldn't move this message",
|
|
190
|
+
detail: formatErrorDetail(err),
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
onSettled: (_data, _err, _vars, context) => {
|
|
194
|
+
if (!context) return;
|
|
195
|
+
if (threadId) {
|
|
196
|
+
queryClient.invalidateQueries({
|
|
197
|
+
queryKey: context.threadMessagesPrefix,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
queryClient.invalidateQueries({ queryKey: context.threadsListPrefix });
|
|
201
|
+
queryClient.invalidateQueries({ queryKey: context.threadsSearchPrefix });
|
|
202
|
+
if (accountId) {
|
|
203
|
+
queryClient.invalidateQueries({
|
|
204
|
+
queryKey: mailboxOperationsListMailboxesQueryKey({
|
|
205
|
+
path: { accountId },
|
|
206
|
+
}),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const moveMessages = useCallback(
|
|
213
|
+
(messageIds: string[], destinationMailboxId: string) => {
|
|
214
|
+
if (messageIds.length === 0) return;
|
|
215
|
+
mutate({ body: { messageIds, destinationMailboxId } });
|
|
216
|
+
},
|
|
217
|
+
[mutate],
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return { moveMessages, isPending };
|
|
221
|
+
};
|