@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,35 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import { normalizeSearchQuery } from "./search-query.js";
4
+
5
+ describe("normalizeSearchQuery", () => {
6
+ test("lowercases an ASCII query", () => {
7
+ assert.equal(normalizeSearchQuery("ALICE"), "alice");
8
+ });
9
+
10
+ test("lowercases a mixed-case query", () => {
11
+ assert.equal(
12
+ normalizeSearchQuery("Alice@Example.COM"),
13
+ "alice@example.com",
14
+ );
15
+ });
16
+
17
+ test("trims surrounding whitespace", () => {
18
+ assert.equal(normalizeSearchQuery(" Bob "), "bob");
19
+ });
20
+
21
+ test("returns an empty string for whitespace-only input", () => {
22
+ assert.equal(normalizeSearchQuery(" "), "");
23
+ });
24
+
25
+ test("leaves an already-lowercase query unchanged", () => {
26
+ assert.equal(normalizeSearchQuery("invoice 2026"), "invoice 2026");
27
+ });
28
+
29
+ test("handles non-ASCII letters via locale-aware lowercasing", () => {
30
+ // `toLocaleLowerCase` folds these where `String.prototype.toLowerCase`
31
+ // would also work for Latin-1, but we exercise it to lock the contract.
32
+ assert.equal(normalizeSearchQuery("ÄPPEL"), "äppel");
33
+ assert.equal(normalizeSearchQuery("STRAẞE"), "straße");
34
+ });
35
+ });
@@ -0,0 +1,11 @@
1
+ // Pre-query normalizer for the mail search bar. We keep the user's typed
2
+ // casing visible in the input and the "results for X" header, but the value
3
+ // sent to the backend (and used as the React Query cache key) is folded to
4
+ // lowercase so equivalent searches collide on the same cache entry and the
5
+ // server-side comparison is case-insensitive.
6
+ //
7
+ // `toLocaleLowerCase` (no locale arg) uses the host locale, which handles
8
+ // Unicode edge cases that ASCII `toLowerCase` mishandles — most notably the
9
+ // Turkish dotless I (`I` → `ı` / `İ` → `i`).
10
+ export const normalizeSearchQuery = (query: string): string =>
11
+ query.trim().toLocaleLowerCase();
@@ -0,0 +1,160 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import type {
4
+ RemitImapSemanticSearchResult,
5
+ RemitImapThreadMessageResponse,
6
+ } from "@remit/api-http-client/types.gen.ts";
7
+ import {
8
+ matchedChunkLabel,
9
+ relatedSearchResults,
10
+ semanticToSearchResult,
11
+ threadToSearchResult,
12
+ } from "./search-result.js";
13
+
14
+ function hit(
15
+ overrides: Partial<RemitImapSemanticSearchResult> &
16
+ Pick<RemitImapSemanticSearchResult, "messageId" | "threadId" | "score">,
17
+ ): RemitImapSemanticSearchResult {
18
+ return {
19
+ matchedChunkType: "body",
20
+ mailboxIds: [],
21
+ ...overrides,
22
+ };
23
+ }
24
+
25
+ describe("semanticToSearchResult", () => {
26
+ test("maps display fields and converts epoch seconds to ms", () => {
27
+ const result = semanticToSearchResult(
28
+ hit({
29
+ messageId: "m1",
30
+ threadId: "t1",
31
+ score: 0.9,
32
+ fromName: "Stripe",
33
+ subject: "Invoice ready",
34
+ sentDate: 1_700_000_000,
35
+ }),
36
+ );
37
+ assert.strictEqual(result.id, "m1");
38
+ assert.strictEqual(result.sender, "Stripe");
39
+ assert.strictEqual(result.subject, "Invoice ready");
40
+ assert.notStrictEqual(result.date, "");
41
+ });
42
+
43
+ test("falls back when denormalized display fields are absent", () => {
44
+ const result = semanticToSearchResult(
45
+ hit({ messageId: "m2", threadId: "t2", score: 0.5 }),
46
+ );
47
+ assert.strictEqual(result.sender, "Unknown");
48
+ assert.strictEqual(result.subject, "(No subject)");
49
+ assert.strictEqual(result.date, "");
50
+ });
51
+
52
+ // The bug: without thread + mailbox the brief could only look the hit up by
53
+ // messageId in its loaded list, where a related hit isn't found — so it must
54
+ // carry both so the conversation opens directly.
55
+ test("carries the thread and first mailbox so the hit opens directly", () => {
56
+ const result = semanticToSearchResult(
57
+ hit({
58
+ messageId: "m3",
59
+ threadId: "t3",
60
+ score: 0.7,
61
+ mailboxIds: ["mb-inbox", "mb-other"],
62
+ }),
63
+ );
64
+ assert.strictEqual(result.threadId, "t3");
65
+ assert.strictEqual(result.mailboxId, "mb-inbox");
66
+ });
67
+
68
+ test("omits mailboxId when the hit carries no mailbox", () => {
69
+ const result = semanticToSearchResult(
70
+ hit({ messageId: "m4", threadId: "t4", score: 0.3, mailboxIds: [] }),
71
+ );
72
+ assert.strictEqual(result.threadId, "t4");
73
+ assert.strictEqual(result.mailboxId, undefined);
74
+ });
75
+
76
+ test("carries the matched-chunk label and score so the row can show why it matched", () => {
77
+ const result = semanticToSearchResult(
78
+ hit({
79
+ messageId: "m5",
80
+ threadId: "t5",
81
+ score: 0.42,
82
+ matchedChunkType: "attachment",
83
+ }),
84
+ );
85
+ assert.strictEqual(result.matchedChunkLabel, "attachment");
86
+ assert.strictEqual(result.score, 0.42);
87
+ });
88
+ });
89
+
90
+ describe("matchedChunkLabel", () => {
91
+ test("labels every known chunk type", () => {
92
+ assert.strictEqual(matchedChunkLabel("body"), "body");
93
+ assert.strictEqual(matchedChunkLabel("subject"), "subject");
94
+ assert.strictEqual(matchedChunkLabel("sender"), "sender");
95
+ assert.strictEqual(matchedChunkLabel("recipient"), "recipient");
96
+ assert.strictEqual(matchedChunkLabel("attachment"), "attachment");
97
+ assert.strictEqual(matchedChunkLabel("entities"), "entities");
98
+ });
99
+ });
100
+
101
+ describe("threadToSearchResult", () => {
102
+ test("carries thread and mailbox so a literal hit opens directly", () => {
103
+ const result = threadToSearchResult({
104
+ messageId: "m1",
105
+ threadId: "t1",
106
+ mailboxId: "mb1",
107
+ subject: "Hi",
108
+ isRead: true,
109
+ sentDate: 1_700_000_000_000,
110
+ } as RemitImapThreadMessageResponse);
111
+ assert.strictEqual(result.id, "m1");
112
+ assert.strictEqual(result.threadId, "t1");
113
+ assert.strictEqual(result.mailboxId, "mb1");
114
+ });
115
+ });
116
+
117
+ describe("relatedSearchResults", () => {
118
+ test("orders by score descending", () => {
119
+ const results = relatedSearchResults(
120
+ [
121
+ hit({ messageId: "m1", threadId: "t1", score: 0.2 }),
122
+ hit({ messageId: "m2", threadId: "t2", score: 0.9 }),
123
+ hit({ messageId: "m3", threadId: "t3", score: 0.5 }),
124
+ ],
125
+ [],
126
+ );
127
+ assert.deepStrictEqual(
128
+ results.map((r) => r.id),
129
+ ["m2", "m3", "m1"],
130
+ );
131
+ });
132
+
133
+ test("excludes threads already shown under Top matches", () => {
134
+ const results = relatedSearchResults(
135
+ [
136
+ hit({ messageId: "m1", threadId: "shared", score: 0.9 }),
137
+ hit({ messageId: "m2", threadId: "t2", score: 0.5 }),
138
+ ],
139
+ ["shared"],
140
+ );
141
+ assert.deepStrictEqual(
142
+ results.map((r) => r.id),
143
+ ["m2"],
144
+ );
145
+ });
146
+
147
+ test("collapses multiple hits in the same thread to one row", () => {
148
+ const results = relatedSearchResults(
149
+ [
150
+ hit({ messageId: "m1", threadId: "t1", score: 0.9 }),
151
+ hit({ messageId: "m2", threadId: "t1", score: 0.4 }),
152
+ ],
153
+ [],
154
+ );
155
+ assert.deepStrictEqual(
156
+ results.map((r) => r.id),
157
+ ["m1"],
158
+ );
159
+ });
160
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Map the row shapes the app carries onto the kit `SearchResult` the search
3
+ * results render. Two feed the literal "Top matches" section (per-mailbox
4
+ * `RemitImapThreadMessageResponse` and the brief/flagged `ThreadRowData`); the
5
+ * third (`RemitImapSemanticSearchResult`) feeds the semantic "Related" section.
6
+ */
7
+ import type {
8
+ RemitImapSemanticSearchChunkType,
9
+ RemitImapSemanticSearchResult,
10
+ RemitImapThreadMessageResponse,
11
+ } from "@remit/api-http-client/types.gen.ts";
12
+ import type { SearchResult, ThreadRowData } from "@remit/ui";
13
+ import { formatEmailDate } from "./format.js";
14
+
15
+ /**
16
+ * Plain-language label for the `matched: …` chip, so the user understands why
17
+ * a "Related" hit matched (#428). Falls back to the raw chunk type for any
18
+ * value the backend adds later that the client doesn't know about yet.
19
+ */
20
+ const MATCHED_CHUNK_LABELS: Record<RemitImapSemanticSearchChunkType, string> = {
21
+ sender: "sender",
22
+ recipient: "recipient",
23
+ subject: "subject",
24
+ attachment: "attachment",
25
+ body: "body",
26
+ entities: "entities",
27
+ };
28
+
29
+ export function matchedChunkLabel(
30
+ chunkType: RemitImapSemanticSearchChunkType,
31
+ ): string {
32
+ return MATCHED_CHUNK_LABELS[chunkType] ?? chunkType;
33
+ }
34
+
35
+ export function threadToSearchResult(
36
+ thread: RemitImapThreadMessageResponse,
37
+ ): SearchResult {
38
+ return {
39
+ id: thread.messageId,
40
+ sender: thread.fromName ?? thread.fromEmail ?? "Unknown",
41
+ subject: thread.subject ?? "(No subject)",
42
+ snippet: thread.snippet ?? "",
43
+ date: formatEmailDate(thread.sentDate),
44
+ unread: !thread.isRead,
45
+ flagged: thread.hasStars === true,
46
+ threadId: thread.threadId,
47
+ mailboxId: thread.mailboxId,
48
+ };
49
+ }
50
+
51
+ export function rowToSearchResult(row: ThreadRowData): SearchResult {
52
+ return {
53
+ id: row.id,
54
+ sender: row.fromName,
55
+ subject: row.subject,
56
+ snippet: row.snippet,
57
+ date: row.timeLabel,
58
+ unread: !row.isRead,
59
+ flagged: row.starred === true,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Map a semantic hit onto a `SearchResult`. Display fields are denormalized at
65
+ * index time and absent for older entries, so each falls back. Read/star state
66
+ * isn't carried by the semantic index, so the row renders neutral. `sentDate` is
67
+ * epoch seconds; `formatEmailDate` takes epoch ms.
68
+ */
69
+ export function semanticToSearchResult(
70
+ hit: RemitImapSemanticSearchResult,
71
+ ): SearchResult {
72
+ return {
73
+ id: hit.messageId,
74
+ sender: hit.fromName ?? "Unknown",
75
+ subject: hit.subject ?? "(No subject)",
76
+ snippet: "",
77
+ date: hit.sentDate != null ? formatEmailDate(hit.sentDate * 1000) : "",
78
+ threadId: hit.threadId,
79
+ matchedChunkLabel: matchedChunkLabel(hit.matchedChunkType),
80
+ score: hit.score,
81
+ ...(hit.mailboxIds[0] != null ? { mailboxId: hit.mailboxIds[0] } : {}),
82
+ };
83
+ }
84
+
85
+ /**
86
+ * Build the semantic "Related" section: order by score (most relevant first),
87
+ * collapse to one row per thread, and drop any thread already shown under the
88
+ * literal "Top matches" — literal precedence, so a thread never appears twice.
89
+ */
90
+ export function relatedSearchResults(
91
+ hits: RemitImapSemanticSearchResult[],
92
+ literalThreadIds: Iterable<string>,
93
+ ): SearchResult[] {
94
+ const seenThreadIds = new Set<string>(literalThreadIds);
95
+ const results: SearchResult[] = [];
96
+ for (const hit of [...hits].sort((a, b) => b.score - a.score)) {
97
+ if (seenThreadIds.has(hit.threadId)) continue;
98
+ seenThreadIds.add(hit.threadId);
99
+ results.push(semanticToSearchResult(hit));
100
+ }
101
+ return results;
102
+ }
@@ -0,0 +1,34 @@
1
+ import { z } from "zod";
2
+
3
+ export const paginationSchema = z.object({
4
+ page: z.number().int().positive().default(1),
5
+ limit: z.number().int().min(10).max(100).default(50),
6
+ cursor: z.string().optional(),
7
+ });
8
+
9
+ export const mailListSearchSchema = z.object({
10
+ filter: z.enum(["all", "unread", "starred", "attachments"]).default("all"),
11
+ sort: z.enum(["date", "sender", "subject"]).default("date"),
12
+ order: z.enum(["asc", "desc"]).default("desc"),
13
+
14
+ ...paginationSchema.shape,
15
+
16
+ dialog: z.enum(["compose", "move", "delete", "settings"]).optional(),
17
+
18
+ selectedThreadId: z.string().optional(),
19
+ selectedMessageId: z.string().optional(),
20
+
21
+ q: z.string().optional(),
22
+ });
23
+
24
+ export type MailListSearch = z.infer<typeof mailListSearchSchema>;
25
+
26
+ export const threadViewSearchSchema = z.object({
27
+ expandedMessageId: z.string().optional(),
28
+ expandAll: z.boolean().default(false),
29
+
30
+ dialog: z.enum(["reply", "reply-all", "forward", "delete"]).optional(),
31
+ replyToMessageId: z.string().optional(),
32
+ });
33
+
34
+ export type ThreadViewSearch = z.infer<typeof threadViewSearchSchema>;
@@ -0,0 +1,112 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type {
4
+ RemitImapAccountResponse,
5
+ RemitImapMailboxResponse,
6
+ } from "@remit/api-http-client/types.gen.ts";
7
+ import {
8
+ buildAccountNameIndex,
9
+ buildMailboxNameIndex,
10
+ } from "./search-token-index.js";
11
+
12
+ const account = (
13
+ overrides: Partial<RemitImapAccountResponse> &
14
+ Pick<RemitImapAccountResponse, "accountId" | "email">,
15
+ ): RemitImapAccountResponse =>
16
+ ({
17
+ accountConfigId: "cfg-1",
18
+ username: "user",
19
+ imapHost: "imap.example.com",
20
+ imapPort: 993,
21
+ imapTls: true,
22
+ imapStartTls: false,
23
+ smtpHost: "smtp.example.com",
24
+ smtpPort: 587,
25
+ smtpTls: false,
26
+ smtpStartTls: true,
27
+ smtpUsername: "user",
28
+ isActive: true,
29
+ createdAt: 1000,
30
+ updatedAt: 1000,
31
+ ...overrides,
32
+ }) as RemitImapAccountResponse;
33
+
34
+ const mailbox = (
35
+ overrides: Partial<RemitImapMailboxResponse> &
36
+ Pick<RemitImapMailboxResponse, "mailboxId" | "fullPath">,
37
+ ): RemitImapMailboxResponse =>
38
+ ({
39
+ accountId: "account-1",
40
+ namespaceType: "personal",
41
+ namespacePrefix: "",
42
+ hierarchyDelimiter: "/",
43
+ messageCount: 0,
44
+ unseenCount: 0,
45
+ deletedCount: 0,
46
+ lastSyncUid: 0,
47
+ highWaterMarkUid: 0,
48
+ lastMessageSyncAt: 0,
49
+ createdAt: 1000,
50
+ updatedAt: 1000,
51
+ ...overrides,
52
+ }) as RemitImapMailboxResponse;
53
+
54
+ describe("buildAccountNameIndex", () => {
55
+ it("indexes by local-part and full email, lower-cased", () => {
56
+ const index = buildAccountNameIndex([
57
+ account({ accountId: "acct-1", email: "Work@Company.com" }),
58
+ ]);
59
+ assert.equal(index.get("work"), "acct-1");
60
+ assert.equal(index.get("work@company.com"), "acct-1");
61
+ });
62
+
63
+ it("first account wins a local-part collision", () => {
64
+ const index = buildAccountNameIndex([
65
+ account({ accountId: "acct-1", email: "work@company.com" }),
66
+ account({ accountId: "acct-2", email: "work@other.com" }),
67
+ ]);
68
+ assert.equal(index.get("work"), "acct-1");
69
+ });
70
+
71
+ it("empty accounts yields an empty index", () => {
72
+ assert.deepEqual(buildAccountNameIndex([]), new Map());
73
+ });
74
+ });
75
+
76
+ describe("buildMailboxNameIndex", () => {
77
+ it("indexes by full path and last segment, lower-cased", () => {
78
+ const index = buildMailboxNameIndex([
79
+ [mailbox({ mailboxId: "mb-1", fullPath: "INBOX/Archive" })],
80
+ ]);
81
+ assert.equal(index.get("inbox/archive"), "mb-1");
82
+ assert.equal(index.get("archive"), "mb-1");
83
+ });
84
+
85
+ it("merges mailbox lists across multiple accounts", () => {
86
+ const index = buildMailboxNameIndex([
87
+ [mailbox({ mailboxId: "mb-1", fullPath: "Archive" })],
88
+ [mailbox({ mailboxId: "mb-2", fullPath: "Sent" })],
89
+ ]);
90
+ assert.equal(index.get("archive"), "mb-1");
91
+ assert.equal(index.get("sent"), "mb-2");
92
+ });
93
+
94
+ it("first mailbox wins a name collision across accounts", () => {
95
+ const index = buildMailboxNameIndex([
96
+ [mailbox({ mailboxId: "mb-1", fullPath: "Archive" })],
97
+ [mailbox({ mailboxId: "mb-2", fullPath: "Archive" })],
98
+ ]);
99
+ assert.equal(index.get("archive"), "mb-1");
100
+ });
101
+
102
+ it("skips mailboxes with no fullPath", () => {
103
+ const index = buildMailboxNameIndex([
104
+ [mailbox({ mailboxId: "mb-1", fullPath: "" })],
105
+ ]);
106
+ assert.equal(index.size, 0);
107
+ });
108
+
109
+ it("empty input yields an empty index", () => {
110
+ assert.deepEqual(buildMailboxNameIndex([]), new Map());
111
+ });
112
+ });
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Builds the name -> id indexes `parseSearchTokens` uses to resolve `in:` and
3
+ * `account:` tokens (#428 follow-up, see doc/design/flows/06-search.md). Pure
4
+ * functions over already-loaded data; the caller owns fetching accounts and
5
+ * mailboxes (`useMailboxNameIndex` fans out the mailbox-list queries).
6
+ */
7
+ import type {
8
+ RemitImapAccountResponse,
9
+ RemitImapMailboxResponse,
10
+ } from "@remit/api-http-client/types.gen.ts";
11
+
12
+ /**
13
+ * Account email (and its local-part before `@`) -> accountId, lower-cased.
14
+ * `account:work` matches an account whose email starts with `work@`, mirroring
15
+ * the label the nav sidebar and daily brief account chips already show
16
+ * (`account.email.split("@")[0]`). The first account wins a collision.
17
+ */
18
+ export function buildAccountNameIndex(
19
+ accounts: RemitImapAccountResponse[],
20
+ ): Map<string, string> {
21
+ const index = new Map<string, string>();
22
+ for (const account of accounts) {
23
+ const email = account.email.toLowerCase();
24
+ const localPart = email.split("@")[0];
25
+ if (localPart && !index.has(localPart)) {
26
+ index.set(localPart, account.accountId);
27
+ }
28
+ if (!index.has(email)) index.set(email, account.accountId);
29
+ }
30
+ return index;
31
+ }
32
+
33
+ /**
34
+ * Mailbox full path (and its last path segment) -> mailboxId, lower-cased, so
35
+ * `in:archive` matches a mailbox at `INBOX/Archive` as well as one literally
36
+ * named `Archive`. Takes one mailbox list per account (as returned by the
37
+ * per-account mailbox-list fan-out) and merges them into one index; the first
38
+ * mailbox wins a name collision across accounts.
39
+ */
40
+ export function buildMailboxNameIndex(
41
+ mailboxesByAccount: RemitImapMailboxResponse[][],
42
+ ): Map<string, string> {
43
+ const index = new Map<string, string>();
44
+ for (const mailboxes of mailboxesByAccount) {
45
+ for (const mailbox of mailboxes) {
46
+ const fullPath = mailbox.fullPath?.toLowerCase();
47
+ if (!fullPath) continue;
48
+ const lastSegment = fullPath.split("/").pop();
49
+ if (!index.has(fullPath)) index.set(fullPath, mailbox.mailboxId);
50
+ if (lastSegment && !index.has(lastSegment)) {
51
+ index.set(lastSegment, mailbox.mailboxId);
52
+ }
53
+ }
54
+ }
55
+ return index;
56
+ }