@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,195 @@
1
+ import assert from "node:assert";
2
+ import { describe, test } from "node:test";
3
+ import type {
4
+ RemitImapFolderAppointment,
5
+ RemitImapMailboxResponse,
6
+ } from "@remit/api-http-client/types.gen.ts";
7
+ import { buildMoveTargets } from "./move-targets.js";
8
+
9
+ const make = (
10
+ overrides: Partial<RemitImapMailboxResponse> & {
11
+ mailboxId: string;
12
+ fullPath: string;
13
+ },
14
+ ): RemitImapMailboxResponse =>
15
+ ({
16
+ accountId: "acct-1",
17
+ namespaceType: "personal",
18
+ namespacePrefix: "",
19
+ hierarchyDelimiter: "/",
20
+ messageCount: 0,
21
+ unseenCount: 0,
22
+ deletedCount: 0,
23
+ createdAt: 0,
24
+ updatedAt: 0,
25
+ ...overrides,
26
+ }) as RemitImapMailboxResponse;
27
+
28
+ const appoint = (
29
+ role: RemitImapFolderAppointment["role"],
30
+ mailboxId: string,
31
+ ): RemitImapFolderAppointment => ({ role, mailboxId });
32
+
33
+ describe("buildMoveTargets — excluded destinations (#236, #976)", () => {
34
+ test("drops the account's appointed Drafts and Sent mailboxes", () => {
35
+ const result = buildMoveTargets(
36
+ [
37
+ make({ mailboxId: "m1", fullPath: "INBOX" }),
38
+ make({ mailboxId: "m2", fullPath: "Drafts" }),
39
+ make({ mailboxId: "m3", fullPath: "Sent" }),
40
+ ],
41
+ [appoint("Drafts", "m2"), appoint("Sent", "m3")],
42
+ );
43
+ const ids = result.map((mailbox) => mailbox.mailboxId);
44
+ assert.deepStrictEqual(ids, ["m1"]);
45
+ });
46
+
47
+ test("keeps an UNappointed Drafts/Sent look-alike — exclusion follows the appointment, not the name", () => {
48
+ // Hostnet-style: INBOX/Drafts carries the flag but is empty; the user
49
+ // appointed INBOX/Concepten instead. INBOX/Drafts is now just a plain
50
+ // folder and a valid move destination.
51
+ const result = buildMoveTargets(
52
+ [
53
+ make({ mailboxId: "inbox", fullPath: "INBOX" }),
54
+ make({ mailboxId: "empty-drafts", fullPath: "INBOX/Drafts" }),
55
+ make({ mailboxId: "concepten", fullPath: "INBOX/Concepten" }),
56
+ ],
57
+ [appoint("Drafts", "concepten")],
58
+ );
59
+ const ids = result.map((mailbox) => mailbox.mailboxId).sort();
60
+ assert.deepStrictEqual(ids, ["empty-drafts", "inbox"]);
61
+ });
62
+
63
+ test("with no appointments at all, nothing is excluded by role (only Outbox by name)", () => {
64
+ const result = buildMoveTargets([
65
+ make({ mailboxId: "m1", fullPath: "INBOX" }),
66
+ make({ mailboxId: "m2", fullPath: "Drafts" }),
67
+ make({ mailboxId: "m3", fullPath: "Outbox" }),
68
+ make({ mailboxId: "m4", fullPath: "Sent Mail" }),
69
+ ]);
70
+ const ids = result.map((mailbox) => mailbox.mailboxId).sort();
71
+ assert.deepStrictEqual(ids, ["m1", "m2", "m4"]);
72
+ });
73
+
74
+ test("keeps Trash and Spam — both are valid manual destinations", () => {
75
+ const result = buildMoveTargets(
76
+ [
77
+ make({ mailboxId: "trash", fullPath: "Trash" }),
78
+ make({ mailboxId: "spam", fullPath: "Spam" }),
79
+ make({ mailboxId: "inbox", fullPath: "INBOX" }),
80
+ ],
81
+ [
82
+ appoint("Trash", "trash"),
83
+ appoint("Junk", "spam"),
84
+ appoint("Inbox", "inbox"),
85
+ ],
86
+ );
87
+ const ids = result.map((mailbox) => mailbox.mailboxId).sort();
88
+ assert.deepStrictEqual(ids, ["inbox", "spam", "trash"]);
89
+ });
90
+
91
+ test("keeps user-defined folders", () => {
92
+ const result = buildMoveTargets([
93
+ make({ mailboxId: "m1", fullPath: "INBOX" }),
94
+ make({ mailboxId: "m2", fullPath: "Project Alpha" }),
95
+ make({ mailboxId: "m3", fullPath: "Receipts/2025" }),
96
+ ]);
97
+ const ids = result.map((mailbox) => mailbox.mailboxId);
98
+ assert.deepStrictEqual(ids.sort(), ["m1", "m2", "m3"]);
99
+ });
100
+ });
101
+
102
+ describe("buildMoveTargets — sort order", () => {
103
+ test("orders appointed system folders by role priority, ahead of plain folders", () => {
104
+ const result = buildMoveTargets(
105
+ [
106
+ make({ mailboxId: "custom", fullPath: "Project Alpha" }),
107
+ make({ mailboxId: "trash", fullPath: "Trash" }),
108
+ make({ mailboxId: "archive", fullPath: "Archive" }),
109
+ make({ mailboxId: "inbox", fullPath: "INBOX" }),
110
+ ],
111
+ [
112
+ appoint("Trash", "trash"),
113
+ appoint("Archive", "archive"),
114
+ appoint("Inbox", "inbox"),
115
+ ],
116
+ );
117
+ assert.deepStrictEqual(
118
+ result.map((m) => m.mailboxId),
119
+ ["inbox", "archive", "trash", "custom"],
120
+ );
121
+ });
122
+
123
+ test("plain folders sort alphabetically, case-insensitively", () => {
124
+ const result = buildMoveTargets([
125
+ make({ mailboxId: "b", fullPath: "banana" }),
126
+ make({ mailboxId: "a", fullPath: "Apple" }),
127
+ ]);
128
+ assert.deepStrictEqual(
129
+ result.map((m) => m.mailboxId),
130
+ ["a", "b"],
131
+ );
132
+ });
133
+ });
134
+
135
+ describe("buildMoveTargets — Outbox locale exclusion (#290)", () => {
136
+ // Outbox has no IMAP special-use flag and isn't a canonical role, so we
137
+ // recognize it by name across the locales the curated list covers.
138
+ const localizedOutboxNames: readonly string[] = [
139
+ "Outbox",
140
+ "Postvak UIT",
141
+ "Boîte d'envoi",
142
+ "Postausgang",
143
+ "Buzón de salida",
144
+ "Posta in uscita",
145
+ "送信トレイ",
146
+ "送件匣",
147
+ "发件箱",
148
+ "Skrzynka nadawcza",
149
+ ];
150
+
151
+ for (const name of localizedOutboxNames) {
152
+ test(`drops localized Outbox "${name}"`, () => {
153
+ const result = buildMoveTargets([
154
+ make({ mailboxId: "inbox", fullPath: "INBOX" }),
155
+ make({ mailboxId: "outbox", fullPath: name }),
156
+ ]);
157
+ const ids = result.map((mailbox) => mailbox.mailboxId);
158
+ assert.deepStrictEqual(ids, ["inbox"]);
159
+ });
160
+ }
161
+
162
+ test("matches Outbox locale names case-insensitively", () => {
163
+ const result = buildMoveTargets([
164
+ make({ mailboxId: "inbox", fullPath: "INBOX" }),
165
+ make({ mailboxId: "m1", fullPath: "POSTVAK UIT" }),
166
+ make({ mailboxId: "m2", fullPath: "postausgang" }),
167
+ make({ mailboxId: "m3", fullPath: "BOÎTE D'ENVOI" }),
168
+ ]);
169
+ const ids = result.map((mailbox) => mailbox.mailboxId);
170
+ assert.deepStrictEqual(ids, ["inbox"]);
171
+ });
172
+
173
+ test("matches Outbox locale names with surrounding whitespace", () => {
174
+ const result = buildMoveTargets([
175
+ make({ mailboxId: "inbox", fullPath: "INBOX" }),
176
+ make({ mailboxId: "m1", fullPath: " Outbox " }),
177
+ make({ mailboxId: "m2", fullPath: " Postvak UIT " }),
178
+ ]);
179
+ const ids = result.map((mailbox) => mailbox.mailboxId);
180
+ assert.deepStrictEqual(ids, ["inbox"]);
181
+ });
182
+
183
+ test("does not over-match unrelated mailbox names", () => {
184
+ const result = buildMoveTargets([
185
+ make({ mailboxId: "inbox", fullPath: "INBOX" }),
186
+ make({ mailboxId: "trash", fullPath: "Trash" }),
187
+ make({ mailboxId: "custom", fullPath: "Custom Folder" }),
188
+ // Substring of a locale name must not trigger the filter — only
189
+ // the exact normalized leaf matches.
190
+ make({ mailboxId: "outboxy", fullPath: "Outbox Archive" }),
191
+ ]);
192
+ const ids = result.map((mailbox) => mailbox.mailboxId).sort();
193
+ assert.deepStrictEqual(ids, ["custom", "inbox", "outboxy", "trash"]);
194
+ });
195
+ });
@@ -0,0 +1,74 @@
1
+ import type {
2
+ RemitImapFolderAppointment,
3
+ RemitImapMailboxResponse,
4
+ } from "@remit/api-http-client/types.gen.ts";
5
+ import {
6
+ buildMailboxRoleMap,
7
+ getMailboxDisplayName,
8
+ ROLE_PRIORITY,
9
+ } from "./folder-roles.js";
10
+
11
+ // Outbox has no IMAP special-use flag (RFC 6154 doesn't define one) and isn't
12
+ // a canonical role (RFC 032 exclusive-folder-appointment) either — it's
13
+ // Remit's own concept, so it isn't in the account's role map. KISS: covers the
14
+ // languages we expect to encounter; extend as needed. Stored lowercased and
15
+ // trimmed; comparisons normalize the input the same way.
16
+ const OUTBOX_LOCALE_NAMES: ReadonlySet<string> = new Set([
17
+ "outbox",
18
+ "postvak uit",
19
+ "boîte d'envoi",
20
+ "postausgang",
21
+ "buzón de salida",
22
+ "posta in uscita",
23
+ "送信トレイ",
24
+ "送件匣",
25
+ "发件箱",
26
+ "skrzynka nadawcza",
27
+ ]);
28
+
29
+ const isOutboxDestination = (mailbox: RemitImapMailboxResponse): boolean => {
30
+ const leaf = getMailboxDisplayName(mailbox.fullPath).trim().toLowerCase();
31
+ return OUTBOX_LOCALE_NAMES.has(leaf);
32
+ };
33
+
34
+ const NON_SYSTEM_PRIORITY = ROLE_PRIORITY.length;
35
+
36
+ /**
37
+ * Filter and order a flat mailbox list for the Move-to picker (RFC 032
38
+ * exclusive-folder-appointment, #976).
39
+ *
40
+ * Drops the account's appointed Drafts and Sent mailboxes (issue #236) —
41
+ * compose/SMTP flows manage those, moving an arbitrary message into them
42
+ * would break the invariant that they only hold messages the user authored —
43
+ * and Outbox by locale name. Everything else, including a Drafts/Sent
44
+ * look-alike the user hasn't actually appointed (e.g. an empty `INBOX/Drafts`
45
+ * twin next to the real `INBOX/Concepten`), is a perfectly valid destination:
46
+ * the exclusion follows the account's role appointment, not folder-name or
47
+ * SPECIAL-USE guessing. Sorts by the same role priority the sidebar pins by,
48
+ * then alphabetically.
49
+ */
50
+ export const buildMoveTargets = (
51
+ mailboxes: readonly RemitImapMailboxResponse[],
52
+ folderAppointments: readonly RemitImapFolderAppointment[] = [],
53
+ ): RemitImapMailboxResponse[] => {
54
+ const roleMap = buildMailboxRoleMap(folderAppointments);
55
+ const filtered = mailboxes.filter((mailbox) => {
56
+ const role = roleMap.get(mailbox.mailboxId);
57
+ if (role === "drafts" || role === "sent") return false;
58
+ return !isOutboxDestination(mailbox);
59
+ });
60
+ return [...filtered].sort((a, b) => {
61
+ const aPriority = roleMap.has(a.mailboxId)
62
+ ? ROLE_PRIORITY.indexOf(roleMap.get(a.mailboxId) ?? "inbox")
63
+ : NON_SYSTEM_PRIORITY;
64
+ const bPriority = roleMap.has(b.mailboxId)
65
+ ? ROLE_PRIORITY.indexOf(roleMap.get(b.mailboxId) ?? "inbox")
66
+ : NON_SYSTEM_PRIORITY;
67
+ if (aPriority !== bPriority) return aPriority - bPriority;
68
+ return getMailboxDisplayName(a.fullPath).localeCompare(
69
+ getMailboxDisplayName(b.fullPath),
70
+ undefined,
71
+ { sensitivity: "base", numeric: true },
72
+ );
73
+ });
74
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The onboarding wizard creates the account server-side, then the user clicks
3
+ * "Go to inbox". The /mail first-run guard redirects back to /onboarding while
4
+ * the cached config still reports zero accounts, so completion must refresh
5
+ * config and wait for it before navigating — otherwise the user loops back into
6
+ * the wizard.
7
+ */
8
+
9
+ import assert from "node:assert/strict";
10
+ import { describe, it } from "node:test";
11
+ import { completeOnboarding } from "./onboarding-completion.js";
12
+
13
+ describe("completeOnboarding", () => {
14
+ it("navigates to the inbox only after config is refreshed", async () => {
15
+ const order: string[] = [];
16
+ let resolveRefetch: (() => void) | undefined;
17
+
18
+ const refetchQueries = () => {
19
+ order.push("refetch:start");
20
+ return new Promise<void>((resolve) => {
21
+ resolveRefetch = () => {
22
+ order.push("refetch:done");
23
+ resolve();
24
+ };
25
+ });
26
+ };
27
+
28
+ const done = completeOnboarding({
29
+ queryClient: { refetchQueries },
30
+ recordCompleted: () => order.push("recorded"),
31
+ navigateToInbox: () => order.push("navigate"),
32
+ });
33
+
34
+ // Refetch is in flight; navigation must not have happened yet.
35
+ await Promise.resolve();
36
+ assert.deepEqual(order, ["recorded", "refetch:start"]);
37
+
38
+ resolveRefetch?.();
39
+ await done;
40
+
41
+ assert.deepEqual(order, [
42
+ "recorded",
43
+ "refetch:start",
44
+ "refetch:done",
45
+ "navigate",
46
+ ]);
47
+ });
48
+
49
+ it("refreshes the config query", async () => {
50
+ let refetchedKey: unknown;
51
+ await completeOnboarding({
52
+ queryClient: {
53
+ refetchQueries: (filters) => {
54
+ refetchedKey = (filters as { queryKey?: unknown }).queryKey;
55
+ return Promise.resolve();
56
+ },
57
+ },
58
+ recordCompleted: () => {},
59
+ navigateToInbox: () => {},
60
+ });
61
+
62
+ assert.ok(refetchedKey, "refetchQueries called with a query key");
63
+ });
64
+ });
@@ -0,0 +1,23 @@
1
+ import { configOperationsGetConfigQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { QueryClient } from "@tanstack/react-query";
3
+
4
+ /**
5
+ * Finishing onboarding creates the account server-side, but the cached config
6
+ * still reports zero accounts. The /mail route's first-run guard redirects to
7
+ * /onboarding whenever config reports no accounts, so navigating to the inbox
8
+ * before the cache is refreshed bounces the user straight back into the wizard.
9
+ *
10
+ * Refresh config and wait for it to settle before navigating, so the guard sees
11
+ * the freshly created account.
12
+ */
13
+ export async function completeOnboarding(deps: {
14
+ queryClient: Pick<QueryClient, "refetchQueries">;
15
+ recordCompleted: () => void;
16
+ navigateToInbox: () => void;
17
+ }): Promise<void> {
18
+ deps.recordCompleted();
19
+ await deps.queryClient.refetchQueries({
20
+ queryKey: configOperationsGetConfigQueryKey(),
21
+ });
22
+ deps.navigateToInbox();
23
+ }
@@ -0,0 +1,99 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ filterDisplayStatus,
5
+ formatExpiresAt,
6
+ pickedDateToExpiresAt,
7
+ } from "./filter-status";
8
+
9
+ const NOW = Date.parse("2026-07-12T12:00:00Z");
10
+
11
+ describe("filterDisplayStatus", () => {
12
+ it("treats a Standing filter as always Active", () => {
13
+ assert.equal(
14
+ filterDisplayStatus({ scope: "Standing", state: "Active" }, NOW),
15
+ "Active",
16
+ );
17
+ });
18
+
19
+ it("treats a Temporary filter past its expiresAt as Expired even when state still caches Active (RFC 034 Decision 1.2)", () => {
20
+ assert.equal(
21
+ filterDisplayStatus(
22
+ {
23
+ scope: "Temporary",
24
+ state: "Active",
25
+ expiresAt: "2026-07-10T00:00:00Z",
26
+ },
27
+ NOW,
28
+ ),
29
+ "Expired",
30
+ );
31
+ });
32
+
33
+ it("treats a Temporary filter before its expiresAt as Active", () => {
34
+ assert.equal(
35
+ filterDisplayStatus(
36
+ {
37
+ scope: "Temporary",
38
+ state: "Active",
39
+ expiresAt: "2026-07-20T00:00:00Z",
40
+ },
41
+ NOW,
42
+ ),
43
+ "Active",
44
+ );
45
+ });
46
+
47
+ it("honors a state the server already flipped to Expired", () => {
48
+ assert.equal(
49
+ filterDisplayStatus(
50
+ {
51
+ scope: "Temporary",
52
+ state: "Expired",
53
+ expiresAt: "2026-07-20T00:00:00Z",
54
+ },
55
+ NOW,
56
+ ),
57
+ "Expired",
58
+ );
59
+ });
60
+ });
61
+
62
+ describe("formatExpiresAt", () => {
63
+ it("returns undefined for a missing value", () => {
64
+ assert.equal(formatExpiresAt(undefined), undefined);
65
+ });
66
+
67
+ it("returns undefined for an unparseable value", () => {
68
+ assert.equal(formatExpiresAt("not-a-date"), undefined);
69
+ });
70
+
71
+ it("renders a parseable date", () => {
72
+ assert.equal(typeof formatExpiresAt("2026-07-16T23:59:59+02:00"), "string");
73
+ });
74
+ });
75
+
76
+ describe("pickedDateToExpiresAt", () => {
77
+ it("returns undefined for empty input", () => {
78
+ assert.equal(pickedDateToExpiresAt(""), undefined);
79
+ });
80
+
81
+ it("returns undefined for a non date-only string", () => {
82
+ assert.equal(pickedDateToExpiresAt("2026/07/16"), undefined);
83
+ });
84
+
85
+ it("produces an ISO 8601 timestamp with a zone offset at end of the picked day", () => {
86
+ const utcReference = new Date("2026-07-12T12:00:00Z");
87
+ const result = pickedDateToExpiresAt("2026-07-16", utcReference);
88
+ assert.equal(result, "2026-07-16T23:59:59+00:00");
89
+ });
90
+
91
+ it("round-trips back to the same calendar day it was expiring on", () => {
92
+ const iso = pickedDateToExpiresAt(
93
+ "2026-07-16",
94
+ new Date("2026-07-12T12:00:00Z"),
95
+ );
96
+ assert.ok(iso);
97
+ assert.equal(new Date(Date.parse(iso)).getUTCDate(), 16);
98
+ });
99
+ });
@@ -0,0 +1,86 @@
1
+ import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
2
+
3
+ export type FilterDisplayStatus = "Active" | "Expired";
4
+
5
+ /**
6
+ * The displayed Active/Expired status of a filter.
7
+ *
8
+ * RFC 034 Decision 1.2: `expiresAt` is the source of truth, `state` is only a
9
+ * lazily-patched cache. A `Temporary` filter whose `expiresAt` is in the past
10
+ * is Expired the instant that comparison is made, even if the server has not
11
+ * yet patched `state` to `Expired`. So the client compares `expiresAt` against
12
+ * `now` directly and also honors a `state` the server already flipped.
13
+ *
14
+ * An `Expired` filter is never hidden — it stays listed so the user can see
15
+ * what it was and when it stopped (RFC 034 Decision 1.2, "stops on its own").
16
+ */
17
+ export const filterDisplayStatus = (
18
+ filter: Pick<RemitImapFilterResponse, "scope" | "expiresAt" | "state">,
19
+ now: number,
20
+ ): FilterDisplayStatus => {
21
+ if (filter.state === "Expired") return "Expired";
22
+ if (filter.scope !== "Temporary") return "Active";
23
+ if (!filter.expiresAt) return "Active";
24
+
25
+ const expiresAtMs = Date.parse(filter.expiresAt);
26
+ if (Number.isNaN(expiresAtMs)) return "Active";
27
+ return expiresAtMs <= now ? "Expired" : "Active";
28
+ };
29
+
30
+ /**
31
+ * Human-readable rendering of an expiry timestamp, e.g. "Jul 16, 2026".
32
+ * Returns `undefined` for an absent or unparseable value so a standing filter
33
+ * (no `expiresAt`) renders nothing.
34
+ */
35
+ export const formatExpiresAt = (
36
+ expiresAt: string | undefined,
37
+ ): string | undefined => {
38
+ if (!expiresAt) return undefined;
39
+ const ms = Date.parse(expiresAt);
40
+ if (Number.isNaN(ms)) return undefined;
41
+ return new Date(ms).toLocaleDateString(undefined, {
42
+ year: "numeric",
43
+ month: "short",
44
+ day: "numeric",
45
+ });
46
+ };
47
+
48
+ /**
49
+ * Turn a plain picked calendar date (`YYYY-MM-DD`, from a native date input)
50
+ * into an ISO 8601 timestamp with the local zone offset, at end of that day —
51
+ * the filter is meant to stay active through the whole picked day (RFC 034
52
+ * Decision 1.1, a zoned civil date). Returns `undefined` for empty input.
53
+ */
54
+ export const pickedDateToExpiresAt = (
55
+ pickedDate: string,
56
+ referenceDate: Date = new Date(),
57
+ ): string | undefined => {
58
+ if (!pickedDate) return undefined;
59
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(pickedDate);
60
+ if (!match) return undefined;
61
+
62
+ const [, year, month, day] = match;
63
+ const local = new Date(
64
+ Number(year),
65
+ Number(month) - 1,
66
+ Number(day),
67
+ 23,
68
+ 59,
69
+ 59,
70
+ 0,
71
+ );
72
+ if (Number.isNaN(local.getTime())) return undefined;
73
+
74
+ const offsetMinutes = -referenceDate.getTimezoneOffset();
75
+ const sign = offsetMinutes >= 0 ? "+" : "-";
76
+ const abs = Math.abs(offsetMinutes);
77
+ const offHours = String(Math.floor(abs / 60)).padStart(2, "0");
78
+ const offMins = String(abs % 60).padStart(2, "0");
79
+
80
+ const pad = (value: number) => String(value).padStart(2, "0");
81
+ return (
82
+ `${local.getFullYear()}-${pad(local.getMonth() + 1)}-${pad(local.getDate())}` +
83
+ `T${pad(local.getHours())}:${pad(local.getMinutes())}:${pad(local.getSeconds())}` +
84
+ `${sign}${offHours}:${offMins}`
85
+ );
86
+ };
@@ -0,0 +1,93 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ commitButtonLabel,
5
+ commitDisabledReason,
6
+ scopeActionCount,
7
+ } from "./organize-copy";
8
+ import type { OrganizeDraft } from "./organize-model";
9
+
10
+ const draft = (overrides: Partial<OrganizeDraft> = {}): OrganizeDraft => ({
11
+ matchOperator: "And",
12
+ literalClauses: [],
13
+ ...overrides,
14
+ });
15
+
16
+ describe("commitDisabledReason", () => {
17
+ it("blocks with a labeling explanation when no folder is chosen", () => {
18
+ const reason = commitDisabledReason({
19
+ draft: draft(),
20
+ scope: "just-these",
21
+ name: "",
22
+ pickedDate: "",
23
+ });
24
+ assert.match(reason ?? "", /Pick a folder/);
25
+ });
26
+
27
+ it("requires a name for a standing filter", () => {
28
+ const reason = commitDisabledReason({
29
+ draft: draft({ moveMailboxId: "mbx-1" }),
30
+ scope: "standing",
31
+ name: " ",
32
+ pickedDate: "",
33
+ });
34
+ assert.match(reason ?? "", /Name this filter/);
35
+ });
36
+
37
+ it("requires a date for a temporary filter", () => {
38
+ const reason = commitDisabledReason({
39
+ draft: draft({ moveMailboxId: "mbx-1" }),
40
+ scope: "temporary",
41
+ name: "Trip",
42
+ pickedDate: "",
43
+ });
44
+ assert.match(reason ?? "", /Pick the date/);
45
+ });
46
+
47
+ it("is undefined when a move target is set for a one-time scope", () => {
48
+ assert.equal(
49
+ commitDisabledReason({
50
+ draft: draft({ moveMailboxId: "mbx-1" }),
51
+ scope: "all-like-these",
52
+ name: "",
53
+ pickedDate: "",
54
+ }),
55
+ undefined,
56
+ );
57
+ });
58
+
59
+ it("is undefined for a fully-specified temporary filter", () => {
60
+ assert.equal(
61
+ commitDisabledReason({
62
+ draft: draft({ moveMailboxId: "mbx-1" }),
63
+ scope: "temporary",
64
+ name: "Trip",
65
+ pickedDate: "2026-07-16",
66
+ }),
67
+ undefined,
68
+ );
69
+ });
70
+ });
71
+
72
+ describe("commitButtonLabel", () => {
73
+ it("pluralizes the one-time labels", () => {
74
+ assert.equal(commitButtonLabel("just-these", 1), "Move 1 message");
75
+ assert.equal(
76
+ commitButtonLabel("all-like-these", 48),
77
+ "Organize 48 messages",
78
+ );
79
+ });
80
+
81
+ it("uses standing copy for the persisted scopes", () => {
82
+ assert.equal(commitButtonLabel("standing", 48), "Always do this");
83
+ assert.equal(commitButtonLabel("temporary", 48), "Do this until then");
84
+ });
85
+ });
86
+
87
+ describe("scopeActionCount", () => {
88
+ it("uses the selection for just-these and the match set otherwise", () => {
89
+ assert.equal(scopeActionCount("just-these", 3, 48), 3);
90
+ assert.equal(scopeActionCount("all-like-these", 3, 48), 48);
91
+ assert.equal(scopeActionCount("standing", 3, 48), 48);
92
+ });
93
+ });
@@ -0,0 +1,61 @@
1
+ import type { OrganizeScope } from "./organize-model";
2
+ import { hasCommittableAction, type OrganizeDraft } from "./organize-model";
3
+
4
+ export interface CommitContext {
5
+ draft: OrganizeDraft;
6
+ scope: OrganizeScope;
7
+ /** The filter name, required for the two standing scopes. */
8
+ name: string;
9
+ /** The raw picked date (`YYYY-MM-DD`), required for the temporary scope. */
10
+ pickedDate: string;
11
+ }
12
+
13
+ /**
14
+ * Why the commit button is disabled, or `undefined` when it is actionable.
15
+ * Never disable a control without saying why (ux.md), so the caller renders
16
+ * this string next to the button.
17
+ */
18
+ export const commitDisabledReason = ({
19
+ draft,
20
+ scope,
21
+ name,
22
+ pickedDate,
23
+ }: CommitContext): string | undefined => {
24
+ if (!hasCommittableAction(draft)) {
25
+ return "Pick a folder to move these into — labeling isn't available yet.";
26
+ }
27
+ if ((scope === "standing" || scope === "temporary") && name.trim() === "") {
28
+ return "Name this filter so you can find it later.";
29
+ }
30
+ if (scope === "temporary" && pickedDate === "") {
31
+ return "Pick the date this should stop on.";
32
+ }
33
+ return undefined;
34
+ };
35
+
36
+ /** The commit button label for each scope. */
37
+ export const commitButtonLabel = (
38
+ scope: OrganizeScope,
39
+ matchedTotal: number,
40
+ ): string => {
41
+ switch (scope) {
42
+ case "just-these":
43
+ return `Move ${matchedTotal} message${matchedTotal === 1 ? "" : "s"}`;
44
+ case "all-like-these":
45
+ return `Organize ${matchedTotal} message${matchedTotal === 1 ? "" : "s"}`;
46
+ case "standing":
47
+ return "Always do this";
48
+ case "temporary":
49
+ return "Do this until then";
50
+ }
51
+ };
52
+
53
+ /**
54
+ * The count a scope acts on: the current selection for "just these", the
55
+ * widened match set for every scope that reaches similar mail.
56
+ */
57
+ export const scopeActionCount = (
58
+ scope: OrganizeScope,
59
+ selectionCount: number,
60
+ matchedCount: number,
61
+ ): number => (scope === "just-these" ? selectionCount : matchedCount);