@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,226 @@
1
+ import {
2
+ mailboxOperationsListMailboxesQueryKey,
3
+ messageBulkOperationsDeleteMessagesMutation,
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 UseDeleteMessagesOptions {
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 DeleteContext {
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
+ * Exported so the optimistic-removal math can be exercised without rendering
58
+ * React. Other identity is preserved — only items whose `messageId` is in
59
+ * the set are removed.
60
+ */
61
+ export const removeMessagesFromItems = (
62
+ items: RemitImapThreadMessageResponse[],
63
+ messageIds: Set<string>,
64
+ ): RemitImapThreadMessageResponse[] =>
65
+ items.filter((item) => !messageIds.has(item.messageId));
66
+
67
+ /**
68
+ * Pure helper: drop soft-deleted rows from a flattened thread list.
69
+ *
70
+ * Belt-and-braces guard against #212. The backend already excludes
71
+ * `isDeleted: true` rows from the inbox listing, but if a regression slips
72
+ * back in (or an eventual-consistency window briefly returns a soft-deleted
73
+ * row) the UI must not show it.
74
+ */
75
+ export const dropDeletedThreads = (
76
+ items: RemitImapThreadMessageResponse[],
77
+ ): RemitImapThreadMessageResponse[] =>
78
+ items.filter((item) => item.isDeleted !== true);
79
+
80
+ export const useDeleteMessages = ({
81
+ mailboxId,
82
+ threadId,
83
+ accountId,
84
+ onAfterOptimisticRemove,
85
+ }: UseDeleteMessagesOptions) => {
86
+ const queryClient = useQueryClient();
87
+ const { pushError } = useErrorBanners();
88
+
89
+ const { mutate, isPending } = useMutation({
90
+ ...messageBulkOperationsDeleteMessagesMutation(),
91
+ onMutate: async (variables): Promise<DeleteContext> => {
92
+ const messageIds = new Set(variables.body.messageIds ?? []);
93
+
94
+ const threadMessagesPrefix = threadId
95
+ ? threadDetailOperationsListThreadMessagesQueryKey({
96
+ path: { threadId },
97
+ })
98
+ : [];
99
+ const threadsListPrefix = threadOperationsListThreadsQueryKey({
100
+ path: { mailboxId },
101
+ });
102
+ const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
103
+ path: { mailboxId },
104
+ });
105
+
106
+ await Promise.all([
107
+ queryClient.cancelQueries({ queryKey: threadMessagesPrefix }),
108
+ queryClient.cancelQueries({ queryKey: threadsListPrefix }),
109
+ queryClient.cancelQueries({ queryKey: threadsSearchPrefix }),
110
+ ]);
111
+
112
+ const previousThreadMessages = threadId
113
+ ? queryClient
114
+ .getQueriesData<ThreadMessagesData>({
115
+ queryKey: threadMessagesPrefix,
116
+ })
117
+ .filter(
118
+ (entry): entry is [readonly unknown[], ThreadMessagesData] =>
119
+ entry[1] !== undefined,
120
+ )
121
+ .map(([queryKey, data]) => ({ queryKey, data }))
122
+ : [];
123
+
124
+ const previousThreadsList = queryClient
125
+ .getQueriesData<ThreadsListData>({ queryKey: threadsListPrefix })
126
+ .concat(
127
+ queryClient.getQueriesData<ThreadsListData>({
128
+ queryKey: threadsSearchPrefix,
129
+ }),
130
+ )
131
+ .filter(
132
+ (entry): entry is [readonly unknown[], ThreadsListData] =>
133
+ entry[1] !== undefined,
134
+ )
135
+ .map(([queryKey, data]) => ({ queryKey, data }));
136
+
137
+ if (threadId) {
138
+ queryClient.setQueriesData<ThreadMessagesData>(
139
+ { queryKey: threadMessagesPrefix },
140
+ (old) => {
141
+ if (!old) return old;
142
+ return {
143
+ ...old,
144
+ items: removeMessagesFromItems(old.items, messageIds),
145
+ };
146
+ },
147
+ );
148
+ }
149
+
150
+ const patchListData = (old: ThreadsListData | undefined) => {
151
+ if (!old) return old;
152
+ return {
153
+ ...old,
154
+ pages: old.pages.map((page) => ({
155
+ ...page,
156
+ items: removeMessagesFromItems(page.items, messageIds),
157
+ })),
158
+ };
159
+ };
160
+
161
+ queryClient.setQueriesData<ThreadsListData>(
162
+ { queryKey: threadsListPrefix },
163
+ patchListData,
164
+ );
165
+ queryClient.setQueriesData<ThreadsListData>(
166
+ { queryKey: threadsSearchPrefix },
167
+ patchListData,
168
+ );
169
+
170
+ onAfterOptimisticRemove?.(Array.from(messageIds));
171
+
172
+ return {
173
+ threadMessagesPrefix,
174
+ threadsListPrefix,
175
+ threadsSearchPrefix,
176
+ previousThreadMessages,
177
+ previousThreadsList,
178
+ };
179
+ },
180
+ onError: (err, vars, context) => {
181
+ if (context) {
182
+ for (const entry of context.previousThreadMessages) {
183
+ queryClient.setQueryData(entry.queryKey, entry.data);
184
+ }
185
+ for (const entry of context.previousThreadsList) {
186
+ queryClient.setQueryData(entry.queryKey, entry.data);
187
+ }
188
+ }
189
+ const count = vars.body.messageIds?.length ?? 0;
190
+ pushError({
191
+ title:
192
+ count > 1
193
+ ? `Couldn't delete ${count} messages`
194
+ : "Couldn't delete this message",
195
+ detail: formatErrorDetail(err),
196
+ });
197
+ },
198
+ onSettled: (_data, _err, _vars, context) => {
199
+ if (!context) return;
200
+ if (threadId) {
201
+ queryClient.invalidateQueries({
202
+ queryKey: context.threadMessagesPrefix,
203
+ });
204
+ }
205
+ queryClient.invalidateQueries({ queryKey: context.threadsListPrefix });
206
+ queryClient.invalidateQueries({ queryKey: context.threadsSearchPrefix });
207
+ if (accountId) {
208
+ queryClient.invalidateQueries({
209
+ queryKey: mailboxOperationsListMailboxesQueryKey({
210
+ path: { accountId },
211
+ }),
212
+ });
213
+ }
214
+ },
215
+ });
216
+
217
+ const deleteMessages = useCallback(
218
+ (messageIds: string[]) => {
219
+ if (messageIds.length === 0) return;
220
+ mutate({ body: { messageIds } });
221
+ },
222
+ [mutate],
223
+ );
224
+
225
+ return { deleteMessages, isPending };
226
+ };
@@ -0,0 +1,112 @@
1
+ import {
2
+ filterDetailOperationsDeleteFilterMutation,
3
+ filterOperationsCreateFilterMutation,
4
+ filterOperationsListFiltersOptions,
5
+ filterOperationsListFiltersQueryKey,
6
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
7
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
8
+ import { useCallback } from "react";
9
+ import {
10
+ buildCreateFilterInput,
11
+ type OrganizeDraft,
12
+ type OrganizeScope,
13
+ } from "@/lib/organize/organize-model";
14
+
15
+ /**
16
+ * List the account's standing filters (Standing + Temporary). Expired
17
+ * Temporary filters stay in the list — they are shown distinctly, never hidden
18
+ * (RFC 034 Decision 1.2).
19
+ */
20
+ export const useFilterList = (accountId: string | undefined) => {
21
+ const query = useQuery({
22
+ ...filterOperationsListFiltersOptions({
23
+ path: { accountId: accountId ?? "" },
24
+ }),
25
+ enabled: !!accountId,
26
+ });
27
+
28
+ return {
29
+ filters: query.data?.items ?? [],
30
+ isPending: query.isPending,
31
+ isError: query.isError,
32
+ error: query.error,
33
+ refetch: query.refetch,
34
+ };
35
+ };
36
+
37
+ /**
38
+ * Create the standing (`"these and new mail like this"`) or temporary
39
+ * (`"until <date>"`) filter for an organize draft. The two one-time scopes
40
+ * never create a filter (RFC 034 recap) and are not handled here.
41
+ */
42
+ export const useCreateFilter = (accountId: string | undefined) => {
43
+ const queryClient = useQueryClient();
44
+ const mutation = useMutation({
45
+ ...filterOperationsCreateFilterMutation(),
46
+ onSuccess: () => {
47
+ if (!accountId) return;
48
+ queryClient.invalidateQueries({
49
+ queryKey: filterOperationsListFiltersQueryKey({
50
+ path: { accountId },
51
+ }),
52
+ });
53
+ },
54
+ });
55
+ const { mutate } = mutation;
56
+
57
+ const createFilter = useCallback(
58
+ (
59
+ draft: OrganizeDraft,
60
+ scope: Extract<OrganizeScope, "standing" | "temporary">,
61
+ name: string,
62
+ ) => {
63
+ if (!accountId) return;
64
+ mutate({
65
+ path: { accountId },
66
+ body: buildCreateFilterInput(draft, scope, name),
67
+ });
68
+ },
69
+ [accountId, mutate],
70
+ );
71
+
72
+ return {
73
+ createFilter,
74
+ isPending: mutation.isPending,
75
+ isSuccess: mutation.isSuccess,
76
+ isError: mutation.isError,
77
+ error: mutation.error,
78
+ reset: mutation.reset,
79
+ };
80
+ };
81
+
82
+ export const useDeleteFilter = (accountId: string | undefined) => {
83
+ const queryClient = useQueryClient();
84
+ const mutation = useMutation({
85
+ ...filterDetailOperationsDeleteFilterMutation(),
86
+ onSuccess: () => {
87
+ if (!accountId) return;
88
+ queryClient.invalidateQueries({
89
+ queryKey: filterOperationsListFiltersQueryKey({
90
+ path: { accountId },
91
+ }),
92
+ });
93
+ },
94
+ });
95
+ const { mutate } = mutation;
96
+
97
+ const deleteFilter = useCallback(
98
+ (filterId: string) => {
99
+ if (!accountId) return;
100
+ mutate({ path: { accountId, filterId } });
101
+ },
102
+ [accountId, mutate],
103
+ );
104
+
105
+ return {
106
+ deleteFilter,
107
+ isPending: mutation.isPending,
108
+ deletingFilterId: mutation.isPending
109
+ ? mutation.variables?.path.filterId
110
+ : undefined,
111
+ };
112
+ };
@@ -0,0 +1,269 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import type {
4
+ RemitImapAddressResponse,
5
+ RemitImapThreadMessageResponse,
6
+ } from "@remit/api-http-client/types.gen.ts";
7
+ import {
8
+ buildAuthenticityIntel,
9
+ buildSenderIntel,
10
+ } from "./useIntelligenceData.js";
11
+
12
+ // Jan 2025 timestamp — far enough in the past to not be "today"
13
+ const JAN_2025_MS = new Date("2025-01-15T12:00:00Z").getTime();
14
+
15
+ function makeThread(
16
+ overrides: Partial<RemitImapThreadMessageResponse> = {},
17
+ ): RemitImapThreadMessageResponse {
18
+ return {
19
+ threadMessageId: "tm-1",
20
+ threadId: "t-1",
21
+ messageId: "m-1",
22
+ accountConfigId: "ac-1",
23
+ mailboxId: "mb-1",
24
+ fromEmail: "alice@example.com",
25
+ fromName: "Alice",
26
+ subject: "Hello",
27
+ senderTrust: "unknown",
28
+ isRead: false,
29
+ hasAttachment: false,
30
+ hasStars: false,
31
+ sentDate: "2025-01-15T12:00:00Z",
32
+ snippet: "",
33
+ ...overrides,
34
+ } as RemitImapThreadMessageResponse;
35
+ }
36
+
37
+ function makeAddress(
38
+ overrides: Partial<RemitImapAddressResponse> = {},
39
+ ): RemitImapAddressResponse {
40
+ return {
41
+ addressId: "addr-1",
42
+ accountConfigId: "ac-1",
43
+ localPart: "alice",
44
+ domain: "example.com",
45
+ normalizedEmail: "alice@example.com",
46
+ createdAt: JAN_2025_MS,
47
+ updatedAt: JAN_2025_MS,
48
+ ...overrides,
49
+ } as RemitImapAddressResponse;
50
+ }
51
+
52
+ describe("buildSenderIntel", () => {
53
+ describe("counter wiring — counters present", () => {
54
+ test("surfaces inboundCount from address", () => {
55
+ const thread = makeThread();
56
+ const address = makeAddress({ inboundCount: 34 });
57
+ const result = buildSenderIntel(thread, address);
58
+ assert.equal(result.inboundCount, 34);
59
+ });
60
+
61
+ test("surfaces replyCount from address", () => {
62
+ const thread = makeThread();
63
+ const address = makeAddress({ inboundCount: 34, replyCount: 12 });
64
+ const result = buildSenderIntel(thread, address);
65
+ assert.equal(result.replyCount, 12);
66
+ });
67
+
68
+ test("surfaces both counters together", () => {
69
+ const thread = makeThread();
70
+ const address = makeAddress({ inboundCount: 34, replyCount: 12 });
71
+ const result = buildSenderIntel(thread, address);
72
+ assert.equal(result.inboundCount, 34);
73
+ assert.equal(result.replyCount, 12);
74
+ });
75
+ });
76
+
77
+ describe("suppression — counters absent", () => {
78
+ test("inboundCount is undefined when address has no inboundCount", () => {
79
+ const thread = makeThread();
80
+ const address = makeAddress(); // no inboundCount
81
+ const result = buildSenderIntel(thread, address);
82
+ // Must be undefined, not 0 — so SenderCard suppresses the engagement clause
83
+ assert.equal(result.inboundCount, undefined);
84
+ });
85
+
86
+ test("replyCount is undefined when address has no replyCount", () => {
87
+ const thread = makeThread();
88
+ const address = makeAddress({ inboundCount: 5 }); // no replyCount
89
+ const result = buildSenderIntel(thread, address);
90
+ assert.equal(result.replyCount, undefined);
91
+ });
92
+
93
+ test("both counters are undefined when address is undefined", () => {
94
+ const thread = makeThread();
95
+ const result = buildSenderIntel(thread, undefined);
96
+ assert.equal(result.inboundCount, undefined);
97
+ assert.equal(result.replyCount, undefined);
98
+ });
99
+
100
+ test("does not coerce absent inboundCount to 0", () => {
101
+ // Regression guard: the old code left counters as undefined so
102
+ // SenderCard could suppress "0 received · you've never replied".
103
+ // Ensure undefined stays undefined, never becomes 0.
104
+ const thread = makeThread();
105
+ const address = makeAddress(); // inboundCount absent
106
+ const result = buildSenderIntel(thread, address);
107
+ assert.notEqual(result.inboundCount, 0);
108
+ });
109
+ });
110
+
111
+ describe("basic sender fields", () => {
112
+ test("uses fromName when present", () => {
113
+ const thread = makeThread({
114
+ fromName: "Alice Smith",
115
+ fromEmail: "alice@example.com",
116
+ });
117
+ const result = buildSenderIntel(thread, undefined);
118
+ assert.equal(result.name, "Alice Smith");
119
+ });
120
+
121
+ test("falls back to fromEmail when fromName is absent", () => {
122
+ const thread = makeThread({
123
+ fromName: undefined,
124
+ fromEmail: "alice@example.com",
125
+ });
126
+ const result = buildSenderIntel(thread, undefined);
127
+ assert.equal(result.name, "alice@example.com");
128
+ });
129
+
130
+ test("falls back to 'Unknown' when both name and email are absent", () => {
131
+ const thread = makeThread({ fromName: undefined, fromEmail: undefined });
132
+ const result = buildSenderIntel(thread, undefined);
133
+ assert.equal(result.name, "Unknown");
134
+ });
135
+
136
+ test("passes senderTrust through", () => {
137
+ const thread = makeThread({ senderTrust: "vip" });
138
+ const result = buildSenderIntel(thread, undefined);
139
+ assert.equal(result.trust, "vip");
140
+ });
141
+
142
+ test("firstSeenLabel is 'unknown' when no address", () => {
143
+ const thread = makeThread();
144
+ const result = buildSenderIntel(thread, undefined);
145
+ assert.equal(result.firstSeenLabel, "unknown");
146
+ });
147
+
148
+ test("firstSeenLabel is formatted when address is present", () => {
149
+ const thread = makeThread();
150
+ const address = makeAddress({ createdAt: JAN_2025_MS });
151
+ const result = buildSenderIntel(thread, address);
152
+ // Should be something like "Jan 2025", not "unknown" or "today"
153
+ assert.notEqual(result.firstSeenLabel, "unknown");
154
+ assert.ok(result.firstSeenLabel.length > 0);
155
+ });
156
+ });
157
+
158
+ describe("addressUnverified flag", () => {
159
+ test("false for a normal address", () => {
160
+ const result = buildSenderIntel(
161
+ makeThread({ fromEmail: "alice@example.com" }),
162
+ undefined,
163
+ );
164
+ assert.equal(result.addressUnverified, false);
165
+ });
166
+
167
+ test("true when the domain has no dot (placeholder junk)", () => {
168
+ const result = buildSenderIntel(
169
+ makeThread({ fromEmail: "missing_mailbox@missing_domain" }),
170
+ undefined,
171
+ );
172
+ assert.equal(result.addressUnverified, true);
173
+ });
174
+
175
+ test("true when the address is missing", () => {
176
+ const result = buildSenderIntel(
177
+ makeThread({ fromEmail: undefined }),
178
+ undefined,
179
+ );
180
+ assert.equal(result.addressUnverified, true);
181
+ });
182
+ });
183
+ });
184
+
185
+ describe("buildAuthenticityIntel", () => {
186
+ test("caution when there is no authenticity signal", () => {
187
+ const thread = makeThread({ fromEmail: "alice@example.com" });
188
+ const result = buildAuthenticityIntel(thread, 0);
189
+ assert.equal(result.verdict, "caution");
190
+ assert.match(result.summary, /can't verify the sender/);
191
+ });
192
+
193
+ test("aligned when a signal is present and does not mismatch", () => {
194
+ const thread = makeThread({
195
+ fromEmail: "alice@example.com",
196
+ authenticity: {
197
+ fromDomain: "example.com",
198
+ dkimDomain: "example.com",
199
+ dkimMismatch: false,
200
+ },
201
+ } as Partial<RemitImapThreadMessageResponse>);
202
+ const result = buildAuthenticityIntel(thread, 0);
203
+ assert.equal(result.verdict, "aligned");
204
+ });
205
+
206
+ test("mismatch when the signing domain does not align", () => {
207
+ const thread = makeThread({
208
+ fromEmail: "security@your-bank.example",
209
+ fromName: "Your Bank",
210
+ authenticity: {
211
+ fromDomain: "your-bank.example",
212
+ dkimDomain: "mailer.evil.example",
213
+ dkimMismatch: true,
214
+ },
215
+ } as Partial<RemitImapThreadMessageResponse>);
216
+ const result = buildAuthenticityIntel(thread, 3);
217
+ assert.equal(result.verdict, "mismatch");
218
+ assert.equal(result.addressUnreadable, undefined);
219
+ assert.equal(result.similarCount, 3);
220
+ });
221
+
222
+ test("no user-facing copy names the verification mechanism", () => {
223
+ const verdicts = [
224
+ buildAuthenticityIntel(makeThread(), 0),
225
+ buildAuthenticityIntel(
226
+ makeThread({ fromEmail: "x@no-signal.example" }),
227
+ 0,
228
+ ),
229
+ buildAuthenticityIntel(
230
+ makeThread({
231
+ fromEmail: "a@bank.example",
232
+ authenticity: {
233
+ fromDomain: "bank.example",
234
+ dkimDomain: "evil.example",
235
+ dkimMismatch: true,
236
+ },
237
+ } as Partial<RemitImapThreadMessageResponse>),
238
+ 0,
239
+ ),
240
+ ];
241
+ for (const v of verdicts) {
242
+ assert.doesNotMatch(v.summary, /DKIM|SPF|DMARC/i);
243
+ }
244
+ });
245
+
246
+ describe("unparseable sender drives the red tier", () => {
247
+ test("mismatch + addressUnreadable when the domain has no dot", () => {
248
+ const thread = makeThread({
249
+ fromEmail: "missing_mailbox@missing_domain",
250
+ });
251
+ const result = buildAuthenticityIntel(thread, 0);
252
+ assert.equal(result.verdict, "mismatch");
253
+ assert.equal(result.addressUnreadable, true);
254
+ });
255
+
256
+ test("red tier even when a clean DKIM signal is present", () => {
257
+ const thread = makeThread({
258
+ fromEmail: "missing_mailbox@missing_domain",
259
+ authenticity: {
260
+ fromDomain: "missing_domain",
261
+ dkimMismatch: false,
262
+ },
263
+ } as Partial<RemitImapThreadMessageResponse>);
264
+ const result = buildAuthenticityIntel(thread, 0);
265
+ assert.equal(result.verdict, "mismatch");
266
+ assert.equal(result.addressUnreadable, true);
267
+ });
268
+ });
269
+ });