@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,84 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
4
+ import { compareAccounts, sortAccountsByCreatedAt } from "./account-order.js";
5
+
6
+ const account = (
7
+ overrides: Partial<RemitImapAccountResponse> &
8
+ Pick<RemitImapAccountResponse, "accountId" | "createdAt">,
9
+ ): RemitImapAccountResponse =>
10
+ ({
11
+ accountConfigId: "cfg-1",
12
+ username: "user",
13
+ email: "user@example.com",
14
+ imapHost: "imap.example.com",
15
+ imapPort: 993,
16
+ imapTls: true,
17
+ imapStartTls: false,
18
+ smtpHost: "smtp.example.com",
19
+ smtpPort: 587,
20
+ smtpTls: false,
21
+ smtpStartTls: true,
22
+ smtpUsername: "user",
23
+ isActive: true,
24
+ updatedAt: overrides.createdAt,
25
+ ...overrides,
26
+ }) as RemitImapAccountResponse;
27
+
28
+ describe("compareAccounts", () => {
29
+ test("oldest createdAt sorts first", () => {
30
+ const a = account({ accountId: "z", createdAt: 1000 });
31
+ const b = account({ accountId: "a", createdAt: 2000 });
32
+ assert.ok(compareAccounts(a, b) < 0);
33
+ assert.ok(compareAccounts(b, a) > 0);
34
+ });
35
+
36
+ test("ties on createdAt are broken by accountId ascending", () => {
37
+ const a = account({ accountId: "a", createdAt: 1000 });
38
+ const b = account({ accountId: "b", createdAt: 1000 });
39
+ assert.ok(compareAccounts(a, b) < 0);
40
+ assert.ok(compareAccounts(b, a) > 0);
41
+ });
42
+
43
+ test("identical accounts compare equal", () => {
44
+ const a = account({ accountId: "x", createdAt: 1000 });
45
+ const b = account({ accountId: "x", createdAt: 1000 });
46
+ assert.strictEqual(compareAccounts(a, b), 0);
47
+ });
48
+ });
49
+
50
+ describe("sortAccountsByCreatedAt", () => {
51
+ test("returns a new array sorted oldest-first", () => {
52
+ const input = [
53
+ account({ accountId: "c", createdAt: 3000 }),
54
+ account({ accountId: "a", createdAt: 1000 }),
55
+ account({ accountId: "b", createdAt: 2000 }),
56
+ ];
57
+ const sorted = sortAccountsByCreatedAt(input);
58
+ assert.deepStrictEqual(
59
+ sorted.map((a) => a.accountId),
60
+ ["a", "b", "c"],
61
+ );
62
+ assert.deepStrictEqual(
63
+ input.map((a) => a.accountId),
64
+ ["c", "a", "b"],
65
+ );
66
+ });
67
+
68
+ test("ties on createdAt sort by accountId", () => {
69
+ const input = [
70
+ account({ accountId: "z", createdAt: 1000 }),
71
+ account({ accountId: "a", createdAt: 1000 }),
72
+ account({ accountId: "m", createdAt: 1000 }),
73
+ ];
74
+ const sorted = sortAccountsByCreatedAt(input);
75
+ assert.deepStrictEqual(
76
+ sorted.map((a) => a.accountId),
77
+ ["a", "m", "z"],
78
+ );
79
+ });
80
+
81
+ test("empty input returns empty array", () => {
82
+ assert.deepStrictEqual(sortAccountsByCreatedAt([]), []);
83
+ });
84
+ });
@@ -0,0 +1,13 @@
1
+ import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
2
+
3
+ export const compareAccounts = (
4
+ a: RemitImapAccountResponse,
5
+ b: RemitImapAccountResponse,
6
+ ): number => {
7
+ if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
8
+ return a.accountId.localeCompare(b.accountId);
9
+ };
10
+
11
+ export const sortAccountsByCreatedAt = <T extends RemitImapAccountResponse>(
12
+ accounts: readonly T[],
13
+ ): T[] => [...accounts].sort(compareAccounts);
@@ -0,0 +1,43 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import { adjacentMessageId } from "./adjacent-message.js";
4
+
5
+ describe("adjacentMessageId", () => {
6
+ const ids = ["a", "b", "c"];
7
+
8
+ test("next returns the following id", () => {
9
+ assert.strictEqual(adjacentMessageId(ids, "a", "next"), "b");
10
+ assert.strictEqual(adjacentMessageId(ids, "b", "next"), "c");
11
+ });
12
+
13
+ test("previous returns the preceding id", () => {
14
+ assert.strictEqual(adjacentMessageId(ids, "c", "previous"), "b");
15
+ assert.strictEqual(adjacentMessageId(ids, "b", "previous"), "a");
16
+ });
17
+
18
+ test("returns null at the end of the list (no wrap)", () => {
19
+ assert.strictEqual(adjacentMessageId(ids, "c", "next"), null);
20
+ });
21
+
22
+ test("returns null at the start of the list (no wrap)", () => {
23
+ assert.strictEqual(adjacentMessageId(ids, "a", "previous"), null);
24
+ });
25
+
26
+ test("returns null when current id is not in the list", () => {
27
+ assert.strictEqual(adjacentMessageId(ids, "z", "next"), null);
28
+ assert.strictEqual(adjacentMessageId(ids, "z", "previous"), null);
29
+ });
30
+
31
+ test("returns null when there is no current id", () => {
32
+ assert.strictEqual(adjacentMessageId(ids, undefined, "next"), null);
33
+ });
34
+
35
+ test("returns null for a single-item list at both ends", () => {
36
+ assert.strictEqual(adjacentMessageId(["only"], "only", "next"), null);
37
+ assert.strictEqual(adjacentMessageId(["only"], "only", "previous"), null);
38
+ });
39
+
40
+ test("returns null for an empty list", () => {
41
+ assert.strictEqual(adjacentMessageId([], "a", "next"), null);
42
+ });
43
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Resolves the message id adjacent to `currentId` in `orderedIds`.
3
+ *
4
+ * The list is in display order (newest first). "next" walks down the list
5
+ * (older), "previous" walks up (newer) — matching the j/k keyboard navigation
6
+ * and the visual top-to-bottom order. Returns `null` at the ends of the list
7
+ * (no wrap) and when the current id is not present.
8
+ */
9
+ export const adjacentMessageId = (
10
+ orderedIds: string[],
11
+ currentId: string | undefined,
12
+ direction: "next" | "previous",
13
+ ): string | null => {
14
+ if (!currentId) return null;
15
+ const index = orderedIds.indexOf(currentId);
16
+ if (index === -1) return null;
17
+ const target = direction === "next" ? index + 1 : index - 1;
18
+ if (target < 0 || target >= orderedIds.length) return null;
19
+ return orderedIds[target];
20
+ };
package/src/lib/api.ts ADDED
@@ -0,0 +1,82 @@
1
+ export class ApiError extends Error {
2
+ constructor(
3
+ message: string,
4
+ public status: number,
5
+ public body?: unknown,
6
+ ) {
7
+ super(message);
8
+ this.name = "ApiError";
9
+ }
10
+ }
11
+
12
+ interface RequestOptions extends Omit<RequestInit, "body"> {
13
+ params?: Record<string, string | number | boolean | undefined>;
14
+ body?: unknown;
15
+ }
16
+
17
+ const buildUrl = (
18
+ path: string,
19
+ params?: Record<string, string | number | boolean | undefined>,
20
+ ): string => {
21
+ const url = new URL(path, window.location.origin);
22
+ if (params) {
23
+ Object.entries(params).forEach(([key, value]) => {
24
+ if (value !== undefined) {
25
+ url.searchParams.set(key, String(value));
26
+ }
27
+ });
28
+ }
29
+ return url.toString();
30
+ };
31
+
32
+ const request = async <T>(
33
+ method: string,
34
+ path: string,
35
+ options: RequestOptions = {},
36
+ ): Promise<T> => {
37
+ const { params, body, headers, ...rest } = options;
38
+
39
+ const url = buildUrl(path, params);
40
+
41
+ const response = await fetch(url, {
42
+ method,
43
+ headers: {
44
+ "Content-Type": "application/json",
45
+ ...headers,
46
+ },
47
+ body: body ? JSON.stringify(body) : undefined,
48
+ ...rest,
49
+ });
50
+
51
+ if (!response.ok) {
52
+ const errorBody = await response.json().catch(() => undefined);
53
+ throw new ApiError(
54
+ errorBody?.message || `Request failed with status ${response.status}`,
55
+ response.status,
56
+ errorBody,
57
+ );
58
+ }
59
+
60
+ if (response.status === 204) {
61
+ return undefined as T;
62
+ }
63
+
64
+ return response.json();
65
+ };
66
+
67
+ export const api = {
68
+ get: <T>(path: string, options?: RequestOptions) =>
69
+ request<T>("GET", path, options),
70
+
71
+ post: <T>(path: string, body?: unknown, options?: RequestOptions) =>
72
+ request<T>("POST", path, { ...options, body }),
73
+
74
+ put: <T>(path: string, body?: unknown, options?: RequestOptions) =>
75
+ request<T>("PUT", path, { ...options, body }),
76
+
77
+ patch: <T>(path: string, body?: unknown, options?: RequestOptions) =>
78
+ request<T>("PATCH", path, { ...options, body }),
79
+
80
+ delete: <T>(path: string, options?: RequestOptions) =>
81
+ request<T>("DELETE", path, options),
82
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Build-time constants injected by vite.config.ts via `define`.
3
+ * __APP_SHA__ is the full git SHA (or "dev" in local builds without git).
4
+ * __APP_BUILD_TIME__ is an ISO timestamp.
5
+ */
6
+
7
+ export const APP_SHA: string = __APP_SHA__;
8
+ export const APP_BUILD_TIME: string = __APP_BUILD_TIME__;
9
+
10
+ /** First 7 characters of the SHA, matching git's default short form. */
11
+ export const APP_SHORT_SHA: string = APP_SHA.slice(0, 7);
12
+
13
+ export const GITHUB_COMMIT_URL = `https://github.com/remit-mail/remit/commit/${APP_SHA}`;
14
+ export const GITHUB_NEW_ISSUE_URL =
15
+ "https://github.com/remit-mail/remit/issues/new";
@@ -0,0 +1,140 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import type { RemitImapAutoMovedInfo } from "@remit/api-http-client/types.gen.ts";
4
+ import { PlacementAction } from "@remit/domain-enums";
5
+ import {
6
+ type AutoMovedRoleMailboxes,
7
+ autoMovedLabel,
8
+ isAutoMoveInEffect,
9
+ resolveUndoTargetMailboxId,
10
+ } from "./auto-moved.js";
11
+
12
+ const ROLE_MAILBOXES: AutoMovedRoleMailboxes = {
13
+ inboxMailboxId: "mb-inbox",
14
+ junkMailboxId: "mb-junk",
15
+ };
16
+
17
+ describe("autoMovedLabel", () => {
18
+ test("reads 'Moved from Junk by Remit'", () => {
19
+ assert.equal(autoMovedLabel("junk"), "Moved from Junk by Remit");
20
+ });
21
+
22
+ test("reads 'Moved from Inbox by Remit'", () => {
23
+ assert.equal(autoMovedLabel("inbox"), "Moved from Inbox by Remit");
24
+ });
25
+
26
+ test("falls back to plain language for an unrecognized placement", () => {
27
+ assert.equal(autoMovedLabel("other"), "Moved from another folder by Remit");
28
+ });
29
+
30
+ test("never leaks verdict jargon", () => {
31
+ for (const placement of ["inbox", "junk", "other"]) {
32
+ assert.doesNotMatch(
33
+ autoMovedLabel(placement),
34
+ /confiden|dry.?run|verdict/i,
35
+ );
36
+ }
37
+ });
38
+ });
39
+
40
+ describe("isAutoMoveInEffect", () => {
41
+ test("false when autoMoved is absent", () => {
42
+ assert.equal(
43
+ isAutoMoveInEffect(undefined, "mb-inbox", ROLE_MAILBOXES),
44
+ false,
45
+ );
46
+ });
47
+
48
+ test("false when currentMailboxId is absent", () => {
49
+ const autoMoved: RemitImapAutoMovedInfo = {
50
+ action: PlacementAction.MoveToInbox,
51
+ fromPlacement: "junk",
52
+ };
53
+ assert.equal(
54
+ isAutoMoveInEffect(autoMoved, undefined, ROLE_MAILBOXES),
55
+ false,
56
+ );
57
+ });
58
+
59
+ test("true when a MoveToInbox message currently sits in the Inbox mailbox", () => {
60
+ const autoMoved: RemitImapAutoMovedInfo = {
61
+ action: PlacementAction.MoveToInbox,
62
+ fromPlacement: "junk",
63
+ };
64
+ assert.equal(
65
+ isAutoMoveInEffect(autoMoved, "mb-inbox", ROLE_MAILBOXES),
66
+ true,
67
+ );
68
+ });
69
+
70
+ test("true when a MoveToJunk message currently sits in the Junk mailbox", () => {
71
+ const autoMoved: RemitImapAutoMovedInfo = {
72
+ action: PlacementAction.MoveToJunk,
73
+ fromPlacement: "inbox",
74
+ };
75
+ assert.equal(
76
+ isAutoMoveInEffect(autoMoved, "mb-junk", ROLE_MAILBOXES),
77
+ true,
78
+ );
79
+ });
80
+
81
+ test("false once the message has moved elsewhere (self-hide on reconcile)", () => {
82
+ const autoMoved: RemitImapAutoMovedInfo = {
83
+ action: PlacementAction.MoveToInbox,
84
+ fromPlacement: "junk",
85
+ };
86
+ assert.equal(
87
+ isAutoMoveInEffect(autoMoved, "mb-archive", ROLE_MAILBOXES),
88
+ false,
89
+ );
90
+ // Undone back to Junk — no longer in the implied destination.
91
+ assert.equal(
92
+ isAutoMoveInEffect(autoMoved, "mb-junk", ROLE_MAILBOXES),
93
+ false,
94
+ );
95
+ });
96
+
97
+ test("false when the implied destination mailbox hasn't resolved yet", () => {
98
+ const autoMoved: RemitImapAutoMovedInfo = {
99
+ action: PlacementAction.MoveToInbox,
100
+ fromPlacement: "junk",
101
+ };
102
+ assert.equal(
103
+ isAutoMoveInEffect(autoMoved, "mb-inbox", {
104
+ inboxMailboxId: undefined,
105
+ junkMailboxId: "mb-junk",
106
+ }),
107
+ false,
108
+ );
109
+ });
110
+ });
111
+
112
+ describe("resolveUndoTargetMailboxId", () => {
113
+ test("resolves the Junk mailbox for fromPlacement='junk'", () => {
114
+ assert.equal(resolveUndoTargetMailboxId("junk", ROLE_MAILBOXES), "mb-junk");
115
+ });
116
+
117
+ test("resolves the Inbox mailbox for fromPlacement='inbox'", () => {
118
+ assert.equal(
119
+ resolveUndoTargetMailboxId("inbox", ROLE_MAILBOXES),
120
+ "mb-inbox",
121
+ );
122
+ });
123
+
124
+ test("undefined for an unresolvable placement", () => {
125
+ assert.equal(
126
+ resolveUndoTargetMailboxId("other", ROLE_MAILBOXES),
127
+ undefined,
128
+ );
129
+ });
130
+
131
+ test("undefined when the role mailbox hasn't resolved yet", () => {
132
+ assert.equal(
133
+ resolveUndoTargetMailboxId("junk", {
134
+ inboxMailboxId: "mb-inbox",
135
+ junkMailboxId: undefined,
136
+ }),
137
+ undefined,
138
+ );
139
+ });
140
+ });
@@ -0,0 +1,68 @@
1
+ import type { RemitImapAutoMovedInfo } from "@remit/api-http-client/types.gen.ts";
2
+ import { PlacementAction } from "@remit/domain-enums";
3
+
4
+ /** The two folder roles the account's mailboxes can resolve for this feature. */
5
+ export interface AutoMovedRoleMailboxes {
6
+ inboxMailboxId: string | undefined;
7
+ junkMailboxId: string | undefined;
8
+ }
9
+
10
+ const FROM_PLACEMENT_LABEL: Record<string, string> = {
11
+ inbox: "Inbox",
12
+ junk: "Junk",
13
+ };
14
+
15
+ /**
16
+ * Plain-language badge text, e.g. "Moved from Junk by Remit". No jargon
17
+ * (verdict/confidence/dryRun never surface) — mirrors the no-jargon precedent
18
+ * in `rescue-candidates.ts`.
19
+ */
20
+ export const autoMovedLabel = (fromPlacement: string): string =>
21
+ `Moved from ${FROM_PLACEMENT_LABEL[fromPlacement] ?? "another folder"} by Remit`;
22
+
23
+ /**
24
+ * The mailbox the verdict's `action` implies as the destination — where the
25
+ * message should currently sit for the move to still be "in effect".
26
+ */
27
+ const impliedDestinationMailboxId = (
28
+ action: string,
29
+ mailboxes: AutoMovedRoleMailboxes,
30
+ ): string | undefined => {
31
+ if (action === PlacementAction.MoveToInbox) return mailboxes.inboxMailboxId;
32
+ if (action === PlacementAction.MoveToJunk) return mailboxes.junkMailboxId;
33
+ return undefined;
34
+ };
35
+
36
+ /**
37
+ * True only while the auto-move is still in effect: the message currently
38
+ * sits in the mailbox the verdict's `action` implied. Once the message moves
39
+ * elsewhere (an undo, or any other move), this returns false — the badge has
40
+ * no local "dismissed" flag; it re-derives from current placement every
41
+ * render, per the data-flow rule that a projection never forks from its
42
+ * source (doc/rules/data-flow.md).
43
+ */
44
+ export const isAutoMoveInEffect = (
45
+ autoMoved: RemitImapAutoMovedInfo | undefined,
46
+ currentMailboxId: string | undefined,
47
+ mailboxes: AutoMovedRoleMailboxes,
48
+ ): boolean => {
49
+ if (!autoMoved || !currentMailboxId) return false;
50
+ const destination = impliedDestinationMailboxId(autoMoved.action, mailboxes);
51
+ return destination !== undefined && destination === currentMailboxId;
52
+ };
53
+
54
+ /**
55
+ * Resolve the undo destination: the mailbox filling the role the message was
56
+ * moved from. `fromPlacement` is always `inbox` or `junk` for an actionable
57
+ * verdict (the Tier 0 classifier only fires move-to-inbox from junk and
58
+ * move-to-junk from inbox — see `classifyPlacement.ts`); `undefined` when the
59
+ * account has no mailbox appointed to that role.
60
+ */
61
+ export const resolveUndoTargetMailboxId = (
62
+ fromPlacement: string,
63
+ mailboxes: AutoMovedRoleMailboxes,
64
+ ): string | undefined => {
65
+ if (fromPlacement === "inbox") return mailboxes.inboxMailboxId;
66
+ if (fromPlacement === "junk") return mailboxes.junkMailboxId;
67
+ return undefined;
68
+ };
@@ -0,0 +1,94 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ discoverSettings,
5
+ getAppPasswordUrl,
6
+ getDiscoveryStatusMessage,
7
+ } from "./autodiscovery.js";
8
+
9
+ // Provider table unit tests for top 5 providers (no network calls)
10
+ describe("autodiscovery provider table", () => {
11
+ it("resolves Gmail", async () => {
12
+ const result = await discoverSettings("user@gmail.com");
13
+ assert.ok(result, "should find Gmail");
14
+ assert.equal(result.source, "provider-table");
15
+ assert.equal(result.imap.host, "imap.gmail.com");
16
+ assert.equal(result.imap.port, 993);
17
+ assert.equal(result.imap.security, "tls");
18
+ assert.equal(result.smtp.host, "smtp.gmail.com");
19
+ assert.equal(result.smtp.port, 587);
20
+ assert.equal(result.smtp.security, "starttls");
21
+ });
22
+
23
+ it("resolves iCloud", async () => {
24
+ const result = await discoverSettings("user@icloud.com");
25
+ assert.ok(result, "should find iCloud");
26
+ assert.equal(result.source, "provider-table");
27
+ assert.equal(result.imap.host, "imap.mail.me.com");
28
+ assert.equal(result.imap.port, 993);
29
+ assert.equal(result.smtp.security, "starttls");
30
+ });
31
+
32
+ it("resolves Fastmail", async () => {
33
+ const result = await discoverSettings("user@fastmail.com");
34
+ assert.ok(result, "should find Fastmail");
35
+ assert.equal(result.source, "provider-table");
36
+ assert.equal(result.imap.host, "imap.fastmail.com");
37
+ assert.equal(result.smtp.host, "smtp.fastmail.com");
38
+ assert.equal(result.smtp.port, 465);
39
+ assert.equal(result.smtp.security, "tls");
40
+ });
41
+
42
+ it("resolves Outlook", async () => {
43
+ const result = await discoverSettings("user@outlook.com");
44
+ assert.ok(result, "should find Outlook");
45
+ assert.equal(result.source, "provider-table");
46
+ assert.equal(result.imap.host, "outlook.office365.com");
47
+ assert.equal(result.smtp.host, "smtp.office365.com");
48
+ });
49
+
50
+ it("resolves Yahoo", async () => {
51
+ const result = await discoverSettings("user@yahoo.com");
52
+ assert.ok(result, "should find Yahoo");
53
+ assert.equal(result.source, "provider-table");
54
+ assert.equal(result.imap.host, "imap.mail.yahoo.com");
55
+ assert.equal(result.smtp.security, "tls");
56
+ });
57
+
58
+ it("returns heuristic for unknown domain", async () => {
59
+ // Use a clearly fake domain — network calls will fail/timeout
60
+ // In the test environment there's no network, so it falls to heuristic
61
+ const result = await discoverSettings(
62
+ "user@no-such-domain-xyz123.test",
63
+ 100,
64
+ );
65
+ assert.ok(result, "should return heuristic result");
66
+ assert.equal(result.source, "heuristic");
67
+ assert.equal(result.imap.host, "imap.no-such-domain-xyz123.test");
68
+ assert.equal(result.smtp.host, "smtp.no-such-domain-xyz123.test");
69
+ });
70
+
71
+ it("returns null for invalid email", async () => {
72
+ const result = await discoverSettings("not-an-email");
73
+ assert.equal(result, null);
74
+ });
75
+ });
76
+
77
+ describe("getDiscoveryStatusMessage", () => {
78
+ it("includes domain in message", () => {
79
+ const msg = getDiscoveryStatusMessage("user@fastmail.com");
80
+ assert.ok(msg.includes("fastmail.com"), "should include domain");
81
+ });
82
+ });
83
+
84
+ describe("getAppPasswordUrl", () => {
85
+ it("returns Gmail app password URL", () => {
86
+ const url = getAppPasswordUrl("user@gmail.com");
87
+ assert.ok(url?.includes("google.com"), "should include google.com");
88
+ });
89
+
90
+ it("returns undefined for unknown domain", () => {
91
+ const url = getAppPasswordUrl("user@unknown-provider.test");
92
+ assert.equal(url, undefined);
93
+ });
94
+ });