@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,97 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, it } from "node:test";
3
+ import { ApiError } from "./api";
4
+ import { getRecentErrors } from "./console-errors";
5
+ import {
6
+ __resetFatalError,
7
+ getCurrentFatalError,
8
+ reportFatalError,
9
+ setFatalErrorTelemetry,
10
+ subscribeFatalError,
11
+ } from "./fatal-error";
12
+
13
+ afterEach(() => {
14
+ __resetFatalError();
15
+ });
16
+
17
+ describe("reportFatalError", () => {
18
+ it("notifies subscribers with the classified fatal", () => {
19
+ const seen: string[] = [];
20
+ subscribeFatalError((fatal) => seen.push(fatal.message));
21
+
22
+ reportFatalError(new Error("server exploded"));
23
+
24
+ assert.deepEqual(seen, ["server exploded"]);
25
+ });
26
+
27
+ it("records the fatal into the recent-errors ring (bug-report flow reads it)", () => {
28
+ reportFatalError(new Error("ring me"));
29
+ const recent = getRecentErrors();
30
+ assert.ok(
31
+ recent.some((line) => line.includes("Fatal: ring me")),
32
+ "expected the fatal to land in the console-errors ring",
33
+ );
34
+ });
35
+
36
+ it("assigns a correlation id and exposes the current fatal", () => {
37
+ const fatal = reportFatalError(new Error("with id"));
38
+ assert.ok(fatal.correlationId.length > 0);
39
+ assert.equal(getCurrentFatalError(), fatal);
40
+ });
41
+
42
+ it("emits a telemetry fatal-error event through the registered sink", () => {
43
+ const recorded: Array<{ message: string; ctx?: Record<string, string> }> =
44
+ [];
45
+ setFatalErrorTelemetry({
46
+ recordPageView: () => {},
47
+ recordError: (error, ctx) =>
48
+ recorded.push({ message: error.message, ctx }),
49
+ recordEvent: () => {},
50
+ recordTiming: () => {},
51
+ });
52
+
53
+ reportFatalError(new Error("telemetry please"));
54
+
55
+ assert.equal(recorded.length, 1);
56
+ assert.equal(recorded[0].message, "telemetry please");
57
+ assert.equal(recorded[0].ctx?.fatal, "true");
58
+ });
59
+
60
+ it("unsubscribe stops further notifications", () => {
61
+ let count = 0;
62
+ const unsubscribe = subscribeFatalError(() => {
63
+ count += 1;
64
+ });
65
+ reportFatalError(new Error("one"));
66
+ unsubscribe();
67
+ reportFatalError(new Error("two"));
68
+ assert.equal(count, 1);
69
+ });
70
+ });
71
+
72
+ describe("reportFatalError — recoverable classification", () => {
73
+ it("classifies a 5xx as recoverable (Retry may succeed)", () => {
74
+ const fatal = reportFatalError(new ApiError("boom", 503));
75
+ assert.equal(fatal.recoverable, true);
76
+ });
77
+
78
+ it("classifies a hard 4xx as not recoverable (deterministic)", () => {
79
+ const fatal = reportFatalError(new ApiError("bad request", 400));
80
+ assert.equal(fatal.recoverable, false);
81
+ });
82
+
83
+ it("honours an explicit recoverable=false for a caught render exception", () => {
84
+ const fatal = reportFatalError(new Error("date value is not finite"), {
85
+ recoverable: false,
86
+ componentStack: "\n at Accounts\n at Route",
87
+ });
88
+ assert.equal(fatal.recoverable, false);
89
+ assert.equal(fatal.componentStack, "\n at Accounts\n at Route");
90
+ });
91
+
92
+ it("carries the error stack so the bug report can seed from it", () => {
93
+ const error = new Error("with stack");
94
+ const fatal = reportFatalError(error);
95
+ assert.equal(fatal.stack, error.stack);
96
+ });
97
+ });
@@ -0,0 +1,165 @@
1
+ import { recordError } from "./console-errors";
2
+ import { isServerError } from "./error-classifier";
3
+ import type { Telemetry } from "./telemetry";
4
+
5
+ /**
6
+ * The single escalation seam for first-party fatal errors.
7
+ *
8
+ * Calling `reportFatalError(error)` does three things, unconditionally:
9
+ * 1. records the error into the recent-errors ring (so the bug-report flow
10
+ * picks it up),
11
+ * 2. emits a telemetry fatal-error event (when a telemetry sink is registered
12
+ * via `setFatalErrorTelemetry` — `main.tsx` plugs the live RUM adapter in
13
+ * here), and
14
+ * 3. notifies every subscriber — the `FatalErrorOverlay` subscribes here and
15
+ * takes over the whole screen with the red escalation page.
16
+ *
17
+ * This is the one place telemetry plugs in: RUM wires in once, not per call
18
+ * site.
19
+ */
20
+
21
+ export interface FatalError {
22
+ error: unknown;
23
+ /** Human-readable message extracted from the error. */
24
+ message: string;
25
+ /** Correlation id for support — distinguishes one fatal from the next. */
26
+ correlationId: string;
27
+ /** When the fatal was reported. */
28
+ at: number;
29
+ /**
30
+ * Whether retrying the same action might succeed. A transient class — a 5xx,
31
+ * an offline/network blip, an aborted request — is recoverable; the overlay
32
+ * offers Retry. A deterministic render/exception (caught by the route error
33
+ * boundary) is NOT recoverable — retry re-crashes, so the overlay offers a
34
+ * way out (a safe route) instead.
35
+ */
36
+ recoverable: boolean;
37
+ /** The error's stack, when it is an `Error`. Seeds the bug report. */
38
+ stack?: string;
39
+ /** React component stack from the error boundary, when available. */
40
+ componentStack?: string;
41
+ }
42
+
43
+ /** Options for `reportFatalError`. */
44
+ export interface ReportFatalOptions {
45
+ /**
46
+ * Force the recoverable classification. Omit to derive it from the error
47
+ * (transient network/5xx/abort ⇒ recoverable). The route error boundary
48
+ * passes `false` for a caught render exception — it is deterministic.
49
+ */
50
+ recoverable?: boolean;
51
+ /** React component stack captured by the error boundary. */
52
+ componentStack?: string;
53
+ }
54
+
55
+ /**
56
+ * Default recoverable classification for an error with no explicit override.
57
+ * Only a 5xx is treated as recoverable — the one clearly transient class, where
58
+ * retrying the same action may succeed. Everything that reaches the fatal seam
59
+ * is otherwise deterministic: aborts and offline/network blips never escalate
60
+ * here (they are filtered upstream by `shouldEscalate`), so a statusless error
61
+ * arriving here is an uncaught/unhandled crash, not a benign network blip.
62
+ * A caught render exception is deterministic too — the error boundary reports
63
+ * it with an explicit `recoverable: false` for good measure.
64
+ */
65
+ const defaultRecoverable = (error: unknown): boolean => isServerError(error);
66
+
67
+ type FatalErrorListener = (fatal: FatalError) => void;
68
+
69
+ const listeners = new Set<FatalErrorListener>();
70
+
71
+ let current: FatalError | null = null;
72
+
73
+ let telemetry: Telemetry | null = null;
74
+
75
+ /**
76
+ * Register the telemetry sink the fatal seam emits to. Called once from
77
+ * `main.tsx` with the live RUM adapter (no-op telemetry when RUM is disabled).
78
+ */
79
+ export const setFatalErrorTelemetry = (sink: Telemetry): void => {
80
+ telemetry = sink;
81
+ };
82
+
83
+ const toError = (error: unknown): Error =>
84
+ error instanceof Error ? error : new Error(messageOf(error));
85
+
86
+ const messageOf = (error: unknown): string => {
87
+ if (error instanceof Error) return error.message || error.name;
88
+ if (typeof error === "string") return error;
89
+ if (
90
+ error &&
91
+ typeof error === "object" &&
92
+ "message" in error &&
93
+ typeof (error as { message: unknown }).message === "string"
94
+ ) {
95
+ return (error as { message: string }).message;
96
+ }
97
+ return "An unexpected server error occurred.";
98
+ };
99
+
100
+ const newCorrelationId = (): string => {
101
+ if (
102
+ typeof globalThis.crypto !== "undefined" &&
103
+ typeof globalThis.crypto.randomUUID === "function"
104
+ ) {
105
+ return globalThis.crypto.randomUUID();
106
+ }
107
+ return `fatal-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
108
+ };
109
+
110
+ /**
111
+ * Report a fatal, first-party error. Idempotent in spirit: the overlay shows
112
+ * the first fatal and stays until the user reloads, so repeated calls only
113
+ * refresh the recorded entry and re-notify (a no-op for an already-mounted
114
+ * overlay).
115
+ */
116
+ export const reportFatalError = (
117
+ error: unknown,
118
+ options?: ReportFatalOptions,
119
+ ): FatalError => {
120
+ const fatal: FatalError = {
121
+ error,
122
+ message: messageOf(error),
123
+ correlationId: newCorrelationId(),
124
+ at: Date.now(),
125
+ recoverable: options?.recoverable ?? defaultRecoverable(error),
126
+ stack: error instanceof Error ? error.stack : undefined,
127
+ componentStack: options?.componentStack,
128
+ };
129
+
130
+ current = fatal;
131
+ recordError(`Fatal: ${fatal.message} [${fatal.correlationId}]`);
132
+
133
+ // Single telemetry seam: emit the fatal through RUM (epic #658). Scrubbing
134
+ // of ids/tokens happens inside the RUM adapter.
135
+ telemetry?.recordError(toError(error), {
136
+ fatal: "true",
137
+ correlationId: fatal.correlationId,
138
+ });
139
+
140
+ for (const listener of listeners) {
141
+ listener(fatal);
142
+ }
143
+
144
+ return fatal;
145
+ };
146
+
147
+ /** Subscribe to fatal errors. Returns an unsubscribe function. */
148
+ export const subscribeFatalError = (
149
+ listener: FatalErrorListener,
150
+ ): (() => void) => {
151
+ listeners.add(listener);
152
+ return () => {
153
+ listeners.delete(listener);
154
+ };
155
+ };
156
+
157
+ /** The most recent fatal error, if any — lets a late subscriber catch up. */
158
+ export const getCurrentFatalError = (): FatalError | null => current;
159
+
160
+ /** Test-only: clear the in-memory fatal state, listeners, and telemetry sink. */
161
+ export const __resetFatalError = (): void => {
162
+ current = null;
163
+ listeners.clear();
164
+ telemetry = null;
165
+ };
@@ -0,0 +1,113 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import type { RemitImapFolderAppointment } from "@remit/api-http-client/types.gen.ts";
4
+ import {
5
+ buildMailboxRoleMap,
6
+ getMailboxDisplayName,
7
+ labelForMailbox,
8
+ shouldShowUnreadBadgeForRole,
9
+ } from "./folder-roles.js";
10
+
11
+ const appoint = (
12
+ role: RemitImapFolderAppointment["role"],
13
+ mailboxId: string | undefined,
14
+ ): RemitImapFolderAppointment => ({ role, mailboxId });
15
+
16
+ describe("buildMailboxRoleMap", () => {
17
+ test("maps mailboxId to its appointed role", () => {
18
+ const map = buildMailboxRoleMap([
19
+ appoint("Inbox", "mb-inbox"),
20
+ appoint("Drafts", "mb-concepten"),
21
+ ]);
22
+ assert.equal(map.get("mb-inbox"), "inbox");
23
+ assert.equal(map.get("mb-concepten"), "drafts");
24
+ });
25
+
26
+ test("an unfilled role (no mailboxId) contributes no entry", () => {
27
+ const map = buildMailboxRoleMap([appoint("Archive", undefined)]);
28
+ assert.equal(map.size, 0);
29
+ });
30
+
31
+ test("a mailbox appointed to two roles keeps the higher-priority one", () => {
32
+ // RFC 032: a folder may legitimately fill more than one role — the
33
+ // sidebar shows one row, so the map picks a single winner deterministically.
34
+ const map = buildMailboxRoleMap([
35
+ appoint("Trash", "mb-1"),
36
+ appoint("Inbox", "mb-1"),
37
+ ]);
38
+ assert.equal(map.get("mb-1"), "inbox");
39
+ });
40
+
41
+ test("empty appointments produce an empty map", () => {
42
+ assert.equal(buildMailboxRoleMap([]).size, 0);
43
+ });
44
+ });
45
+
46
+ describe("getMailboxDisplayName", () => {
47
+ test("returns the leaf segment of a nested path", () => {
48
+ assert.equal(getMailboxDisplayName("INBOX/Sent Messages"), "Sent Messages");
49
+ });
50
+
51
+ test("returns the whole path when there is no delimiter", () => {
52
+ assert.equal(getMailboxDisplayName("INBOX"), "INBOX");
53
+ });
54
+ });
55
+
56
+ describe("labelForMailbox", () => {
57
+ const t = (key: string, fallback: string) =>
58
+ key === "sidebar.sent" ? "Verzonden" : fallback;
59
+
60
+ test("a trimmed displayNameOverride wins over everything", () => {
61
+ assert.equal(
62
+ labelForMailbox(
63
+ { fullPath: "INBOX/Sent", displayNameOverride: " My Sent " },
64
+ "sent",
65
+ t,
66
+ ),
67
+ "My Sent",
68
+ );
69
+ });
70
+
71
+ test("falls back to the translated canonical role label", () => {
72
+ assert.equal(
73
+ labelForMailbox({ fullPath: "INBOX/Verzonden" }, "sent", t),
74
+ "Verzonden",
75
+ );
76
+ });
77
+
78
+ test("falls back to the provider leaf when there is no role", () => {
79
+ assert.equal(
80
+ labelForMailbox({ fullPath: "INBOX/Nieuwsbrieven" }, undefined, t),
81
+ "Nieuwsbrieven",
82
+ );
83
+ });
84
+
85
+ test("falls back to the leaf when no translator is supplied", () => {
86
+ assert.equal(labelForMailbox({ fullPath: "INBOX/Sent" }, "sent"), "Sent");
87
+ });
88
+
89
+ test("a blank/whitespace override is ignored", () => {
90
+ assert.equal(
91
+ labelForMailbox(
92
+ { fullPath: "INBOX/Sent", displayNameOverride: " " },
93
+ "sent",
94
+ t,
95
+ ),
96
+ "Verzonden",
97
+ );
98
+ });
99
+ });
100
+
101
+ describe("shouldShowUnreadBadgeForRole", () => {
102
+ test("hides the badge for Sent, Drafts, and Trash", () => {
103
+ assert.equal(shouldShowUnreadBadgeForRole("sent"), false);
104
+ assert.equal(shouldShowUnreadBadgeForRole("drafts"), false);
105
+ assert.equal(shouldShowUnreadBadgeForRole("trash"), false);
106
+ });
107
+
108
+ test("shows the badge for Inbox, Junk, and plain folders (no role)", () => {
109
+ assert.equal(shouldShowUnreadBadgeForRole("inbox"), true);
110
+ assert.equal(shouldShowUnreadBadgeForRole("junk"), true);
111
+ assert.equal(shouldShowUnreadBadgeForRole(undefined), true);
112
+ });
113
+ });
@@ -0,0 +1,112 @@
1
+ import type {
2
+ RemitImapCanonicalMailboxRole,
3
+ RemitImapFolderAppointment,
4
+ } from "@remit/api-http-client/types.gen.ts";
5
+ import type { NavMailboxRole } from "@remit/ui";
6
+
7
+ /**
8
+ * RFC 032 exclusive-folder-appointment (#976): the sidebar, the move-to
9
+ * picker, and the per-role hooks all resolve a mailbox's role from the
10
+ * account's `folderAppointments` map — never by re-detecting it from the
11
+ * folder's name or IMAP flags. The map is server-computed (persisted choice,
12
+ * or a proposal for anything unfilled) and exclusive by construction: a role
13
+ * points at exactly one mailbox, so there is nothing left to dedupe or guess
14
+ * at client-side.
15
+ */
16
+
17
+ /** `CanonicalMailboxRole` (PascalCase, wire) → kit `NavMailboxRole` (lowercase). */
18
+ export const CANONICAL_TO_NAV_ROLE: Record<string, NavMailboxRole> = {
19
+ Inbox: "inbox",
20
+ Drafts: "drafts",
21
+ Sent: "sent",
22
+ Archive: "archive",
23
+ Junk: "junk",
24
+ Trash: "trash",
25
+ All: "all",
26
+ Flagged: "flagged",
27
+ };
28
+
29
+ /** Kit `NavMailboxRole`/`FolderRole` (lowercase) → wire `CanonicalMailboxRole`. */
30
+ export const NAV_ROLE_TO_CANONICAL: Record<
31
+ NavMailboxRole,
32
+ RemitImapCanonicalMailboxRole
33
+ > = {
34
+ inbox: "Inbox",
35
+ drafts: "Drafts",
36
+ sent: "Sent",
37
+ archive: "Archive",
38
+ junk: "Junk",
39
+ trash: "Trash",
40
+ all: "All",
41
+ flagged: "Flagged",
42
+ };
43
+
44
+ // Display/pin priority, matching the kit's own ROLE_ORDER (nav-sidebar.tsx).
45
+ // Used to pick a single role when one mailbox is (unusually) appointed to more
46
+ // than one — the RFC allows this, but a sidebar row shows one role — and to
47
+ // order the move-to picker's system-folder rows.
48
+ export const ROLE_PRIORITY: readonly NavMailboxRole[] = [
49
+ "inbox",
50
+ "flagged",
51
+ "drafts",
52
+ "sent",
53
+ "archive",
54
+ "all",
55
+ "junk",
56
+ "trash",
57
+ ];
58
+
59
+ /**
60
+ * Reverse the role→mailboxId appointment map into mailboxId→role, so a
61
+ * mailbox row can look up its own role in O(1). When a mailbox is appointed to
62
+ * more than one role, the highest-priority role wins for display purposes.
63
+ */
64
+ export function buildMailboxRoleMap(
65
+ appointments: readonly RemitImapFolderAppointment[],
66
+ ): Map<string, NavMailboxRole> {
67
+ const byMailbox = new Map<string, NavMailboxRole>();
68
+ const sorted = [...appointments].sort(
69
+ (a, b) =>
70
+ ROLE_PRIORITY.indexOf(CANONICAL_TO_NAV_ROLE[a.role]) -
71
+ ROLE_PRIORITY.indexOf(CANONICAL_TO_NAV_ROLE[b.role]),
72
+ );
73
+ for (const appointment of sorted) {
74
+ if (!appointment.mailboxId) continue;
75
+ if (byMailbox.has(appointment.mailboxId)) continue;
76
+ const role = CANONICAL_TO_NAV_ROLE[appointment.role];
77
+ if (role) byMailbox.set(appointment.mailboxId, role);
78
+ }
79
+ return byMailbox;
80
+ }
81
+
82
+ /** Leaf segment of a provider path (`INBOX/Spam` → `Spam`). */
83
+ export const getMailboxDisplayName = (fullPath: string): string => {
84
+ const parts = fullPath.split("/");
85
+ return parts[parts.length - 1] || fullPath;
86
+ };
87
+
88
+ type Translator = (key: string, fallback: string) => string;
89
+
90
+ /**
91
+ * Render-ready label: a trimmed `displayNameOverride` wins, else the
92
+ * canonical localized label for the appointed role, else the provider leaf.
93
+ */
94
+ export function labelForMailbox(
95
+ mailbox: { fullPath: string; displayNameOverride?: string },
96
+ role: NavMailboxRole | undefined,
97
+ t?: Translator,
98
+ ): string {
99
+ const override = mailbox.displayNameOverride?.trim();
100
+ if (override) return override;
101
+ const leaf = getMailboxDisplayName(mailbox.fullPath);
102
+ if (!role || !t) return leaf;
103
+ return t(`sidebar.${role}`, leaf);
104
+ }
105
+
106
+ // Roles where a user-action-oriented unread badge isn't useful: Sent/Drafts
107
+ // weren't sent TO the user, and Trash is deleted mail (issue #195).
108
+ const COUNTLESS_ROLES = new Set<NavMailboxRole>(["sent", "drafts", "trash"]);
109
+
110
+ export const shouldShowUnreadBadgeForRole = (
111
+ role: NavMailboxRole | undefined,
112
+ ): boolean => role === undefined || !COUNTLESS_ROLES.has(role);
@@ -0,0 +1,115 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import {
4
+ formatDate,
5
+ formatDatePreset,
6
+ formatDeleteToTrashTitle,
7
+ formatEmailDate,
8
+ formatRelativeTime,
9
+ } from "./format.js";
10
+
11
+ // null/undefined can arrive at runtime even though the type forbids them.
12
+ const invalidInputs: Array<[string, unknown]> = [
13
+ ["Invalid Date", new Date("nope")],
14
+ ["null", null],
15
+ ["undefined", undefined],
16
+ ["empty string", ""],
17
+ ["whitespace string", " "],
18
+ ["unparseable string", "not-a-date"],
19
+ ];
20
+
21
+ describe("date formatters render invalid input as empty instead of throwing", () => {
22
+ for (const [label, value] of invalidInputs) {
23
+ test(`formatDate(${label})`, () => {
24
+ assert.strictEqual(
25
+ formatDate(value as Parameters<typeof formatDate>[0]),
26
+ "",
27
+ );
28
+ });
29
+ test(`formatDatePreset(${label})`, () => {
30
+ assert.strictEqual(
31
+ formatDatePreset(
32
+ value as Parameters<typeof formatDatePreset>[0],
33
+ "medium",
34
+ ),
35
+ "",
36
+ );
37
+ });
38
+ test(`formatRelativeTime(${label})`, () => {
39
+ assert.strictEqual(
40
+ formatRelativeTime(value as Parameters<typeof formatRelativeTime>[0]),
41
+ "",
42
+ );
43
+ });
44
+ test(`formatEmailDate(${label})`, () => {
45
+ assert.strictEqual(
46
+ formatEmailDate(value as Parameters<typeof formatEmailDate>[0]),
47
+ "",
48
+ );
49
+ });
50
+ }
51
+ });
52
+
53
+ describe("date formatters render valid input", () => {
54
+ const epochMs = Date.UTC(2023, 0, 17, 12, 0, 0);
55
+
56
+ test("epoch 0 is a valid date, not a fallback", () => {
57
+ assert.notStrictEqual(formatDate(0), "");
58
+ });
59
+
60
+ test("a Date, an epoch number, and the same epoch as a string agree", () => {
61
+ const fromNumber = formatDate(epochMs, { year: "numeric" });
62
+ const fromDate = formatDate(new Date(epochMs), { year: "numeric" });
63
+ const fromString = formatDate(String(epochMs), { year: "numeric" });
64
+ assert.strictEqual(fromNumber, "2023");
65
+ assert.strictEqual(fromDate, "2023");
66
+ assert.strictEqual(fromString, "2023");
67
+ });
68
+
69
+ test("formatRelativeTime renders a non-empty label for a recent date", () => {
70
+ assert.notStrictEqual(formatRelativeTime(Date.now() - 60_000), "");
71
+ });
72
+
73
+ test("an ISO string with separators goes through the Date parser, not the epoch branch", () => {
74
+ // The /^-?\d+$/ guard only matches pure digits, so a real business date
75
+ // string (with "-", "T", ":", "+") is parsed as a date, never as an epoch.
76
+ assert.strictEqual(
77
+ formatDate("2026-07-07T12:34:56+02:00", {
78
+ year: "numeric",
79
+ timeZone: "UTC",
80
+ }),
81
+ "2026",
82
+ );
83
+ });
84
+
85
+ test("an all-digit string is read as epoch MILLISECONDS", () => {
86
+ // toDate does Number(msString); a 13-digit value is milliseconds.
87
+ // Seconds-based sources must pre-multiply by 1000 at the CALL SITE — a
88
+ // 10-digit seconds value is the caller's responsibility, not toDate's.
89
+ const msString = String(Date.UTC(2023, 0, 17));
90
+ assert.strictEqual(
91
+ formatDate(msString, { year: "numeric", timeZone: "UTC" }),
92
+ "2023",
93
+ );
94
+ });
95
+ });
96
+
97
+ describe("formatDeleteToTrashTitle", () => {
98
+ test("uses the singular noun for one message", () => {
99
+ assert.strictEqual(formatDeleteToTrashTitle(1), "Move 1 message to Trash?");
100
+ });
101
+
102
+ test("uses the plural noun and the count for many messages", () => {
103
+ assert.strictEqual(
104
+ formatDeleteToTrashTitle(3),
105
+ "Move 3 messages to Trash?",
106
+ );
107
+ });
108
+
109
+ test("treats zero as plural", () => {
110
+ assert.strictEqual(
111
+ formatDeleteToTrashTitle(0),
112
+ "Move 0 messages to Trash?",
113
+ );
114
+ });
115
+ });