@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,55 @@
1
+ import type { RemitImapSenderTrust } from "@remit/api-http-client/types.gen.ts";
2
+ import type { Telemetry } from "@/lib/telemetry";
3
+
4
+ /**
5
+ * Rescue-from-Spam telemetry. Shapes the small, privacy-safe payloads (counts,
6
+ * folder roles, booleans — never bodies or addresses) and emits them through the
7
+ * shared `recordEvent` channel, matching the `domain.action` event-name
8
+ * convention used across the client.
9
+ */
10
+
11
+ export function recordRescueCandidatesSurfaced(
12
+ telemetry: Telemetry,
13
+ count: number,
14
+ ): void {
15
+ telemetry.recordEvent("rescue.candidates_surfaced", { count: String(count) });
16
+ }
17
+
18
+ export function recordRescueFlowOpened(
19
+ telemetry: Telemetry,
20
+ count: number,
21
+ ): void {
22
+ telemetry.recordEvent("rescue.flow_opened", { count: String(count) });
23
+ }
24
+
25
+ export function recordRescueCommitted(
26
+ telemetry: Telemetry,
27
+ input: { selected: number; total: number; toInbox: boolean },
28
+ ): void {
29
+ telemetry.recordEvent("rescue.committed", {
30
+ selected: String(input.selected),
31
+ total: String(input.total),
32
+ destination: input.toInbox ? "inbox" : "other",
33
+ });
34
+ }
35
+
36
+ /**
37
+ * The reverse signal: a message moved back into Junk. `wasRescuable` is the best
38
+ * cheaply-available prior-rescue proxy — the sender is one we can verify, so the
39
+ * user is overriding our trust. There is no per-message "was previously rescued"
40
+ * flag in the client, so we do not claim one.
41
+ */
42
+ export function recordRescueSentToJunk(
43
+ telemetry: Telemetry,
44
+ input: {
45
+ count: number;
46
+ senderTrust: RemitImapSenderTrust;
47
+ wasRescuable: boolean;
48
+ },
49
+ ): void {
50
+ telemetry.recordEvent("rescue.sent_to_junk", {
51
+ count: String(input.count),
52
+ senderTrust: input.senderTrust,
53
+ wasRescuable: String(input.wasRescuable),
54
+ });
55
+ }
@@ -0,0 +1,299 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import { AwsRum } from "aws-rum-web";
4
+ import { createRumTelemetry, initRum } from "./rum-adapter";
5
+
6
+ interface RumStub {
7
+ appMonitorId: string;
8
+ calls: {
9
+ recordPageView: string[];
10
+ recordError: Error[];
11
+ recordEvent: { name: string; attributes: Record<string, unknown> }[];
12
+ };
13
+ }
14
+
15
+ function resetInstances(): void {
16
+ (
17
+ globalThis as unknown as { __AWS_RUM_MOCKS__: { instances: unknown[] } }
18
+ ).__AWS_RUM_MOCKS__ = { instances: [] };
19
+ }
20
+
21
+ function getLastInstance(): RumStub {
22
+ const mocks = (
23
+ globalThis as unknown as {
24
+ __AWS_RUM_MOCKS__: { instances: RumStub[] };
25
+ }
26
+ ).__AWS_RUM_MOCKS__;
27
+ const instance = mocks.instances.at(-1);
28
+ assert.ok(instance, "Expected an AwsRum instance to have been created");
29
+ return instance;
30
+ }
31
+
32
+ function makeRum(): AwsRum {
33
+ return new (
34
+ AwsRum as unknown as new (
35
+ id: string,
36
+ v: string,
37
+ r: string,
38
+ c: Record<string, unknown>,
39
+ ) => AwsRum
40
+ )("monitor-id", "1.0.0", "eu-west-1", {});
41
+ }
42
+
43
+ function stubCalls(rum: AwsRum): RumStub["calls"] {
44
+ return (rum as unknown as RumStub).calls;
45
+ }
46
+
47
+ describe("createRumTelemetry", () => {
48
+ let rum: AwsRum;
49
+
50
+ beforeEach(() => {
51
+ resetInstances();
52
+ rum = makeRum();
53
+ });
54
+
55
+ it("recordPageView delegates to the RUM client", () => {
56
+ const t = createRumTelemetry(rum);
57
+ t.recordPageView("/inbox");
58
+ assert.deepEqual(stubCalls(rum).recordPageView, ["/inbox"]);
59
+ });
60
+
61
+ it("recordPageView strips the entire query string", () => {
62
+ const t = createRumTelemetry(rum);
63
+ t.recordPageView("/callback?token=secret&next=/inbox");
64
+ assert.equal(stubCalls(rum).recordPageView[0], "/callback");
65
+ });
66
+
67
+ it("recordPageView strips selectedMessageId query param", () => {
68
+ const t = createRumTelemetry(rum);
69
+ t.recordPageView("/inbox?selectedMessageId=msg_abc123def456");
70
+ assert.equal(stubCalls(rum).recordPageView[0], "/inbox");
71
+ });
72
+
73
+ it("recordPageView strips search query param", () => {
74
+ const t = createRumTelemetry(rum);
75
+ t.recordPageView("/inbox?q=sensitive+search+text");
76
+ assert.equal(stubCalls(rum).recordPageView[0], "/inbox");
77
+ });
78
+
79
+ it("recordPageView strips query string and fragment together", () => {
80
+ const t = createRumTelemetry(rum);
81
+ t.recordPageView("/inbox?q=foo#section");
82
+ assert.equal(stubCalls(rum).recordPageView[0], "/inbox");
83
+ });
84
+
85
+ it("recordPageView strips fragment-only paths", () => {
86
+ const t = createRumTelemetry(rum);
87
+ t.recordPageView("/inbox#fragment");
88
+ assert.equal(stubCalls(rum).recordPageView[0], "/inbox");
89
+ });
90
+
91
+ it("recordPageView strips message id path segments", () => {
92
+ const t = createRumTelemetry(rum);
93
+ t.recordPageView("/messages/msg_abc123def456");
94
+ assert.ok(
95
+ !stubCalls(rum).recordPageView[0]?.includes("msg_abc123def456"),
96
+ "message id must be redacted",
97
+ );
98
+ });
99
+
100
+ it("recordError delegates to the RUM client", () => {
101
+ const t = createRumTelemetry(rum);
102
+ const error = new Error("boom");
103
+ t.recordError(error);
104
+ assert.deepEqual(stubCalls(rum).recordError, [error]);
105
+ });
106
+
107
+ it("recordError scrubs message id tokens from error messages", () => {
108
+ const t = createRumTelemetry(rum);
109
+ const error = new Error("failed to load /messages/msg_abc123def456");
110
+ t.recordError(error);
111
+ const recorded = stubCalls(rum).recordError[0] as Error;
112
+ assert.ok(
113
+ !recorded.message.includes("msg_abc123def456"),
114
+ "message id must be scrubbed from error message",
115
+ );
116
+ });
117
+
118
+ it("recordError preserves error name on scrubbed errors", () => {
119
+ const t = createRumTelemetry(rum);
120
+ const error = new TypeError("token=secret leaked into /messages/msg_xyz");
121
+ t.recordError(error);
122
+ const recorded = stubCalls(rum).recordError[0] as Error;
123
+ assert.equal(recorded.name, "TypeError");
124
+ });
125
+
126
+ it("recordError with context emits an additional event", () => {
127
+ const t = createRumTelemetry(rum);
128
+ t.recordError(new Error("boom"), { component: "Inbox" });
129
+ const ctx = stubCalls(rum).recordEvent.find(
130
+ (e) => e.name === "telemetry.error_context",
131
+ );
132
+ assert.ok(ctx, "Expected a telemetry.error_context event");
133
+ assert.deepEqual(ctx.attributes, { component: "Inbox" });
134
+ });
135
+
136
+ it("recordEvent delegates to the RUM client", () => {
137
+ const t = createRumTelemetry(rum);
138
+ t.recordEvent("thread.open", { mailboxId: "inbox" });
139
+ assert.ok(
140
+ stubCalls(rum).recordEvent.some((e) => e.name === "thread.open"),
141
+ "Expected thread.open event",
142
+ );
143
+ });
144
+
145
+ it("recordEvent with no attributes passes an empty object", () => {
146
+ const t = createRumTelemetry(rum);
147
+ t.recordEvent("app.load");
148
+ const ev = stubCalls(rum).recordEvent.find((e) => e.name === "app.load");
149
+ assert.ok(ev, "Expected app.load event");
150
+ assert.deepEqual(ev.attributes, {});
151
+ });
152
+
153
+ it("recordTiming emits a telemetry.timing event with name and duration", () => {
154
+ const t = createRumTelemetry(rum);
155
+ t.recordTiming("api.latency", 123);
156
+ const ev = stubCalls(rum).recordEvent.find(
157
+ (e) => e.name === "telemetry.timing",
158
+ );
159
+ assert.ok(ev, "Expected telemetry.timing event");
160
+ assert.equal((ev.attributes as Record<string, string>).name, "api.latency");
161
+ assert.equal((ev.attributes as Record<string, string>).durationMs, "123");
162
+ });
163
+
164
+ it("recordTiming merges extra attributes", () => {
165
+ const t = createRumTelemetry(rum);
166
+ t.recordTiming("api.latency", 50, { route: "/inbox" });
167
+ const ev = stubCalls(rum).recordEvent.find(
168
+ (e) => e.name === "telemetry.timing",
169
+ );
170
+ assert.ok(ev, "Expected telemetry.timing event");
171
+ assert.equal((ev.attributes as Record<string, string>).route, "/inbox");
172
+ });
173
+ });
174
+
175
+ type WindowListener = (event: unknown) => void;
176
+
177
+ interface WindowStub {
178
+ listeners: Map<string, WindowListener>;
179
+ addEventListener(type: string, listener: WindowListener): void;
180
+ }
181
+
182
+ function installWindowStub(): WindowStub {
183
+ const stub: WindowStub = {
184
+ listeners: new Map(),
185
+ addEventListener(type, listener): void {
186
+ this.listeners.set(type, listener);
187
+ },
188
+ };
189
+ (globalThis as unknown as { window: WindowStub }).window = stub;
190
+ return stub;
191
+ }
192
+
193
+ function getRumConfig(
194
+ instance: RumStub,
195
+ ): { telemetries: string[] } & Record<string, unknown> {
196
+ return (instance as unknown as { config: { telemetries: string[] } }).config;
197
+ }
198
+
199
+ describe("initRum", () => {
200
+ afterEach(() => {
201
+ delete globalThis.__REMIT_CONFIG__;
202
+ delete (globalThis as unknown as { window?: unknown }).window;
203
+ });
204
+
205
+ it("returns a no-op telemetry when VITE_RUM_APP_MONITOR_ID is unset", () => {
206
+ globalThis.__REMIT_CONFIG__ = {};
207
+ const t = initRum();
208
+ assert.doesNotThrow(() => t.recordPageView("/"));
209
+ assert.doesNotThrow(() => t.recordError(new Error("x")));
210
+ assert.doesNotThrow(() => t.recordEvent("e"));
211
+ assert.doesNotThrow(() => t.recordTiming("t", 1));
212
+ });
213
+
214
+ it("creates a RumTelemetry when VITE_RUM_APP_MONITOR_ID is set", () => {
215
+ resetInstances();
216
+ installWindowStub();
217
+ globalThis.__REMIT_CONFIG__ = {
218
+ rum: {
219
+ appMonitorId: "test-monitor-id",
220
+ identityPoolId: "eu-west-1:test-pool",
221
+ region: "eu-west-1",
222
+ },
223
+ };
224
+ const t = initRum();
225
+ const instance = getLastInstance();
226
+ assert.equal(instance.appMonitorId, "test-monitor-id");
227
+ assert.doesNotThrow(() => t.recordPageView("/inbox"));
228
+ });
229
+
230
+ it("does not enable leaky auto-telemetries", () => {
231
+ resetInstances();
232
+ installWindowStub();
233
+ globalThis.__REMIT_CONFIG__ = {
234
+ rum: { appMonitorId: "test-monitor-id" },
235
+ };
236
+ initRum();
237
+ const config = getRumConfig(getLastInstance());
238
+ assert.ok(
239
+ !config.telemetries.includes("errors"),
240
+ "errors auto-capture must be disabled",
241
+ );
242
+ assert.ok(
243
+ !config.telemetries.includes("http"),
244
+ "http auto-capture must be disabled",
245
+ );
246
+ });
247
+
248
+ it("disables auto page view so paths are recorded via the sanitizing adapter", () => {
249
+ resetInstances();
250
+ installWindowStub();
251
+ globalThis.__REMIT_CONFIG__ = {
252
+ rum: { appMonitorId: "test-monitor-id" },
253
+ };
254
+ initRum();
255
+ const config = getRumConfig(getLastInstance());
256
+ assert.equal(config.disableAutoPageView, true);
257
+ });
258
+
259
+ it("scrubs uncaught errors before recording them through RUM", () => {
260
+ resetInstances();
261
+ const win = installWindowStub();
262
+ globalThis.__REMIT_CONFIG__ = {
263
+ rum: { appMonitorId: "test-monitor-id" },
264
+ };
265
+ initRum();
266
+ const handler = win.listeners.get("error");
267
+ assert.ok(handler, "Expected a global error handler");
268
+ handler({
269
+ error: new Error("failed to load /messages/msg_abc123def456"),
270
+ message: "failed to load /messages/msg_abc123def456",
271
+ });
272
+ const recorded = stubCalls(getLastInstance() as unknown as AwsRum)
273
+ .recordError[0] as Error;
274
+ assert.ok(
275
+ !recorded.message.includes("msg_abc123def456"),
276
+ "uncaught error message must be scrubbed",
277
+ );
278
+ });
279
+
280
+ it("scrubs unhandled rejections before recording them through RUM", () => {
281
+ resetInstances();
282
+ const win = installWindowStub();
283
+ globalThis.__REMIT_CONFIG__ = {
284
+ rum: { appMonitorId: "test-monitor-id" },
285
+ };
286
+ initRum();
287
+ const handler = win.listeners.get("unhandledrejection");
288
+ assert.ok(handler, "Expected a global unhandledrejection handler");
289
+ handler({
290
+ reason: new Error("rejected loading /messages/msg_xyz789"),
291
+ });
292
+ const recorded = stubCalls(getLastInstance() as unknown as AwsRum)
293
+ .recordError[0] as Error;
294
+ assert.ok(
295
+ !recorded.message.includes("msg_xyz789"),
296
+ "rejection reason must be scrubbed",
297
+ );
298
+ });
299
+ });
@@ -0,0 +1,107 @@
1
+ import { AwsRum } from "aws-rum-web";
2
+ import { getRuntimeConfig } from "../runtime-config";
3
+ import type { Telemetry } from "./telemetry";
4
+ import { noopTelemetry } from "./telemetry";
5
+
6
+ const ID_TOKEN_PATTERN =
7
+ /([?&](token|access_token|id_token|authorization)=[^&]*)|\/messages\/[a-z0-9_-]+/gi;
8
+
9
+ function scrubIds(value: string): string {
10
+ return value.replace(ID_TOKEN_PATTERN, "[redacted]");
11
+ }
12
+
13
+ function sanitizePath(path: string): string {
14
+ const qIndex = path.indexOf("?");
15
+ const hIndex = path.indexOf("#");
16
+ const cutAt =
17
+ qIndex === -1 && hIndex === -1
18
+ ? path.length
19
+ : qIndex === -1
20
+ ? hIndex
21
+ : hIndex === -1
22
+ ? qIndex
23
+ : Math.min(qIndex, hIndex);
24
+ return scrubIds(path.slice(0, cutAt));
25
+ }
26
+
27
+ function scrubErrorMessage(error: Error): Error {
28
+ const scrubbed = scrubIds(error.message);
29
+ if (scrubbed === error.message) {
30
+ return error;
31
+ }
32
+ const next = new Error(scrubbed);
33
+ next.name = error.name;
34
+ next.stack = error.stack;
35
+ return next;
36
+ }
37
+
38
+ export function createRumTelemetry(rum: AwsRum): Telemetry {
39
+ return {
40
+ recordPageView(path: string): void {
41
+ rum.recordPageView(sanitizePath(path));
42
+ },
43
+
44
+ recordError(error: Error, context?: Record<string, string>): void {
45
+ rum.recordError(scrubErrorMessage(error));
46
+ if (context) {
47
+ rum.recordEvent("telemetry.error_context", context);
48
+ }
49
+ },
50
+
51
+ recordEvent(name: string, attributes?: Record<string, string>): void {
52
+ rum.recordEvent(name, attributes ?? {});
53
+ },
54
+
55
+ recordTiming(
56
+ name: string,
57
+ durationMs: number,
58
+ attributes?: Record<string, string>,
59
+ ): void {
60
+ rum.recordEvent("telemetry.timing", {
61
+ ...attributes,
62
+ name,
63
+ durationMs: String(durationMs),
64
+ });
65
+ },
66
+ };
67
+ }
68
+
69
+ function toError(reason: unknown): Error {
70
+ if (reason instanceof Error) {
71
+ return reason;
72
+ }
73
+ return new Error(String(reason));
74
+ }
75
+
76
+ function installGlobalErrorHandlers(telemetry: Telemetry): void {
77
+ window.addEventListener("error", (event: ErrorEvent): void => {
78
+ telemetry.recordError(toError(event.error ?? event.message));
79
+ });
80
+ window.addEventListener(
81
+ "unhandledrejection",
82
+ (event: PromiseRejectionEvent): void => {
83
+ telemetry.recordError(toError(event.reason));
84
+ },
85
+ );
86
+ }
87
+
88
+ export function initRum(): Telemetry {
89
+ const { appMonitorId, identityPoolId, region } = getRuntimeConfig().rum;
90
+ if (!appMonitorId) {
91
+ return noopTelemetry;
92
+ }
93
+
94
+ const rum = new AwsRum(appMonitorId, "1.0.0", region, {
95
+ sessionSampleRate: 1,
96
+ identityPoolId,
97
+ endpoint: `https://dataplane.rum.${region}.amazonaws.com`,
98
+ telemetries: ["performance"],
99
+ disableAutoPageView: true,
100
+ allowCookies: false,
101
+ enableXRay: false,
102
+ });
103
+
104
+ const telemetry = createRumTelemetry(rum);
105
+ installGlobalErrorHandlers(telemetry);
106
+ return telemetry;
107
+ }
@@ -0,0 +1,80 @@
1
+ import assert from "node:assert";
2
+ import { beforeEach, describe, test } from "node:test";
3
+ import {
4
+ isSearchSaved,
5
+ loadSavedSearches,
6
+ removeSavedSearch,
7
+ saveSearch,
8
+ } from "./saved-searches.js";
9
+
10
+ function installMemoryStorage(): void {
11
+ const store = new Map<string, string>();
12
+ globalThis.localStorage = {
13
+ getItem: (k: string) => store.get(k) ?? null,
14
+ setItem: (k: string, v: string) => void store.set(k, v),
15
+ removeItem: (k: string) => void store.delete(k),
16
+ clear: () => store.clear(),
17
+ key: () => null,
18
+ length: 0,
19
+ } as Storage;
20
+ }
21
+
22
+ describe("saved-searches", () => {
23
+ beforeEach(installMemoryStorage);
24
+
25
+ test("starts empty", () => {
26
+ assert.deepEqual(loadSavedSearches(), []);
27
+ });
28
+
29
+ test("saves and loads a query", () => {
30
+ assert.deepEqual(saveSearch("from:alice has:attachment"), [
31
+ "from:alice has:attachment",
32
+ ]);
33
+ assert.deepEqual(loadSavedSearches(), ["from:alice has:attachment"]);
34
+ });
35
+
36
+ test("most recent save first, deduplicated", () => {
37
+ saveSearch("alice");
38
+ saveSearch("bob");
39
+ assert.deepEqual(saveSearch("alice"), ["alice", "bob"]);
40
+ });
41
+
42
+ test("ignores blank queries", () => {
43
+ saveSearch("alice");
44
+ assert.deepEqual(saveSearch(" "), ["alice"]);
45
+ });
46
+
47
+ test("trims before storing", () => {
48
+ assert.deepEqual(saveSearch(" bob "), ["bob"]);
49
+ });
50
+
51
+ test("survives corrupt storage", () => {
52
+ globalThis.localStorage.setItem("remit.savedSearches", "{not json");
53
+ assert.deepEqual(loadSavedSearches(), []);
54
+ });
55
+
56
+ test("removeSavedSearch drops the exact query", () => {
57
+ saveSearch("alice");
58
+ saveSearch("bob");
59
+ assert.deepEqual(removeSavedSearch("alice"), ["bob"]);
60
+ assert.deepEqual(loadSavedSearches(), ["bob"]);
61
+ });
62
+
63
+ test("removeSavedSearch is a no-op for a query that isn't saved", () => {
64
+ saveSearch("alice");
65
+ assert.deepEqual(removeSavedSearch("nonexistent"), ["alice"]);
66
+ });
67
+
68
+ test("isSearchSaved reflects the persisted set, trimmed", () => {
69
+ saveSearch("alice");
70
+ assert.equal(isSearchSaved("alice"), true);
71
+ assert.equal(isSearchSaved(" alice "), true);
72
+ assert.equal(isSearchSaved("bob"), false);
73
+ });
74
+
75
+ test("caps at MAX_SAVED entries", () => {
76
+ for (let i = 0; i < 30; i++) saveSearch(`query-${i}`);
77
+ assert.equal(loadSavedSearches().length, 25);
78
+ assert.deepEqual(loadSavedSearches()[0], "query-29");
79
+ });
80
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Saved searches for the mail search bar, persisted in localStorage (#428
3
+ * follow-up, local-only MVP — see doc/design/flows/06-search.md; no backend
4
+ * preference storage yet). Mirrors `recent-searches.ts`: a query already
5
+ * carries its filter chips (`parseSearchTokens` re-derives them), so saving
6
+ * the raw query text is enough to reproduce the full search. Best-effort:
7
+ * storage failures (private mode, quota) degrade to an empty list rather than
8
+ * crashing the search surface.
9
+ */
10
+ const SAVED_SEARCHES_KEY = "remit.savedSearches";
11
+ const MAX_SAVED = 25;
12
+
13
+ export function loadSavedSearches(): string[] {
14
+ try {
15
+ const raw = localStorage.getItem(SAVED_SEARCHES_KEY);
16
+ if (!raw) return [];
17
+ const parsed: unknown = JSON.parse(raw);
18
+ if (!Array.isArray(parsed)) return [];
19
+ return parsed.filter((q): q is string => typeof q === "string");
20
+ } catch {
21
+ return [];
22
+ }
23
+ }
24
+
25
+ export function saveSearch(query: string): string[] {
26
+ const trimmed = query.trim();
27
+ if (!trimmed) return loadSavedSearches();
28
+ const next = [
29
+ trimmed,
30
+ ...loadSavedSearches().filter((q) => q !== trimmed),
31
+ ].slice(0, MAX_SAVED);
32
+ try {
33
+ localStorage.setItem(SAVED_SEARCHES_KEY, JSON.stringify(next));
34
+ } catch {
35
+ return next;
36
+ }
37
+ return next;
38
+ }
39
+
40
+ export function removeSavedSearch(query: string): string[] {
41
+ const next = loadSavedSearches().filter((q) => q !== query);
42
+ try {
43
+ localStorage.setItem(SAVED_SEARCHES_KEY, JSON.stringify(next));
44
+ } catch {
45
+ return next;
46
+ }
47
+ return next;
48
+ }
49
+
50
+ export function isSearchSaved(query: string): boolean {
51
+ return loadSavedSearches().includes(query.trim());
52
+ }
@@ -0,0 +1,61 @@
1
+ // Regression test for #623: search results must be openable once the query
2
+ // settles. The route computes `isSearchPending = searchInput !== searchQuery`
3
+ // to decide whether to suppress the reading pane, then resolves the selected
4
+ // thread via `resolveSelectedThread`.
5
+ //
6
+ // - `searchInput` is the live (pre-debounce) value the user is typing.
7
+ // - `searchQuery` is the debounced (committed) value sent to the API.
8
+ // - The pane is suppressed only while the two differ (a debounce is in flight).
9
+ // - Once settled, a selected result should be shown in the reading pane.
10
+
11
+ import assert from "node:assert";
12
+ import { describe, test } from "node:test";
13
+ import { isSearchPending, resolveSelectedThread } from "./search-pending.ts";
14
+
15
+ describe("isSearchPending (reading-pane suppression guard)", () => {
16
+ test("no search: both empty — not pending", () => {
17
+ assert.equal(isSearchPending("", ""), false);
18
+ });
19
+
20
+ test("settled search: input matches debounced query — not pending", () => {
21
+ assert.equal(isSearchPending("amazon", "amazon"), false);
22
+ });
23
+
24
+ test("user starts typing: input differs from query — pending", () => {
25
+ assert.equal(isSearchPending("ama", ""), true);
26
+ });
27
+
28
+ test("mid-debounce: partial input vs previous query — pending", () => {
29
+ assert.equal(isSearchPending("amaz", "amazon"), true);
30
+ });
31
+ });
32
+
33
+ describe("resolveSelectedThread (#623 reading-pane resolution)", () => {
34
+ const threads = [{ messageId: "msg-001" }, { messageId: "msg-002" }];
35
+
36
+ test("regression #623: settled query with a matching selection opens the thread", () => {
37
+ const pending = isSearchPending("amazon", "amazon");
38
+ assert.equal(pending, false);
39
+
40
+ const selectedThread = resolveSelectedThread(threads, "msg-001", pending);
41
+ assert.deepEqual(selectedThread, { messageId: "msg-001" });
42
+ });
43
+
44
+ test("pending search suppresses the reading pane even with a matching selection", () => {
45
+ const pending = isSearchPending("amaz", "amazon");
46
+ assert.equal(pending, true);
47
+
48
+ const selectedThread = resolveSelectedThread(threads, "msg-001", pending);
49
+ assert.equal(selectedThread, undefined);
50
+ });
51
+
52
+ test("settled query but selection absent from results — no thread", () => {
53
+ const selectedThread = resolveSelectedThread(threads, "msg-999", false);
54
+ assert.equal(selectedThread, undefined);
55
+ });
56
+
57
+ test("settled query with no selection — no thread", () => {
58
+ const selectedThread = resolveSelectedThread(threads, undefined, false);
59
+ assert.equal(selectedThread, undefined);
60
+ });
61
+ });
@@ -0,0 +1,24 @@
1
+ // Reading-pane suppression guard for the mail search bar (#539, #623).
2
+ //
3
+ // `searchInput` is the live (pre-debounce) value the user is typing;
4
+ // `searchQuery` is the debounced (committed) value sent to the API. A debounce
5
+ // is in flight whenever the two differ — during that window the reading pane is
6
+ // kept closed so it clears the instant a new search starts (#539). Once the
7
+ // query settles, a selected result is honored so search results can be opened
8
+ // (#623).
9
+ export const isSearchPending = (
10
+ searchInput: string,
11
+ searchQuery: string,
12
+ ): boolean => searchInput !== searchQuery;
13
+
14
+ // Resolve the thread to show in the reading pane. Returns `undefined` while a
15
+ // search debounce is pending or when nothing is selected; otherwise looks the
16
+ // selected message up in the loaded thread list.
17
+ export const resolveSelectedThread = <T extends { messageId: string }>(
18
+ threads: T[],
19
+ selectedMessageId: string | undefined,
20
+ pending: boolean,
21
+ ): T | undefined => {
22
+ if (pending || !selectedMessageId) return undefined;
23
+ return threads.find((t) => t.messageId === selectedMessageId);
24
+ };