@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,366 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import type { ThreadRowData } from "@remit/ui";
4
+ import {
5
+ groupBriefSections,
6
+ matchesBriefSearch,
7
+ matchesSearchTokens,
8
+ } from "./brief.js";
9
+ import type { SearchToken } from "./search-tokens.js";
10
+
11
+ function row(
12
+ overrides: Partial<ThreadRowData> & Pick<ThreadRowData, "id">,
13
+ ): ThreadRowData {
14
+ return {
15
+ accountId: "acc_1",
16
+ fromName: "Sender",
17
+ fromEmail: "sender@example.com",
18
+ subject: "Subject",
19
+ snippet: "Snippet",
20
+ timeLabel: "09:00",
21
+ isRead: false,
22
+ hasAttachment: false,
23
+ starred: false,
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ describe("groupBriefSections", () => {
29
+ test("returns empty array when no rows", () => {
30
+ const sections = groupBriefSections([]);
31
+ assert.deepStrictEqual(sections, []);
32
+ });
33
+
34
+ // --- One section per category ---
35
+
36
+ test("personal goes to the personal section", () => {
37
+ const r = row({ id: "1", category: "personal" });
38
+ const sections = groupBriefSections([r]);
39
+ assert.strictEqual(sections.length, 1);
40
+ assert.strictEqual(sections[0].id, "personal");
41
+ assert.strictEqual(sections[0].label, "Personal");
42
+ });
43
+
44
+ test("transactional goes to the transactional section", () => {
45
+ const r = row({ id: "1", category: "transactional" });
46
+ const sections = groupBriefSections([r]);
47
+ assert.strictEqual(sections[0].id, "transactional");
48
+ assert.strictEqual(sections[0].label, "Transactional");
49
+ });
50
+
51
+ test("newsletter goes to the newsletter section", () => {
52
+ const r = row({ id: "1", category: "newsletter" });
53
+ const sections = groupBriefSections([r]);
54
+ assert.strictEqual(sections[0].id, "newsletter");
55
+ assert.strictEqual(sections[0].label, "Newsletter");
56
+ });
57
+
58
+ test("marketing goes to the marketing section", () => {
59
+ const r = row({ id: "1", category: "marketing" });
60
+ const sections = groupBriefSections([r]);
61
+ assert.strictEqual(sections[0].id, "marketing");
62
+ assert.strictEqual(sections[0].label, "Marketing");
63
+ });
64
+
65
+ test("social goes to the social section", () => {
66
+ const r = row({ id: "1", category: "social" });
67
+ const sections = groupBriefSections([r]);
68
+ assert.strictEqual(sections[0].id, "social");
69
+ assert.strictEqual(sections[0].label, "Social");
70
+ });
71
+
72
+ test("automated goes to the automated section", () => {
73
+ const r = row({ id: "1", category: "automated" });
74
+ const sections = groupBriefSections([r]);
75
+ assert.strictEqual(sections[0].id, "automated");
76
+ assert.strictEqual(sections[0].label, "Automated");
77
+ });
78
+
79
+ // --- Read state is not a routing signal ---
80
+
81
+ test("read and unread rows of a category share one section", () => {
82
+ const rows: ThreadRowData[] = [
83
+ row({ id: "1", isRead: false, category: "personal" }),
84
+ row({ id: "2", isRead: true, category: "personal" }),
85
+ ];
86
+ const sections = groupBriefSections(rows);
87
+ assert.strictEqual(sections.length, 1);
88
+ assert.strictEqual(sections[0].id, "personal");
89
+ assert.strictEqual(sections[0].threads.length, 2);
90
+ });
91
+
92
+ // --- Fallback to personal ---
93
+
94
+ test("missing category falls back to personal", () => {
95
+ const r = row({ id: "1" });
96
+ const sections = groupBriefSections([r]);
97
+ assert.strictEqual(sections.length, 1);
98
+ assert.strictEqual(sections[0].id, "personal");
99
+ });
100
+
101
+ // --- Starred is a row marker, not a section (Flagged lives in the nav) ---
102
+
103
+ test("a starred newsletter stays in the newsletter section", () => {
104
+ const r = row({ id: "1", starred: true, category: "newsletter" });
105
+ const sections = groupBriefSections([r]);
106
+ assert.strictEqual(sections.length, 1);
107
+ assert.strictEqual(sections[0].id, "newsletter");
108
+ });
109
+
110
+ test("a starred personal message stays in the personal section", () => {
111
+ const r = row({ id: "1", starred: true, category: "personal" });
112
+ const sections = groupBriefSections([r]);
113
+ assert.strictEqual(sections.length, 1);
114
+ assert.strictEqual(sections[0].id, "personal");
115
+ });
116
+
117
+ test("starred mail never produces a flagged section", () => {
118
+ const rows: ThreadRowData[] = [
119
+ row({ id: "p", category: "personal" }),
120
+ row({ id: "f", starred: true, category: "automated" }),
121
+ ];
122
+ const sections = groupBriefSections(rows);
123
+ assert.deepStrictEqual(
124
+ sections.map((s) => s.id),
125
+ ["personal", "automated"],
126
+ );
127
+ });
128
+
129
+ // --- Trust no longer sections ---
130
+
131
+ test("a vip newsletter stays in the newsletter section (trust does not section)", () => {
132
+ const r = row({ id: "1", trust: "vip", category: "newsletter" });
133
+ const sections = groupBriefSections([r]);
134
+ assert.strictEqual(sections.length, 1);
135
+ assert.strictEqual(sections[0].id, "newsletter");
136
+ });
137
+
138
+ test("a wellknown automated row stays in the automated section", () => {
139
+ const r = row({ id: "1", trust: "wellknown", category: "automated" });
140
+ const sections = groupBriefSections([r]);
141
+ assert.strictEqual(sections.length, 1);
142
+ assert.strictEqual(sections[0].id, "automated");
143
+ });
144
+
145
+ // --- Section order and omission ---
146
+
147
+ test("display order is: personal, transactional, newsletter, marketing, social, automated", () => {
148
+ const rows: ThreadRowData[] = [
149
+ row({ id: "auto", category: "automated" }),
150
+ row({ id: "social", category: "social" }),
151
+ row({ id: "mkt", category: "marketing" }),
152
+ row({ id: "news", category: "newsletter" }),
153
+ row({ id: "txn", category: "transactional" }),
154
+ row({ id: "pers", category: "personal" }),
155
+ row({ id: "star", starred: true, category: "automated" }),
156
+ ];
157
+ const sections = groupBriefSections(rows);
158
+ assert.deepStrictEqual(
159
+ sections.map((s) => s.id),
160
+ [
161
+ "personal",
162
+ "transactional",
163
+ "newsletter",
164
+ "marketing",
165
+ "social",
166
+ "automated",
167
+ ],
168
+ );
169
+ });
170
+
171
+ test("empty sections are omitted", () => {
172
+ const sections = groupBriefSections([row({ id: "1", category: "social" })]);
173
+ assert.deepStrictEqual(
174
+ sections.map((s) => s.id),
175
+ ["social"],
176
+ );
177
+ });
178
+
179
+ test("each row appears in exactly one section", () => {
180
+ const rows: ThreadRowData[] = [
181
+ row({ id: "1", category: "personal" }),
182
+ row({ id: "2", starred: true, category: "automated" }),
183
+ row({ id: "3", category: "automated" }),
184
+ row({ id: "4", category: "transactional" }),
185
+ row({ id: "5", starred: true, category: "newsletter" }),
186
+ row({ id: "6", category: "newsletter" }),
187
+ row({ id: "7", category: "marketing" }),
188
+ ];
189
+ const sections = groupBriefSections(rows);
190
+ const allIds = sections.flatMap((s) => s.threads.map((t) => t.id));
191
+ assert.strictEqual(allIds.length, rows.length);
192
+ const uniqueIds = new Set(allIds);
193
+ assert.strictEqual(uniqueIds.size, rows.length);
194
+ });
195
+ });
196
+
197
+ describe("matchesBriefSearch", () => {
198
+ const r = row({
199
+ id: "1",
200
+ fromName: "Alice Tan",
201
+ fromEmail: "alice@example.com",
202
+ subject: "Q3 roadmap",
203
+ snippet: "See the attached deck",
204
+ });
205
+
206
+ test("matches on fromName (case-insensitive)", () => {
207
+ assert.strictEqual(matchesBriefSearch(r, "alice"), true);
208
+ });
209
+
210
+ test("matches on fromEmail", () => {
211
+ assert.strictEqual(matchesBriefSearch(r, "alice@example"), true);
212
+ });
213
+
214
+ test("matches on subject", () => {
215
+ assert.strictEqual(matchesBriefSearch(r, "roadmap"), true);
216
+ });
217
+
218
+ test("matches on snippet", () => {
219
+ assert.strictEqual(matchesBriefSearch(r, "deck"), true);
220
+ });
221
+
222
+ test("returns false when query matches nothing", () => {
223
+ assert.strictEqual(matchesBriefSearch(r, "zyxwvuts"), false);
224
+ });
225
+ });
226
+
227
+ describe("matchesSearchTokens", () => {
228
+ const from = (value: string): SearchToken => ({
229
+ type: "from",
230
+ raw: `from:${value}`,
231
+ value,
232
+ });
233
+ const hasAttachment: SearchToken = {
234
+ type: "hasAttachment",
235
+ raw: "has:attachment",
236
+ };
237
+ const isUnread: SearchToken = { type: "isUnread", raw: "is:unread" };
238
+ const after = (epochSeconds: number): SearchToken => ({
239
+ type: "after",
240
+ raw: "after:x",
241
+ value: "x",
242
+ epochSeconds,
243
+ });
244
+ const before = (epochSeconds: number): SearchToken => ({
245
+ type: "before",
246
+ raw: "before:x",
247
+ value: "x",
248
+ epochSeconds,
249
+ });
250
+
251
+ test("returns true with no tokens", () => {
252
+ assert.strictEqual(matchesSearchTokens(row({ id: "1" }), []), true);
253
+ });
254
+
255
+ test("from: matches fromEmail or fromName, case-insensitively", () => {
256
+ const r = row({ id: "1", fromEmail: "alice@dhl.com", fromName: "DHL" });
257
+ assert.strictEqual(matchesSearchTokens(r, [from("DHL")]), true);
258
+ assert.strictEqual(matchesSearchTokens(r, [from("alice@dhl")]), true);
259
+ assert.strictEqual(matchesSearchTokens(r, [from("ups")]), false);
260
+ });
261
+
262
+ test("has:attachment requires hasAttachment true", () => {
263
+ assert.strictEqual(
264
+ matchesSearchTokens(row({ id: "1", hasAttachment: true }), [
265
+ hasAttachment,
266
+ ]),
267
+ true,
268
+ );
269
+ assert.strictEqual(
270
+ matchesSearchTokens(row({ id: "1", hasAttachment: false }), [
271
+ hasAttachment,
272
+ ]),
273
+ false,
274
+ );
275
+ });
276
+
277
+ test("is:unread requires isRead falsy", () => {
278
+ assert.strictEqual(
279
+ matchesSearchTokens(row({ id: "1", isRead: false }), [isUnread]),
280
+ true,
281
+ );
282
+ assert.strictEqual(
283
+ matchesSearchTokens(row({ id: "1", isRead: true }), [isUnread]),
284
+ false,
285
+ );
286
+ });
287
+
288
+ test("after:/before: compare against sentDate (ms)", () => {
289
+ const jan15 = row({
290
+ id: "1",
291
+ sentDate: Date.parse("2024-01-15T00:00:00Z"),
292
+ });
293
+ assert.strictEqual(
294
+ matchesSearchTokens(jan15, [
295
+ after(Date.parse("2024-01-01T00:00:00Z") / 1000),
296
+ ]),
297
+ true,
298
+ );
299
+ assert.strictEqual(
300
+ matchesSearchTokens(jan15, [
301
+ before(Date.parse("2024-01-01T00:00:00Z") / 1000),
302
+ ]),
303
+ false,
304
+ );
305
+ });
306
+
307
+ test("a date token never matches a row with no sentDate", () => {
308
+ const r = row({ id: "1" });
309
+ assert.strictEqual(matchesSearchTokens(r, [after(0)]), false);
310
+ assert.strictEqual(matchesSearchTokens(r, [before(0)]), false);
311
+ });
312
+
313
+ test("in: matches on mailboxId", () => {
314
+ const r = row({ id: "1", mailboxId: "mb-archive" });
315
+ const inToken: SearchToken = {
316
+ type: "in",
317
+ raw: "in:archive",
318
+ value: "archive",
319
+ mailboxId: "mb-archive",
320
+ };
321
+ assert.strictEqual(matchesSearchTokens(r, [inToken]), true);
322
+ assert.strictEqual(
323
+ matchesSearchTokens(row({ id: "2", mailboxId: "mb-inbox" }), [inToken]),
324
+ false,
325
+ );
326
+ });
327
+
328
+ test("account: matches on accountId", () => {
329
+ const r = row({ id: "1", accountId: "acc_work" });
330
+ const accountToken: SearchToken = {
331
+ type: "account",
332
+ raw: "account:work",
333
+ value: "work",
334
+ accountId: "acc_work",
335
+ };
336
+ assert.strictEqual(matchesSearchTokens(r, [accountToken]), true);
337
+ assert.strictEqual(
338
+ matchesSearchTokens(row({ id: "2", accountId: "acc_personal" }), [
339
+ accountToken,
340
+ ]),
341
+ false,
342
+ );
343
+ });
344
+
345
+ test("all tokens must match (AND)", () => {
346
+ const r = row({
347
+ id: "1",
348
+ fromEmail: "alice@dhl.com",
349
+ hasAttachment: true,
350
+ isRead: false,
351
+ });
352
+ assert.strictEqual(
353
+ matchesSearchTokens(r, [from("dhl"), hasAttachment, isUnread]),
354
+ true,
355
+ );
356
+ assert.strictEqual(
357
+ matchesSearchTokens(r, [
358
+ from("dhl"),
359
+ hasAttachment,
360
+ isUnread,
361
+ from("ups"),
362
+ ]),
363
+ false,
364
+ );
365
+ });
366
+ });
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Daily brief grouping logic.
3
+ *
4
+ * Pure function: takes a flat list of thread message rows and returns one
5
+ * section per message category, in a fixed display order:
6
+ *
7
+ * 1. Personal
8
+ * 2. Transactional
9
+ * 3. Newsletter
10
+ * 4. Marketing
11
+ * 5. Social
12
+ * 6. Automated
13
+ *
14
+ * Each row lands in the section for its category; a row with no category counts
15
+ * as `personal` (the classifier's own fallback). Starred mail is not a section —
16
+ * the star is a per-row marker, so a starred message stays in its category.
17
+ *
18
+ * Sender trust (vip/wellknown) no longer sections the brief — the signal is
19
+ * still carried on each row (see `toThreadRowData`) for future use, but it does
20
+ * not decide where a row lands. Read state is likewise not a routing signal: in
21
+ * a high-volume mailbox read≠handled and unread≠important; unread is a
22
+ * user-selectable filter chip instead.
23
+ *
24
+ * Muted senders (filtered by the caller) and empty sections are excluded.
25
+ */
26
+
27
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
28
+ import { MessageCategory } from "@remit/domain-enums";
29
+ import type {
30
+ SenderTrustLevel,
31
+ ThreadCategory,
32
+ ThreadRowData,
33
+ ThreadSection,
34
+ } from "@remit/ui";
35
+ import { toDisplayCategory } from "./display-category.js";
36
+ import { formatEmailDate } from "./format.js";
37
+ import type { SearchToken } from "./search-tokens.js";
38
+
39
+ /**
40
+ * Map a `RemitImapThreadMessageResponse` to the `ThreadRowData` shape used by
41
+ * remit-ui row body components. Mirrors the logic in MessageListItem so the
42
+ * brief rows render identically to per-mailbox rows.
43
+ */
44
+ export function toThreadRowData(
45
+ thread: RemitImapThreadMessageResponse,
46
+ ): ThreadRowData {
47
+ const suspicious = thread.authenticity?.dkimMismatch === true;
48
+ return {
49
+ id: thread.messageId,
50
+ accountId: thread.accountId ?? thread.accountConfigId,
51
+ mailboxId: thread.mailboxId,
52
+ fromName: thread.fromName ?? thread.fromEmail ?? "Unknown",
53
+ fromEmail: thread.fromEmail ?? "",
54
+ subject: thread.subject ?? "(No subject)",
55
+ snippet: thread.snippet ?? "",
56
+ timeLabel: formatEmailDate(thread.sentDate),
57
+ sentDate: thread.sentDate,
58
+ isRead: thread.isRead,
59
+ hasAttachment: thread.hasAttachment,
60
+ starred:
61
+ thread.star != null && thread.star !== "none" && thread.hasStars === true,
62
+ trust: thread.senderTrust as SenderTrustLevel,
63
+ category: toDisplayCategory(thread.category),
64
+ suspicious,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Category sections in fixed display order. The `id`/`label` drive the rendered
70
+ * section; `category` is the row category that routes into it.
71
+ */
72
+ const CATEGORY_SECTIONS: ReadonlyArray<{
73
+ id: string;
74
+ label: string;
75
+ category: ThreadCategory;
76
+ }> = [
77
+ { id: "personal", label: "Personal", category: MessageCategory.personal },
78
+ {
79
+ id: "transactional",
80
+ label: "Transactional",
81
+ category: MessageCategory.transactional,
82
+ },
83
+ {
84
+ id: "newsletter",
85
+ label: "Newsletter",
86
+ category: MessageCategory.newsletter,
87
+ },
88
+ { id: "marketing", label: "Marketing", category: MessageCategory.marketing },
89
+ { id: "social", label: "Social", category: MessageCategory.social },
90
+ { id: "automated", label: "Automated", category: MessageCategory.automated },
91
+ ];
92
+
93
+ /**
94
+ * Group a flat list of thread row data into one section per message category.
95
+ * Rows should already be filtered for the selected account chip (if any) before
96
+ * calling this function.
97
+ *
98
+ * @param rows Flat array of ThreadRowData, sorted newest-first.
99
+ * @returns Array of ThreadSection in category display order — empty
100
+ * sections are omitted.
101
+ */
102
+ export function groupBriefSections(rows: ThreadRowData[]): ThreadSection[] {
103
+ const byCategory = new Map<string, ThreadRowData[]>(
104
+ CATEGORY_SECTIONS.map((s) => [s.category, []]),
105
+ );
106
+
107
+ for (const row of rows) {
108
+ const category = row.category ?? MessageCategory.personal;
109
+ const bucket =
110
+ byCategory.get(category) ?? byCategory.get(MessageCategory.personal);
111
+ bucket?.push(row);
112
+ }
113
+
114
+ const sections: ThreadSection[] = [];
115
+ for (const section of CATEGORY_SECTIONS) {
116
+ const threads = byCategory.get(section.category);
117
+ if (threads && threads.length > 0)
118
+ sections.push({
119
+ id: section.id,
120
+ label: section.label,
121
+ threads,
122
+ });
123
+ }
124
+ return sections;
125
+ }
126
+
127
+ /**
128
+ * Returns true when `t` matches the free-text `query` (lower-cased).
129
+ * Checked against fromName, fromEmail, subject, and snippet.
130
+ */
131
+ export function matchesBriefSearch(t: ThreadRowData, query: string): boolean {
132
+ return (
133
+ t.fromName.toLowerCase().includes(query) ||
134
+ t.fromEmail.toLowerCase().includes(query) ||
135
+ t.subject.toLowerCase().includes(query) ||
136
+ t.snippet.toLowerCase().includes(query)
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Returns true when `t` satisfies every parsed filter token — the client-side
142
+ * counterpart of the params `threadOperationsSearchThreads` accepts, applied
143
+ * over the already-loaded brief/flagged rows (#428). A row without the data a
144
+ * token needs (e.g. no `sentDate` for `before:`/`after:`) never matches that
145
+ * token, so it drops out rather than showing under an unverifiable filter.
146
+ * `in:`/`account:` have no server param at all — this is the entire
147
+ * implementation of both, reusing the same per-account fan-out the daily
148
+ * brief already reads its rows from (`accountId`/`mailboxId` are already on
149
+ * every row).
150
+ */
151
+ export function matchesSearchTokens(
152
+ t: ThreadRowData,
153
+ tokens: SearchToken[],
154
+ ): boolean {
155
+ return tokens.every((token) => {
156
+ switch (token.type) {
157
+ case "from": {
158
+ const needle = token.value.toLowerCase();
159
+ return (
160
+ t.fromEmail.toLowerCase().includes(needle) ||
161
+ t.fromName.toLowerCase().includes(needle)
162
+ );
163
+ }
164
+ case "hasAttachment":
165
+ return t.hasAttachment === true;
166
+ case "isUnread":
167
+ return !t.isRead;
168
+ case "after":
169
+ return t.sentDate != null && t.sentDate >= token.epochSeconds * 1000;
170
+ case "before":
171
+ return t.sentDate != null && t.sentDate < token.epochSeconds * 1000;
172
+ case "in":
173
+ return t.mailboxId === token.mailboxId;
174
+ case "account":
175
+ return t.accountId === token.accountId;
176
+ default:
177
+ return false;
178
+ }
179
+ });
180
+ }