@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,90 @@
1
+ import {
2
+ organizeJobDetailOperationsGetOrganizeJobOptions,
3
+ organizeOperationsCreateOrganizeJobMutation,
4
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
+ import type { RemitImapOrganizeJobResponse } from "@remit/api-http-client/types.gen.ts";
6
+ import { useMutation, useQuery } from "@tanstack/react-query";
7
+ import { useCallback, useState } from "react";
8
+ import {
9
+ buildOrganizeInput,
10
+ type OrganizeDraft,
11
+ } from "@/lib/organize/organize-model";
12
+ import {
13
+ isTerminalJobState,
14
+ nextPollDelayMs,
15
+ } from "@/lib/organize/organize-poll";
16
+
17
+ export interface OrganizeJobProgress {
18
+ state: RemitImapOrganizeJobResponse["state"] | undefined;
19
+ matchedCount: number;
20
+ appliedCount: number;
21
+ failedCount: number;
22
+ errorMessage: string;
23
+ }
24
+
25
+ /**
26
+ * "All like these" — start a one-time retroactive back-apply (POST /organize)
27
+ * and poll its status to completion (GET /organize/{organizeJobId}). Polling
28
+ * backs off exponentially and stops the instant the job reaches a terminal
29
+ * state (Complete / Failed), surfacing matched / applied / failed counts. The
30
+ * back-apply itself scans the corpus server-side; the client only reads a
31
+ * single job row per poll.
32
+ */
33
+ export const useOrganizeJob = (accountId: string | undefined) => {
34
+ const [organizeJobId, setOrganizeJobId] = useState<string | undefined>();
35
+
36
+ const createMutation = useMutation({
37
+ ...organizeOperationsCreateOrganizeJobMutation(),
38
+ onSuccess: (data) => setOrganizeJobId(data.organizeJobId),
39
+ });
40
+ const { mutate: createJob } = createMutation;
41
+
42
+ const jobQuery = useQuery({
43
+ ...organizeJobDetailOperationsGetOrganizeJobOptions({
44
+ path: {
45
+ accountId: accountId ?? "",
46
+ organizeJobId: organizeJobId ?? "",
47
+ },
48
+ }),
49
+ enabled: !!accountId && !!organizeJobId,
50
+ refetchInterval: (query) => {
51
+ const state = query.state.data?.state;
52
+ if (isTerminalJobState(state)) return false;
53
+ return nextPollDelayMs(query.state.dataUpdateCount);
54
+ },
55
+ });
56
+
57
+ const start = useCallback(
58
+ (draft: OrganizeDraft) => {
59
+ if (!accountId) return;
60
+ setOrganizeJobId(undefined);
61
+ createJob({
62
+ path: { accountId },
63
+ body: buildOrganizeInput(draft),
64
+ });
65
+ },
66
+ [accountId, createJob],
67
+ );
68
+
69
+ const job = jobQuery.data;
70
+ const state = job?.state ?? createMutation.data?.state;
71
+ const isDone = isTerminalJobState(job?.state);
72
+
73
+ const progress: OrganizeJobProgress = {
74
+ state,
75
+ matchedCount: job?.matchedCount ?? 0,
76
+ appliedCount: job?.appliedCount ?? 0,
77
+ failedCount: job?.failedCount ?? 0,
78
+ errorMessage: job?.errorMessage ?? "",
79
+ };
80
+
81
+ return {
82
+ start,
83
+ progress,
84
+ isStarting: createMutation.isPending,
85
+ isRunning: !!organizeJobId && !isDone,
86
+ isDone,
87
+ isError: createMutation.isError || jobQuery.isError,
88
+ error: createMutation.error ?? jobQuery.error,
89
+ };
90
+ };
@@ -0,0 +1,40 @@
1
+ import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import { useMutation } from "@tanstack/react-query";
3
+ import { useCallback } from "react";
4
+ import {
5
+ buildOrganizeInput,
6
+ type OrganizeDraft,
7
+ } from "@/lib/organize/organize-model";
8
+
9
+ /**
10
+ * "Select similar messages" (the widen step). Runs the read-only matcher once
11
+ * server-side (POST /organize/preview) and returns the messages the same
12
+ * predicate would apply an action to — the previewed set equals the set a job
13
+ * with the same input would apply to. This is the only corpus-wide query the
14
+ * flow makes; there is no client-side pagination over messages.
15
+ */
16
+ export const useOrganizePreview = (accountId: string | undefined) => {
17
+ const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
18
+ const { mutate } = mutation;
19
+
20
+ const preview = useCallback(
21
+ (draft: OrganizeDraft) => {
22
+ if (!accountId) return;
23
+ mutate({
24
+ path: { accountId },
25
+ body: buildOrganizeInput(draft),
26
+ });
27
+ },
28
+ [accountId, mutate],
29
+ );
30
+
31
+ return {
32
+ preview,
33
+ reset: mutation.reset,
34
+ matchedCount: mutation.data?.matchedCount,
35
+ messageIds: mutation.data?.messageIds,
36
+ isPending: mutation.isPending,
37
+ isError: mutation.isError,
38
+ error: mutation.error,
39
+ };
40
+ };
@@ -0,0 +1,217 @@
1
+ /**
2
+ * useRescueCandidates — exercises the real hook.
3
+ *
4
+ * Strategy: pre-seed a QueryClient cache with a mocked
5
+ * threadOperationsSearchThreads response (same key the hook generates), then
6
+ * render the hook synchronously via renderToString + QueryClientProvider. The
7
+ * hook reads from the cache, so the queryFn never fires; mapping drift in the
8
+ * hook source will break these assertions.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { describe, test } from "node:test";
13
+ import { threadOperationsSearchThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
14
+ import type {
15
+ RemitImapSenderTrust,
16
+ RemitImapThreadMessageResponse,
17
+ RemitImapThreadSearchResponse,
18
+ } from "@remit/api-http-client/types.gen.ts";
19
+ import type { RescueCandidate } from "@remit/ui";
20
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
21
+ import { createElement } from "react";
22
+ import { renderToString } from "react-dom/server";
23
+ import { useRescueCandidates } from "./useRescueCandidates.js";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ const JUNK_MAILBOX_ID = "mb-junk";
30
+
31
+ /** Query options the hook passes — must stay in sync with useRescueCandidates.ts */
32
+ const HOOK_QUERY_OPTIONS = {
33
+ path: { mailboxId: JUNK_MAILBOX_ID },
34
+ query: {
35
+ senderTrust: ["vip", "wellknown"] as RemitImapSenderTrust[],
36
+ dkimMismatch: false,
37
+ results: true,
38
+ limit: 500,
39
+ },
40
+ };
41
+
42
+ function makeThread(
43
+ overrides: Partial<RemitImapThreadMessageResponse> &
44
+ Pick<RemitImapThreadMessageResponse, "messageId" | "senderTrust">,
45
+ ): RemitImapThreadMessageResponse {
46
+ return {
47
+ threadMessageId: `tm-${overrides.messageId}`,
48
+ threadId: `t-${overrides.messageId}`,
49
+ accountConfigId: "acc",
50
+ mailboxId: JUNK_MAILBOX_ID,
51
+ sentDate: 1_000_000,
52
+ isRead: false,
53
+ hasAttachment: false,
54
+ hasStars: false,
55
+ isDeleted: false,
56
+ createdAt: 1_000_000,
57
+ updatedAt: 1_000_000,
58
+ fromName: "Anna de Vries",
59
+ fromEmail: "anna@example.nl",
60
+ subject: "Re: invoice",
61
+ snippet: "See attached.",
62
+ ...overrides,
63
+ } as RemitImapThreadMessageResponse;
64
+ }
65
+
66
+ /**
67
+ * Pre-seeds a QueryClient with `response` under the key the hook generates,
68
+ * renders the hook synchronously inside a QueryClientProvider, and returns
69
+ * whatever the hook returned.
70
+ */
71
+ function renderHookWithResponse(response: RemitImapThreadSearchResponse): {
72
+ candidates: RescueCandidate[];
73
+ } {
74
+ const client = new QueryClient({
75
+ defaultOptions: { queries: { retry: false } },
76
+ });
77
+ client.setQueryData(
78
+ threadOperationsSearchThreadsQueryKey(HOOK_QUERY_OPTIONS),
79
+ response,
80
+ );
81
+
82
+ let captured: { candidates: RescueCandidate[] } = { candidates: [] };
83
+
84
+ function Capture() {
85
+ captured = useRescueCandidates(JUNK_MAILBOX_ID);
86
+ return null;
87
+ }
88
+
89
+ renderToString(
90
+ createElement(
91
+ QueryClientProvider,
92
+ { client },
93
+ createElement(Capture),
94
+ ) as never,
95
+ );
96
+
97
+ return captured;
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Tests
102
+ // ---------------------------------------------------------------------------
103
+
104
+ describe("useRescueCandidates: empty / disabled states", () => {
105
+ test("returns empty candidates when the response has no items", () => {
106
+ const { candidates } = renderHookWithResponse({ items: [] });
107
+ assert.deepEqual(candidates, []);
108
+ });
109
+
110
+ test("returns empty candidates and does not fire when junkMailboxId is undefined", () => {
111
+ const client = new QueryClient({
112
+ defaultOptions: { queries: { retry: false } },
113
+ });
114
+
115
+ // Prove the query was never seeded — so if enabled fired, it would hang
116
+ let captured: { candidates: RescueCandidate[] } = { candidates: [] };
117
+
118
+ function Capture() {
119
+ captured = useRescueCandidates(undefined);
120
+ return null;
121
+ }
122
+
123
+ renderToString(
124
+ createElement(
125
+ QueryClientProvider,
126
+ { client },
127
+ createElement(Capture),
128
+ ) as never,
129
+ );
130
+
131
+ assert.deepEqual(captured.candidates, []);
132
+ });
133
+ });
134
+
135
+ describe("useRescueCandidates: candidate mapping from real hook output", () => {
136
+ const vipThread = makeThread({ messageId: "m1", senderTrust: "vip" });
137
+ const wellknownThread = makeThread({
138
+ messageId: "m2",
139
+ senderTrust: "wellknown",
140
+ });
141
+
142
+ test("maps messageId to candidate id", () => {
143
+ const { candidates } = renderHookWithResponse({ items: [vipThread] });
144
+ assert.equal(candidates[0].id, "m1");
145
+ });
146
+
147
+ test("maps fromName as senderName", () => {
148
+ const { candidates } = renderHookWithResponse({ items: [vipThread] });
149
+ assert.equal(candidates[0].senderName, "Anna de Vries");
150
+ });
151
+
152
+ test("falls back to fromEmail when fromName is absent", () => {
153
+ const thread = makeThread({
154
+ messageId: "m3",
155
+ senderTrust: "vip",
156
+ fromName: undefined,
157
+ });
158
+ const { candidates } = renderHookWithResponse({ items: [thread] });
159
+ assert.equal(candidates[0].senderName, "anna@example.nl");
160
+ });
161
+
162
+ test("falls back to 'Unknown sender' when both name and email are absent", () => {
163
+ const thread = makeThread({
164
+ messageId: "m4",
165
+ senderTrust: "vip",
166
+ fromName: undefined,
167
+ fromEmail: undefined,
168
+ });
169
+ const { candidates } = renderHookWithResponse({ items: [thread] });
170
+ assert.equal(candidates[0].senderName, "Unknown sender");
171
+ assert.equal(candidates[0].senderAddress, "");
172
+ });
173
+
174
+ test("falls back to '(no subject)' when subject is absent", () => {
175
+ const thread = makeThread({
176
+ messageId: "m5",
177
+ senderTrust: "vip",
178
+ subject: undefined,
179
+ });
180
+ const { candidates } = renderHookWithResponse({ items: [thread] });
181
+ assert.equal(candidates[0].subject, "(no subject)");
182
+ });
183
+
184
+ test("sets vip trustSubReason via rescueCandidateReason", () => {
185
+ const { candidates } = renderHookWithResponse({ items: [vipThread] });
186
+ assert.equal(candidates[0].trustSubReason, "A sender you know");
187
+ });
188
+
189
+ test("sets wellknown trustSubReason via rescueCandidateReason", () => {
190
+ const { candidates } = renderHookWithResponse({
191
+ items: [wellknownThread],
192
+ });
193
+ assert.equal(candidates[0].trustSubReason, "You've emailed them before");
194
+ });
195
+
196
+ test("sets the constant trustReason headline", () => {
197
+ const { candidates } = renderHookWithResponse({ items: [vipThread] });
198
+ assert.equal(candidates[0].trustReason, "We can verify this sender");
199
+ });
200
+
201
+ test("preserves senderTrust on the candidate", () => {
202
+ const { candidates } = renderHookWithResponse({
203
+ items: [vipThread, wellknownThread],
204
+ });
205
+ assert.equal(candidates[0].senderTrust, "vip");
206
+ assert.equal(candidates[1].senderTrust, "wellknown");
207
+ });
208
+
209
+ test("maps all response items to candidates in order", () => {
210
+ const { candidates } = renderHookWithResponse({
211
+ items: [vipThread, wellknownThread],
212
+ });
213
+ assert.equal(candidates.length, 2);
214
+ assert.equal(candidates[0].id, "m1");
215
+ assert.equal(candidates[1].id, "m2");
216
+ });
217
+ });
@@ -0,0 +1,48 @@
1
+ import { threadOperationsSearchThreadsOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { RescueCandidate } from "@remit/ui";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { rescueCandidateReason } from "@/lib/rescue-candidates";
5
+
6
+ const TRUST_REASON = "We can verify this sender";
7
+
8
+ /**
9
+ * Fetch rescue candidates from the backend for the given Junk mailbox.
10
+ *
11
+ * Filters by senderTrust=[vip,wellknown] and dkimMismatch=false. The
12
+ * dkimMismatch=false filter excludes senders with no authenticity signal
13
+ * (undefined), not just confirmed mismatches — this is intentional: it closes
14
+ * the impersonation path where a spoofed trusted-sender From with no DKIM
15
+ * would otherwise surface as a rescue candidate.
16
+ *
17
+ * Only fires when junkMailboxId is provided.
18
+ */
19
+ export function useRescueCandidates(junkMailboxId: string | undefined): {
20
+ candidates: RescueCandidate[];
21
+ } {
22
+ const { data } = useQuery({
23
+ ...threadOperationsSearchThreadsOptions({
24
+ path: { mailboxId: junkMailboxId ?? "" },
25
+ query: {
26
+ senderTrust: ["vip", "wellknown"],
27
+ dkimMismatch: false,
28
+ results: true,
29
+ limit: 500,
30
+ },
31
+ }),
32
+ enabled: !!junkMailboxId,
33
+ staleTime: 60_000,
34
+ });
35
+
36
+ const candidates: RescueCandidate[] = (data?.items ?? []).map((thread) => ({
37
+ id: thread.messageId,
38
+ senderName: thread.fromName || thread.fromEmail || "Unknown sender",
39
+ senderAddress: thread.fromEmail ?? "",
40
+ subject: thread.subject || "(no subject)",
41
+ snippet: thread.snippet ?? "",
42
+ trustReason: TRUST_REASON,
43
+ trustSubReason: rescueCandidateReason(thread.senderTrust),
44
+ senderTrust: thread.senderTrust,
45
+ }));
46
+
47
+ return { candidates };
48
+ }
@@ -0,0 +1,118 @@
1
+ import {
2
+ outboxDetailOperationsUpdateOutboxMessageMutation,
3
+ outboxOperationsCreateOutboxMessageMutation,
4
+ outboxOperationsListOutboxMessagesOptions,
5
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
6
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
7
+ import { useCallback, useRef, useState } from "react";
8
+
9
+ export type SaveStatus = "idle" | "saving" | "saved" | "error";
10
+
11
+ interface DraftData {
12
+ accountId: string;
13
+ toAddresses: string[];
14
+ ccAddresses?: string[];
15
+ bccAddresses?: string[];
16
+ subject?: string;
17
+ textBody?: string;
18
+ htmlBody?: string;
19
+ inReplyTo?: string;
20
+ references?: string[];
21
+ }
22
+
23
+ interface UseSaveDraftOptions {
24
+ outboxMessageId?: string;
25
+ onDraftCreated: (id: string) => void;
26
+ }
27
+
28
+ export const useSaveDraft = ({
29
+ outboxMessageId,
30
+ onDraftCreated,
31
+ }: UseSaveDraftOptions) => {
32
+ const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
33
+ const [saveError, setSaveError] = useState<unknown>(null);
34
+ const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
35
+ const queryClient = useQueryClient();
36
+
37
+ const createMutation = useMutation(
38
+ outboxOperationsCreateOutboxMessageMutation(),
39
+ );
40
+ const updateMutation = useMutation(
41
+ outboxDetailOperationsUpdateOutboxMessageMutation(),
42
+ );
43
+
44
+ const executeSave = useCallback(
45
+ async (data: DraftData) => {
46
+ setSaveStatus("saving");
47
+ setSaveError(null);
48
+
49
+ if (outboxMessageId) {
50
+ const result = await updateMutation.mutateAsync({
51
+ path: { outboxMessageId },
52
+ body: {
53
+ toAddresses: data.toAddresses,
54
+ ccAddresses: data.ccAddresses,
55
+ bccAddresses: data.bccAddresses,
56
+ subject: data.subject,
57
+ textBody: data.textBody,
58
+ htmlBody: data.htmlBody,
59
+ inReplyTo: data.inReplyTo,
60
+ references: data.references,
61
+ },
62
+ });
63
+ setSaveStatus("saved");
64
+ return result;
65
+ }
66
+
67
+ const result = await createMutation.mutateAsync({
68
+ body: {
69
+ ...data,
70
+ sendImmediately: false,
71
+ },
72
+ });
73
+ onDraftCreated(result.outboxMessageId);
74
+ setSaveStatus("saved");
75
+ queryClient.invalidateQueries({
76
+ queryKey: outboxOperationsListOutboxMessagesOptions().queryKey,
77
+ });
78
+ return result;
79
+ },
80
+ [
81
+ outboxMessageId,
82
+ createMutation,
83
+ updateMutation,
84
+ onDraftCreated,
85
+ queryClient,
86
+ ],
87
+ );
88
+
89
+ const saveDraft = useCallback(
90
+ (data: DraftData) => {
91
+ if (timerRef.current) clearTimeout(timerRef.current);
92
+ timerRef.current = setTimeout(() => {
93
+ // Keep the real error, not just a vague "error" status — the caller
94
+ // surfaces its detail in a banner. A fatal 5xx additionally escalates
95
+ // through the global MutationCache.onError sink.
96
+ executeSave(data).catch((error: unknown) => {
97
+ setSaveError(error);
98
+ setSaveStatus("error");
99
+ });
100
+ }, 2000);
101
+ },
102
+ [executeSave],
103
+ );
104
+
105
+ const saveImmediately = useCallback(
106
+ (data: DraftData) => {
107
+ if (timerRef.current) clearTimeout(timerRef.current);
108
+ return executeSave(data);
109
+ },
110
+ [executeSave],
111
+ );
112
+
113
+ const cancelAutoSave = useCallback(() => {
114
+ if (timerRef.current) clearTimeout(timerRef.current);
115
+ }, []);
116
+
117
+ return { saveStatus, saveError, saveDraft, saveImmediately, cancelAutoSave };
118
+ };
@@ -0,0 +1,75 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import { computeRange, nextFocusId } from "./useSelection.js";
4
+
5
+ const ids = ["a", "b", "c", "d", "e"];
6
+
7
+ describe("computeRange", () => {
8
+ test("forward range: anchor above target selects the inclusive slice", () => {
9
+ assert.deepStrictEqual(computeRange(ids, "b", "d"), ["b", "c", "d"]);
10
+ });
11
+
12
+ test("backward range: target above anchor selects the same inclusive slice", () => {
13
+ assert.deepStrictEqual(computeRange(ids, "d", "b"), ["b", "c", "d"]);
14
+ });
15
+
16
+ test("anchor equals target selects just that one id", () => {
17
+ assert.deepStrictEqual(computeRange(ids, "c", "c"), ["c"]);
18
+ });
19
+
20
+ test("no anchor selects just the target", () => {
21
+ assert.deepStrictEqual(computeRange(ids, undefined, "c"), ["c"]);
22
+ });
23
+
24
+ test("anchor not present in the list selects just the target", () => {
25
+ assert.deepStrictEqual(computeRange(ids, "zzz", "c"), ["c"]);
26
+ });
27
+
28
+ test("full span from first to last includes every id in order", () => {
29
+ assert.deepStrictEqual(computeRange(ids, "a", "e"), [
30
+ "a",
31
+ "b",
32
+ "c",
33
+ "d",
34
+ "e",
35
+ ]);
36
+ });
37
+
38
+ test("target not present in the list selects nothing", () => {
39
+ assert.deepStrictEqual(computeRange(ids, "b", "zzz"), []);
40
+ });
41
+ });
42
+
43
+ describe("nextFocusId", () => {
44
+ test("moves down one row", () => {
45
+ assert.strictEqual(nextFocusId(ids, "b", 1), "c");
46
+ });
47
+
48
+ test("moves up one row", () => {
49
+ assert.strictEqual(nextFocusId(ids, "c", -1), "b");
50
+ });
51
+
52
+ test("clamps at the bottom (no wrap)", () => {
53
+ assert.strictEqual(nextFocusId(ids, "e", 1), "e");
54
+ });
55
+
56
+ test("clamps at the top (no wrap)", () => {
57
+ assert.strictEqual(nextFocusId(ids, "a", -1), "a");
58
+ });
59
+
60
+ test("no focus + down starts at the first row", () => {
61
+ assert.strictEqual(nextFocusId(ids, undefined, 1), "a");
62
+ });
63
+
64
+ test("no focus + up starts at the last row", () => {
65
+ assert.strictEqual(nextFocusId(ids, undefined, -1), "e");
66
+ });
67
+
68
+ test("focus not in the list + down starts at the first row", () => {
69
+ assert.strictEqual(nextFocusId(ids, "zzz", 1), "a");
70
+ });
71
+
72
+ test("empty list returns undefined", () => {
73
+ assert.strictEqual(nextFocusId([], "a", 1), undefined);
74
+ });
75
+ });