@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.
Files changed (298) hide show
  1. package/harness/build.mjs +84 -0
  2. package/harness/index.html +37 -0
  3. package/harness/vite-preset.ts +29 -0
  4. package/index.html +47 -0
  5. package/package.json +147 -0
  6. package/public/config.js +5 -0
  7. package/public/locales/de/common.json +1 -0
  8. package/public/locales/de/errors.json +1 -0
  9. package/public/locales/de/mail.json +13 -0
  10. package/public/locales/de/settings.json +1 -0
  11. package/public/locales/en/common.json +26 -0
  12. package/public/locales/en/errors.json +1 -0
  13. package/public/locales/en/mail.json +69 -0
  14. package/public/locales/en/settings.json +1 -0
  15. package/public/locales/fr/common.json +1 -0
  16. package/public/locales/fr/errors.json +1 -0
  17. package/public/locales/fr/mail.json +13 -0
  18. package/public/locales/fr/settings.json +1 -0
  19. package/public/locales/nl/common.json +1 -0
  20. package/public/locales/nl/errors.json +1 -0
  21. package/public/locales/nl/mail.json +13 -0
  22. package/public/locales/nl/settings.json +1 -0
  23. package/src/auth/AccountMenu.tsx +58 -0
  24. package/src/auth/AccountSession.tsx +19 -0
  25. package/src/auth/BetterAuthShell.tsx +198 -0
  26. package/src/auth/account-menu-mode.test.ts +64 -0
  27. package/src/auth/account-menu-mode.ts +28 -0
  28. package/src/auth/amplify-config.test.ts +158 -0
  29. package/src/auth/amplify-config.ts +60 -0
  30. package/src/auth/auth-error.test.ts +109 -0
  31. package/src/auth/auth-error.ts +107 -0
  32. package/src/auth/auth-interceptor.test.ts +86 -0
  33. package/src/auth/auth-interceptor.ts +23 -0
  34. package/src/auth/auth.css +334 -0
  35. package/src/auth/better-auth/BetterAuthAccount.tsx +25 -0
  36. package/src/auth/better-auth-config.ts +85 -0
  37. package/src/auth/better-auth-provider.tsx +20 -0
  38. package/src/auth/cognito/CognitoAccount.tsx +48 -0
  39. package/src/auth/cognito/CognitoShell.tsx +266 -0
  40. package/src/auth/cognito/cognito-token.test.ts +110 -0
  41. package/src/auth/cognito/cognito-token.ts +47 -0
  42. package/src/auth/cognito/cognito.css +21 -0
  43. package/src/auth/cognito-provider.tsx +22 -0
  44. package/src/auth/combined-provider.tsx +42 -0
  45. package/src/auth/composition.test.ts +136 -0
  46. package/src/auth/provider.tsx +63 -0
  47. package/src/auth/sign-out-visibility.test.ts +40 -0
  48. package/src/auth/sign-out-visibility.ts +10 -0
  49. package/src/auth/sign-up-disabled.test.ts +23 -0
  50. package/src/components/compose/AddressField.tsx +237 -0
  51. package/src/components/compose/ComposeBody.tsx +36 -0
  52. package/src/components/compose/ComposeForm.tsx +676 -0
  53. package/src/components/compose/ComposeProvider.tsx +133 -0
  54. package/src/components/compose/ComposeSmtpMissingBanner.tsx +49 -0
  55. package/src/components/compose/FromSelector.tsx +67 -0
  56. package/src/components/compose/FullCompose.tsx +82 -0
  57. package/src/components/compose/InlineCompose.tsx +29 -0
  58. package/src/components/compose/MobileComposeSheet.tsx +152 -0
  59. package/src/components/compose/PlateEditor.tsx +86 -0
  60. package/src/components/compose/PlateToolbar.tsx +110 -0
  61. package/src/components/compose/SubjectField.tsx +19 -0
  62. package/src/components/compose/draft-discard-banner.test.ts +76 -0
  63. package/src/components/compose/index.ts +6 -0
  64. package/src/components/compose/sanitize-quote-html.ts +23 -0
  65. package/src/components/layout/AppShellSkeleton.tsx +53 -0
  66. package/src/components/layout/ComposeFab.tsx +86 -0
  67. package/src/components/layout/Drawer.tsx +105 -0
  68. package/src/components/mail/AutoMovedIndicator.tsx +48 -0
  69. package/src/components/mail/BlockedImagesNotice.render.test.ts +65 -0
  70. package/src/components/mail/BlockedImagesNotice.tsx +41 -0
  71. package/src/components/mail/BriefPane.tsx +289 -0
  72. package/src/components/mail/ConversationView.tsx +462 -0
  73. package/src/components/mail/DailyBrief.tsx +451 -0
  74. package/src/components/mail/DraftsView.tsx +279 -0
  75. package/src/components/mail/FlaggedList.tsx +170 -0
  76. package/src/components/mail/FlaggedPane.tsx +217 -0
  77. package/src/components/mail/IntelligencePane.test.ts +123 -0
  78. package/src/components/mail/IntelligencePane.tsx +441 -0
  79. package/src/components/mail/MailListHeader.tsx +187 -0
  80. package/src/components/mail/MailNav.tsx +70 -0
  81. package/src/components/mail/MailSidebarAdapter.tsx +351 -0
  82. package/src/components/mail/MailViewChrome.tsx +95 -0
  83. package/src/components/mail/MailboxPane.tsx +1219 -0
  84. package/src/components/mail/MessageActionMenu.tsx +368 -0
  85. package/src/components/mail/MessageBody.tsx +207 -0
  86. package/src/components/mail/MessageBodyErrorBanner.render.test.ts +85 -0
  87. package/src/components/mail/MessageBodyErrorBanner.test.ts +86 -0
  88. package/src/components/mail/MessageBodyErrorBanner.tsx +90 -0
  89. package/src/components/mail/MessageCard.tsx +401 -0
  90. package/src/components/mail/MessageDetail.tsx +153 -0
  91. package/src/components/mail/MessageList.tsx +804 -0
  92. package/src/components/mail/MessageListItem.tsx +267 -0
  93. package/src/components/mail/MessageToolbar.render.test.ts +57 -0
  94. package/src/components/mail/MessageToolbar.tsx +166 -0
  95. package/src/components/mail/MobileMessageBar.tsx +142 -0
  96. package/src/components/mail/MoveToTrigger.tsx +250 -0
  97. package/src/components/mail/NavMenuButton.tsx +25 -0
  98. package/src/components/mail/OutboxPane.tsx +531 -0
  99. package/src/components/mail/PullToRefresh.tsx +24 -0
  100. package/src/components/mail/RawMessageView.tsx +84 -0
  101. package/src/components/mail/ReadingPaneEmpty.render.test.ts +42 -0
  102. package/src/components/mail/SelectionToolbar.tsx +139 -0
  103. package/src/components/mail/SpamRescue.render.test.ts +35 -0
  104. package/src/components/mail/SpamRescue.tsx +138 -0
  105. package/src/components/mail/SwipeableMessageRow.tsx +148 -0
  106. package/src/components/mail/message-body-error-banner-content.ts +87 -0
  107. package/src/components/mail/organize/OrganizeDialog.render.test.ts +37 -0
  108. package/src/components/mail/organize/OrganizeDialog.tsx +83 -0
  109. package/src/components/mail/organize/OrganizePanel.render.test.ts +102 -0
  110. package/src/components/mail/organize/OrganizePanel.tsx +344 -0
  111. package/src/components/onboarding/OnboardingWizard.tsx +1547 -0
  112. package/src/components/settings/AccountFormPanel.test.ts +72 -0
  113. package/src/components/settings/AccountFormPanel.tsx +942 -0
  114. package/src/components/settings/DangerZone.tsx +30 -0
  115. package/src/components/settings/DeleteAccountDialog.render.test.ts +47 -0
  116. package/src/components/settings/DeleteAccountDialog.tsx +224 -0
  117. package/src/components/settings/FiltersList.render.test.ts +71 -0
  118. package/src/components/settings/FiltersList.tsx +93 -0
  119. package/src/components/settings/account-form-helpers.test.ts +172 -0
  120. package/src/components/settings/account-form-helpers.ts +105 -0
  121. package/src/components/ui/AppVersion.tsx +50 -0
  122. package/src/components/ui/BugReportButton.tsx +45 -0
  123. package/src/components/ui/ConfirmDialog.tsx +125 -0
  124. package/src/components/ui/DropdownMenu.tsx +105 -0
  125. package/src/components/ui/EmptyState.tsx +11 -0
  126. package/src/components/ui/ErrorBanner.tsx +88 -0
  127. package/src/components/ui/ErrorBannerProvider.tsx +77 -0
  128. package/src/components/ui/ErrorBannerStack.tsx +34 -0
  129. package/src/components/ui/ErrorState.tsx +80 -0
  130. package/src/components/ui/FatalErrorOverlay.render.test.ts +66 -0
  131. package/src/components/ui/FatalErrorOverlay.tsx +151 -0
  132. package/src/components/ui/KeyboardShortcutsModal.tsx +120 -0
  133. package/src/components/ui/SlidePanel.tsx +72 -0
  134. package/src/components/ui/error-banners.test.ts +207 -0
  135. package/src/components/ui/error-banners.ts +111 -0
  136. package/src/hooks/queries/keys.ts +38 -0
  137. package/src/hooks/useArchiveMailbox.ts +104 -0
  138. package/src/hooks/useAutoMovedBadge.ts +77 -0
  139. package/src/hooks/useCurrentMailboxName.ts +99 -0
  140. package/src/hooks/useDebouncedValue.ts +12 -0
  141. package/src/hooks/useDeleteMessages.test.ts +103 -0
  142. package/src/hooks/useDeleteMessages.ts +226 -0
  143. package/src/hooks/useFilters.ts +112 -0
  144. package/src/hooks/useIntelligenceData.test.ts +269 -0
  145. package/src/hooks/useIntelligenceData.ts +284 -0
  146. package/src/hooks/useIsDark.ts +27 -0
  147. package/src/hooks/useKeyboardNavigation.ts +192 -0
  148. package/src/hooks/useLayoutTier.test.ts +47 -0
  149. package/src/hooks/useLayoutTier.ts +44 -0
  150. package/src/hooks/useLongPress.test.ts +291 -0
  151. package/src/hooks/useLongPress.ts +87 -0
  152. package/src/hooks/useMailboxAccount.ts +62 -0
  153. package/src/hooks/useMailboxNameIndex.ts +32 -0
  154. package/src/hooks/useMarkAsRead.test.ts +165 -0
  155. package/src/hooks/useMarkAsRead.ts +319 -0
  156. package/src/hooks/useMediaQuery.ts +39 -0
  157. package/src/hooks/useMessageBodyContent.test.ts +370 -0
  158. package/src/hooks/useMessageBodyContent.ts +380 -0
  159. package/src/hooks/useMoveMessages.test.ts +63 -0
  160. package/src/hooks/useMoveMessages.ts +221 -0
  161. package/src/hooks/useOrganizeJob.ts +90 -0
  162. package/src/hooks/useOrganizePreview.ts +40 -0
  163. package/src/hooks/useRescueCandidates.test.ts +217 -0
  164. package/src/hooks/useRescueCandidates.ts +48 -0
  165. package/src/hooks/useSaveDraft.ts +118 -0
  166. package/src/hooks/useSelection.test.ts +75 -0
  167. package/src/hooks/useSelection.ts +211 -0
  168. package/src/hooks/useSemanticSearch.ts +94 -0
  169. package/src/hooks/useSignature.ts +69 -0
  170. package/src/hooks/useStaleAccountSync.test.ts +211 -0
  171. package/src/hooks/useStaleAccountSync.ts +246 -0
  172. package/src/hooks/useSwipeNavigation.test.ts +35 -0
  173. package/src/hooks/useSwipeNavigation.ts +99 -0
  174. package/src/hooks/useToggleStar.ts +187 -0
  175. package/src/hooks/useToggleTrusted.test.ts +102 -0
  176. package/src/hooks/useToggleTrusted.ts +161 -0
  177. package/src/hooks/useTriageKeyboard.ts +103 -0
  178. package/src/hooks/useTriggerSync.test.ts +24 -0
  179. package/src/hooks/useTriggerSync.ts +58 -0
  180. package/src/hooks/useUpdateAddressFlags.ts +125 -0
  181. package/src/hooks/useVisualViewport.test.ts +127 -0
  182. package/src/hooks/useVisualViewport.ts +51 -0
  183. package/src/index.css +19 -0
  184. package/src/lib/account-order.test.ts +84 -0
  185. package/src/lib/account-order.ts +13 -0
  186. package/src/lib/adjacent-message.test.ts +43 -0
  187. package/src/lib/adjacent-message.ts +20 -0
  188. package/src/lib/api.ts +82 -0
  189. package/src/lib/app-info.ts +15 -0
  190. package/src/lib/auto-moved.test.ts +140 -0
  191. package/src/lib/auto-moved.ts +68 -0
  192. package/src/lib/autodiscovery.test.ts +94 -0
  193. package/src/lib/autodiscovery.ts +354 -0
  194. package/src/lib/brief.test.ts +366 -0
  195. package/src/lib/brief.ts +180 -0
  196. package/src/lib/bug-report.test.ts +213 -0
  197. package/src/lib/bug-report.ts +176 -0
  198. package/src/lib/client.ts +36 -0
  199. package/src/lib/console-errors.test.ts +64 -0
  200. package/src/lib/console-errors.ts +92 -0
  201. package/src/lib/conversation-target.test.ts +68 -0
  202. package/src/lib/conversation-target.ts +59 -0
  203. package/src/lib/display-category.ts +20 -0
  204. package/src/lib/drafts.test.ts +241 -0
  205. package/src/lib/drafts.ts +121 -0
  206. package/src/lib/error-classifier.test.ts +133 -0
  207. package/src/lib/error-classifier.ts +96 -0
  208. package/src/lib/fatal-error.test.ts +97 -0
  209. package/src/lib/fatal-error.ts +165 -0
  210. package/src/lib/folder-roles.test.ts +113 -0
  211. package/src/lib/folder-roles.ts +112 -0
  212. package/src/lib/format.test.ts +115 -0
  213. package/src/lib/format.ts +198 -0
  214. package/src/lib/i18n.ts +36 -0
  215. package/src/lib/intelligence-pref.test.ts +57 -0
  216. package/src/lib/intelligence-pref.ts +25 -0
  217. package/src/lib/keymap-dispatch.test.ts +178 -0
  218. package/src/lib/keymap-dispatch.ts +187 -0
  219. package/src/lib/keymap.test.ts +49 -0
  220. package/src/lib/keymap.ts +178 -0
  221. package/src/lib/mail-context.ts +64 -0
  222. package/src/lib/mail-route.test.ts +80 -0
  223. package/src/lib/mail-route.ts +44 -0
  224. package/src/lib/message-body-source.test.ts +98 -0
  225. package/src/lib/message-body-source.ts +48 -0
  226. package/src/lib/move-targets.test.ts +195 -0
  227. package/src/lib/move-targets.ts +74 -0
  228. package/src/lib/onboarding-completion.test.ts +64 -0
  229. package/src/lib/onboarding-completion.ts +23 -0
  230. package/src/lib/organize/filter-status.test.ts +99 -0
  231. package/src/lib/organize/filter-status.ts +86 -0
  232. package/src/lib/organize/organize-copy.test.ts +93 -0
  233. package/src/lib/organize/organize-copy.ts +61 -0
  234. package/src/lib/organize/organize-model.test.ts +104 -0
  235. package/src/lib/organize/organize-model.ts +105 -0
  236. package/src/lib/organize/organize-poll.test.ts +37 -0
  237. package/src/lib/organize/organize-poll.ts +28 -0
  238. package/src/lib/outbox-status.test.ts +64 -0
  239. package/src/lib/outbox-status.ts +75 -0
  240. package/src/lib/plate-serializer.ts +164 -0
  241. package/src/lib/provider-presets.test.ts +96 -0
  242. package/src/lib/provider-presets.ts +81 -0
  243. package/src/lib/query-error-handler.test.ts +144 -0
  244. package/src/lib/query-error-handler.ts +34 -0
  245. package/src/lib/query-escalation.integration.test.ts +138 -0
  246. package/src/lib/recent-searches.test.ts +53 -0
  247. package/src/lib/recent-searches.ts +34 -0
  248. package/src/lib/rescue-candidates.test.ts +94 -0
  249. package/src/lib/rescue-candidates.ts +35 -0
  250. package/src/lib/rescue-telemetry.test.ts +75 -0
  251. package/src/lib/rescue-telemetry.ts +55 -0
  252. package/src/lib/rum-adapter.test.ts +299 -0
  253. package/src/lib/rum-adapter.ts +107 -0
  254. package/src/lib/saved-searches.test.ts +80 -0
  255. package/src/lib/saved-searches.ts +52 -0
  256. package/src/lib/search-pending.test.ts +61 -0
  257. package/src/lib/search-pending.ts +24 -0
  258. package/src/lib/search-query.test.ts +35 -0
  259. package/src/lib/search-query.ts +11 -0
  260. package/src/lib/search-result.test.ts +160 -0
  261. package/src/lib/search-result.ts +102 -0
  262. package/src/lib/search-schemas.ts +34 -0
  263. package/src/lib/search-token-index.test.ts +112 -0
  264. package/src/lib/search-token-index.ts +56 -0
  265. package/src/lib/search-tokens.test.ts +218 -0
  266. package/src/lib/search-tokens.ts +133 -0
  267. package/src/lib/telemetry-context.tsx +9 -0
  268. package/src/lib/telemetry.ts +17 -0
  269. package/src/lib/theme-preference.ts +46 -0
  270. package/src/lib/theme.ts +33 -0
  271. package/src/lib/utils.ts +4 -0
  272. package/src/main.tsx +10 -0
  273. package/src/routeTree.gen.ts +392 -0
  274. package/src/router.test.ts +81 -0
  275. package/src/router.tsx +31 -0
  276. package/src/routes/__root.tsx +69 -0
  277. package/src/routes/index.tsx +7 -0
  278. package/src/routes/mail/$mailboxId.tsx +27 -0
  279. package/src/routes/mail/flagged.tsx +37 -0
  280. package/src/routes/mail/index.tsx +41 -0
  281. package/src/routes/mail/outbox.tsx +13 -0
  282. package/src/routes/mail.tsx +413 -0
  283. package/src/routes/onboarding.tsx +35 -0
  284. package/src/routes/settings/-accounts.test.ts +65 -0
  285. package/src/routes/settings/accounts.tsx +542 -0
  286. package/src/routes/settings/advanced.tsx +55 -0
  287. package/src/routes/settings/appearance.tsx +140 -0
  288. package/src/routes/settings/filters.tsx +141 -0
  289. package/src/routes/settings/folders.tsx +216 -0
  290. package/src/routes/settings/index.tsx +5 -0
  291. package/src/routes/settings/senders.tsx +466 -0
  292. package/src/routes/settings/suggested-vips.tsx +10 -0
  293. package/src/routes/settings.tsx +66 -0
  294. package/src/runtime-config.ts +75 -0
  295. package/src/shell/index.tsx +98 -0
  296. package/src/types/index.ts +73 -0
  297. package/src/vite-env.d.ts +4 -0
  298. 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
+ };