@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,123 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "node:test";
3
+ import { resolveSimilarState, resolveSpamAction } from "./IntelligencePane";
4
+
5
+ const junkId = "junk-mailbox";
6
+ const inboxId = "inbox-mailbox";
7
+
8
+ describe("resolveSpamAction (#594)", () => {
9
+ test("offers Not spam when the message is in Junk and Inbox is resolved", () => {
10
+ assert.equal(
11
+ resolveSpamAction({
12
+ mailboxId: junkId,
13
+ junkMailboxId: junkId,
14
+ inboxMailboxId: inboxId,
15
+ }),
16
+ "notSpam",
17
+ );
18
+ });
19
+
20
+ test("offers Mark spam when the message is outside Junk and Junk is resolved", () => {
21
+ assert.equal(
22
+ resolveSpamAction({
23
+ mailboxId: inboxId,
24
+ junkMailboxId: junkId,
25
+ inboxMailboxId: inboxId,
26
+ }),
27
+ "markSpam",
28
+ );
29
+ });
30
+
31
+ test("offers nothing when the move source mailbox is unknown", () => {
32
+ assert.equal(
33
+ resolveSpamAction({
34
+ mailboxId: undefined,
35
+ junkMailboxId: junkId,
36
+ inboxMailboxId: inboxId,
37
+ }),
38
+ null,
39
+ );
40
+ });
41
+
42
+ test("offers nothing in Junk when the Inbox target hasn't loaded", () => {
43
+ assert.equal(
44
+ resolveSpamAction({
45
+ mailboxId: junkId,
46
+ junkMailboxId: junkId,
47
+ inboxMailboxId: undefined,
48
+ }),
49
+ null,
50
+ );
51
+ });
52
+
53
+ test("offers nothing outside Junk when the Junk target hasn't loaded", () => {
54
+ assert.equal(
55
+ resolveSpamAction({
56
+ mailboxId: inboxId,
57
+ junkMailboxId: undefined,
58
+ inboxMailboxId: inboxId,
59
+ }),
60
+ null,
61
+ );
62
+ });
63
+
64
+ test("the two actions are mutually exclusive", () => {
65
+ const inJunk = resolveSpamAction({
66
+ mailboxId: junkId,
67
+ junkMailboxId: junkId,
68
+ inboxMailboxId: inboxId,
69
+ });
70
+ const elsewhere = resolveSpamAction({
71
+ mailboxId: inboxId,
72
+ junkMailboxId: junkId,
73
+ inboxMailboxId: inboxId,
74
+ });
75
+ assert.notEqual(inJunk, elsewhere);
76
+ });
77
+ });
78
+
79
+ describe("resolveSimilarState — fail-fast on similar-messages search", () => {
80
+ test("a fatal 5xx does NOT show the grey label — it escalates instead (ready)", () => {
81
+ assert.equal(
82
+ resolveSimilarState({
83
+ similarError: new Error("500"),
84
+ similarErrorIsFatal: true,
85
+ isSimilarLoading: false,
86
+ }),
87
+ "ready",
88
+ );
89
+ });
90
+
91
+ test("a soft (non-fatal) error keeps the muted error state", () => {
92
+ assert.equal(
93
+ resolveSimilarState({
94
+ similarError: new Error("404"),
95
+ similarErrorIsFatal: false,
96
+ isSimilarLoading: false,
97
+ }),
98
+ "error",
99
+ );
100
+ });
101
+
102
+ test("an empty result (no error) is a soft ready state", () => {
103
+ assert.equal(
104
+ resolveSimilarState({
105
+ similarError: null,
106
+ similarErrorIsFatal: false,
107
+ isSimilarLoading: false,
108
+ }),
109
+ "ready",
110
+ );
111
+ });
112
+
113
+ test("loading wins when there is no error", () => {
114
+ assert.equal(
115
+ resolveSimilarState({
116
+ similarError: null,
117
+ similarErrorIsFatal: false,
118
+ isSimilarLoading: true,
119
+ }),
120
+ "loading",
121
+ );
122
+ });
123
+ });
@@ -0,0 +1,441 @@
1
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
+ import {
3
+ IntelligencePanel,
4
+ type IntelligenceQuickActions,
5
+ type SimilarMessageLinkComponent,
6
+ type SimilarState,
7
+ } from "@remit/ui";
8
+ import { Link } from "@tanstack/react-router";
9
+ import { Sparkles } from "lucide-react";
10
+ import { useCallback, useState } from "react";
11
+ import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
12
+ import { useInboxMailbox, useJunkMailbox } from "@/hooks/useArchiveMailbox";
13
+ import { useIntelligenceData } from "@/hooks/useIntelligenceData";
14
+ import { useMoveMessages } from "@/hooks/useMoveMessages";
15
+ import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
16
+ import { isRescueCandidate } from "@/lib/rescue-candidates";
17
+ import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
18
+ import { useTelemetry } from "@/lib/telemetry-context";
19
+
20
+ export interface IntelligencePaneProps {
21
+ onClose: () => void;
22
+ /**
23
+ * The currently selected thread row. When present, the real
24
+ * `IntelligencePanel` renders with wired data; when absent the pane shows
25
+ * the empty-state placeholder.
26
+ */
27
+ thread?: RemitImapThreadMessageResponse;
28
+ /** The mailbox the message list is currently showing — the move source. */
29
+ mailboxId?: string;
30
+ /** Account that owns `mailboxId`; resolves the Junk/Inbox move targets. */
31
+ accountId?: string;
32
+ /**
33
+ * Hide the panel's own close (X). Set when hosted inside the mobile Drawer,
34
+ * whose header already renders a close button — one way back, not two (#874).
35
+ */
36
+ hideCloseButton?: boolean;
37
+ }
38
+
39
+ /**
40
+ * Category-override values accepted by the `AddressFlags.category` override
41
+ * (PATCH /addresses/{id}). Matches `MessageCategory` — the full taxonomy the
42
+ * user can assign as a sender-level override.
43
+ */
44
+ const CATEGORY_OVERRIDES = [
45
+ "personal",
46
+ "newsletter",
47
+ "marketing",
48
+ "automated",
49
+ "transactional",
50
+ "social",
51
+ ] as const;
52
+ type CategoryOverride = (typeof CATEGORY_OVERRIDES)[number];
53
+
54
+ /**
55
+ * Decide which spam quick-action to offer for the current message (issue #594).
56
+ *
57
+ * The two buttons are symmetric and mutually exclusive:
58
+ * - In the Junk mailbox → offer **Not spam** (move out, promote sender), but
59
+ * only when an Inbox destination is resolved.
60
+ * - Anywhere else → offer **Mark spam** (move in, demote sender), but only when
61
+ * the Junk destination is resolved.
62
+ *
63
+ * Returns `"notSpam"`, `"markSpam"`, or `null` (no actionable button — e.g. the
64
+ * move source isn't known, or the needed target mailbox hasn't loaded). Pure so
65
+ * the wiring decision can be unit-tested without rendering.
66
+ */
67
+ export const resolveSpamAction = (input: {
68
+ mailboxId?: string;
69
+ junkMailboxId?: string;
70
+ inboxMailboxId?: string;
71
+ }): "notSpam" | "markSpam" | null => {
72
+ const { mailboxId, junkMailboxId, inboxMailboxId } = input;
73
+ if (!mailboxId) return null;
74
+ const isInJunk = Boolean(junkMailboxId && mailboxId === junkMailboxId);
75
+ if (isInJunk) return inboxMailboxId ? "notSpam" : null;
76
+ return junkMailboxId ? "markSpam" : null;
77
+ };
78
+
79
+ /**
80
+ * Decide the "Similar messages" section state from the semantic-search query.
81
+ *
82
+ * The fail-fast rule: a fatal first-party 5xx must NEVER degrade to the benign
83
+ * grey "Similarity search unavailable" label — it escalates to the global red
84
+ * overlay instead, so this returns `"ready"` for it (the section then renders
85
+ * nothing rather than a misleading soft state). A soft (non-fatal) failure
86
+ * keeps the muted `"error"` state; an empty result is `"ready"` (no error).
87
+ * Pure so the classification can be unit-tested without rendering.
88
+ */
89
+ export const resolveSimilarState = (input: {
90
+ similarError: unknown;
91
+ similarErrorIsFatal: boolean;
92
+ isSimilarLoading: boolean;
93
+ }): SimilarState => {
94
+ const { similarError, similarErrorIsFatal, isSimilarLoading } = input;
95
+ if (similarError && !similarErrorIsFatal) return "error";
96
+ if (isSimilarLoading) return "loading";
97
+ return "ready";
98
+ };
99
+
100
+ function IntelligenceSkeleton() {
101
+ return (
102
+ <aside className="flex h-full w-full flex-col bg-surface-sunken">
103
+ <header className="flex h-pane-header shrink-0 items-center gap-1.5 border-b border-line px-row-inset">
104
+ <Sparkles className="size-3.5 text-fg-subtle" />
105
+ <span className="text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
106
+ Intelligence
107
+ </span>
108
+ </header>
109
+ <div className="flex-1 animate-pulse space-y-4 p-4">
110
+ <div className="space-y-2">
111
+ <div className="h-2.5 rounded bg-surface w-1/4" />
112
+ <div className="flex gap-3">
113
+ <div className="size-10 rounded-full bg-surface shrink-0" />
114
+ <div className="flex-1 space-y-1.5">
115
+ <div className="h-3 rounded bg-surface w-3/4" />
116
+ <div className="h-2.5 rounded bg-surface w-1/2" />
117
+ </div>
118
+ </div>
119
+ </div>
120
+ <div className="space-y-2">
121
+ <div className="h-2.5 rounded bg-surface w-1/4" />
122
+ <div className="h-8 rounded bg-surface" />
123
+ </div>
124
+ <div className="space-y-2">
125
+ <div className="h-2.5 rounded bg-surface w-1/4" />
126
+ <div className="flex gap-1.5 flex-wrap">
127
+ {Array.from({ length: 5 }).map((_, i) => (
128
+ // biome-ignore lint/suspicious/noArrayIndexKey: list is static, no stable id
129
+ <div key={i} className="h-6 w-16 rounded bg-surface" />
130
+ ))}
131
+ </div>
132
+ </div>
133
+ </div>
134
+ </aside>
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Reclassify picker: a small modal listing the category-override options. On
140
+ * selection it PATCHes `AddressFlags.category` for the sender.
141
+ */
142
+ function ReclassifyDialog({
143
+ isOpen,
144
+ current,
145
+ onSelect,
146
+ onCancel,
147
+ }: {
148
+ isOpen: boolean;
149
+ current: string;
150
+ onSelect: (category: CategoryOverride) => void;
151
+ onCancel: () => void;
152
+ }) {
153
+ if (!isOpen) return null;
154
+ return (
155
+ <div
156
+ className="fixed inset-0 z-50 flex items-center justify-center"
157
+ aria-hidden="true"
158
+ onClick={onCancel}
159
+ >
160
+ <div className="absolute inset-0 bg-canvas/80 backdrop-blur-sm" />
161
+ <div
162
+ role="dialog"
163
+ aria-modal="true"
164
+ aria-label="Reclassify sender"
165
+ className="relative z-10 w-full max-w-sm rounded-sm border border-line bg-surface p-6 shadow-lg"
166
+ onClick={(e) => e.stopPropagation()}
167
+ onKeyDown={(e) => e.stopPropagation()}
168
+ >
169
+ <h2 className="text-lg font-semibold">Reclassify this sender</h2>
170
+ <p className="mt-2 text-sm text-fg-muted">
171
+ Set a category override for future messages from this sender.
172
+ </p>
173
+ <div className="mt-4 flex flex-col gap-1">
174
+ {CATEGORY_OVERRIDES.map((cat) => (
175
+ <button
176
+ key={cat}
177
+ type="button"
178
+ onClick={() => onSelect(cat)}
179
+ className={`flex min-h-11 items-center justify-between rounded px-3 text-left text-sm capitalize transition-colors hover:bg-surface-raised ${
180
+ cat === current ? "font-semibold text-accent" : "text-fg"
181
+ }`}
182
+ >
183
+ {cat}
184
+ {cat === current && (
185
+ <span className="text-2xs text-fg-subtle">current</span>
186
+ )}
187
+ </button>
188
+ ))}
189
+ </div>
190
+ <div className="mt-6 flex justify-end">
191
+ <button
192
+ type="button"
193
+ onClick={onCancel}
194
+ className="inline-flex min-h-11 items-center justify-center rounded border border-line px-4 text-sm font-medium transition-colors hover:bg-surface-raised"
195
+ >
196
+ Cancel
197
+ </button>
198
+ </div>
199
+ </div>
200
+ </div>
201
+ );
202
+ }
203
+
204
+ interface WiredPanelProps {
205
+ thread: RemitImapThreadMessageResponse;
206
+ onClose: () => void;
207
+ mailboxId?: string;
208
+ accountId?: string;
209
+ hideCloseButton?: boolean;
210
+ }
211
+
212
+ /**
213
+ * Inner panel that resolves intelligence data and wires quick-action mutations.
214
+ */
215
+ function WiredPanel({
216
+ thread,
217
+ onClose,
218
+ mailboxId,
219
+ accountId,
220
+ hideCloseButton,
221
+ }: WiredPanelProps) {
222
+ const {
223
+ data,
224
+ addressId,
225
+ isSimilarLoading,
226
+ similarError,
227
+ similarErrorIsFatal,
228
+ } = useIntelligenceData(thread, mailboxId);
229
+ const [confirmBlock, setConfirmBlock] = useState(false);
230
+ const [reclassifyOpen, setReclassifyOpen] = useState(false);
231
+ const senderEmail = thread.fromEmail ?? undefined;
232
+
233
+ const { updateFlags, isPending } = useUpdateAddressFlags({
234
+ addressId,
235
+ senderEmail,
236
+ });
237
+
238
+ // "Not spam" / "Mark spam" move the message across the Junk boundary; the
239
+ // backend's moveMessage then promotes (out of Junk) or demotes (into Junk)
240
+ // the sender's trust (issue #594). Only one button is wired at a time,
241
+ // depending on whether the message is currently sitting in Junk.
242
+ const { junkMailboxId } = useJunkMailbox(accountId);
243
+ const { inboxMailboxId } = useInboxMailbox(accountId);
244
+ const { moveMessages } = useMoveMessages({
245
+ mailboxId: mailboxId ?? "",
246
+ threadId: thread.threadId,
247
+ accountId,
248
+ });
249
+ const telemetry = useTelemetry();
250
+
251
+ const spamAction = resolveSpamAction({
252
+ mailboxId,
253
+ junkMailboxId,
254
+ inboxMailboxId,
255
+ });
256
+
257
+ const handleNotSpam = useCallback(() => {
258
+ if (!inboxMailboxId) return;
259
+ moveMessages([thread.messageId], inboxMailboxId);
260
+ }, [inboxMailboxId, moveMessages, thread.messageId]);
261
+
262
+ const handleMarkSpam = useCallback(() => {
263
+ if (!junkMailboxId) return;
264
+ recordRescueSentToJunk(telemetry, {
265
+ count: 1,
266
+ senderTrust: thread.senderTrust,
267
+ wasRescuable: isRescueCandidate(thread),
268
+ });
269
+ moveMessages([thread.messageId], junkMailboxId);
270
+ }, [junkMailboxId, moveMessages, thread, telemetry]);
271
+
272
+ const handleShowSimilar = useCallback(() => {
273
+ // The similar-messages section scrolls into view automatically when
274
+ // data.similar is populated. This handler is for the "N similar" button
275
+ // inside the authenticity panel; a scroll-to implementation can be added
276
+ // in a future pass without breaking the contract.
277
+ }, []);
278
+
279
+ const handleToggleVip = useCallback(() => {
280
+ const next = !(data?.flags?.vip === true);
281
+ updateFlags({ vip: { value: next } });
282
+ }, [data?.flags?.vip, updateFlags]);
283
+
284
+ const handleToggleMute = useCallback(() => {
285
+ const next = !(data?.flags?.muted === true);
286
+ updateFlags({ muted: { value: next } });
287
+ }, [data?.flags?.muted, updateFlags]);
288
+
289
+ const handleToggleBlock = useCallback(() => {
290
+ if (data?.flags?.blocked === true) {
291
+ // Unblock — no confirm needed
292
+ updateFlags({ blocked: { value: false } });
293
+ } else {
294
+ setConfirmBlock(true);
295
+ }
296
+ }, [data?.flags?.blocked, updateFlags]);
297
+
298
+ const handleBlockConfirm = useCallback(() => {
299
+ setConfirmBlock(false);
300
+ updateFlags({ blocked: { value: true } });
301
+ }, [updateFlags]);
302
+
303
+ const handleToggleUnsubscribe = useCallback(() => {
304
+ const next = !(data?.flags?.unsubscribed === true);
305
+ updateFlags({ unsubscribed: { value: next } });
306
+ }, [data?.flags?.unsubscribed, updateFlags]);
307
+
308
+ const handleReclassifySelect = useCallback(
309
+ (category: CategoryOverride) => {
310
+ setReclassifyOpen(false);
311
+ updateFlags({ category: { value: category } });
312
+ },
313
+ [updateFlags],
314
+ );
315
+
316
+ // Per-sender flag toggles are always wired. They stay active even before the
317
+ // address record resolves: `updateFlags` surfaces feedback when `addressId`
318
+ // is missing rather than letting the button look active but do nothing.
319
+ const actions: IntelligenceQuickActions = {
320
+ onToggleVip: handleToggleVip,
321
+ onToggleMute: handleToggleMute,
322
+ onToggleBlock: handleToggleBlock,
323
+ onToggleUnsubscribe: handleToggleUnsubscribe,
324
+ onReclassify: () => setReclassifyOpen(true),
325
+ onNotSpam: spamAction === "notSpam" ? handleNotSpam : undefined,
326
+ onMarkSpam: spamAction === "markSpam" ? handleMarkSpam : undefined,
327
+ };
328
+
329
+ // Each similar-message row is a real router anchor — same link shape as a
330
+ // message-list row — so it opens the message in the reading pane while
331
+ // preserving deep-linking, middle-click, and existing search params.
332
+ const similarLinkComponent: SimilarMessageLinkComponent = ({
333
+ mailboxId: rowMailboxId,
334
+ messageId,
335
+ className,
336
+ ariaLabel,
337
+ children,
338
+ }) => (
339
+ <Link
340
+ to="/mail/$mailboxId"
341
+ params={{ mailboxId: rowMailboxId }}
342
+ search={(prev) => ({ ...prev, selectedMessageId: messageId })}
343
+ className={className}
344
+ aria-label={ariaLabel}
345
+ >
346
+ {children}
347
+ </Link>
348
+ );
349
+
350
+ if (!data) {
351
+ return <IntelligenceSkeleton />;
352
+ }
353
+
354
+ const similarState = resolveSimilarState({
355
+ similarError,
356
+ similarErrorIsFatal,
357
+ isSimilarLoading,
358
+ });
359
+
360
+ return (
361
+ <>
362
+ <IntelligencePanel
363
+ data={data}
364
+ onClose={onClose}
365
+ hideCloseButton={hideCloseButton}
366
+ onShowSimilar={handleShowSimilar}
367
+ actions={actions}
368
+ similarState={similarState}
369
+ similarLinkComponent={similarLinkComponent}
370
+ // No left border: the ResizableHandle to our left already draws the
371
+ // hairline seam. The remit-ui IntelligencePanel default `border-l`
372
+ // would double it to 2px.
373
+ className="border-l-0 h-full w-full"
374
+ />
375
+ <ConfirmDialog
376
+ isOpen={confirmBlock}
377
+ title="Block this sender?"
378
+ description={`Messages from ${senderEmail ?? "this sender"} will never load images and will be flagged. You can undo this in Settings → Senders.`}
379
+ confirmLabel="Block sender"
380
+ destructive
381
+ isBusy={isPending}
382
+ onConfirm={handleBlockConfirm}
383
+ onCancel={() => setConfirmBlock(false)}
384
+ />
385
+ <ReclassifyDialog
386
+ isOpen={reclassifyOpen}
387
+ current={data.category.value}
388
+ onSelect={handleReclassifySelect}
389
+ onCancel={() => setReclassifyOpen(false)}
390
+ />
391
+ </>
392
+ );
393
+ }
394
+
395
+ /**
396
+ * Pane 4 of the 4-pane shell: the intelligence sidebar. Wired to real data via
397
+ * `useIntelligenceData`; renders the `IntelligencePanel` from `@remit/ui`.
398
+ *
399
+ * Data flow:
400
+ * - Sender basics + trust + authenticity + category: from the thread row (instant,
401
+ * already loaded by the message list).
402
+ * - Address flags (VIP/muted/blocked/etc.) + first-seen: `GET /addresses/search`.
403
+ * - Similar messages: `GET /search/semantic` (last; panel still renders on failure).
404
+ */
405
+ export const IntelligencePane = ({
406
+ onClose,
407
+ thread,
408
+ mailboxId,
409
+ accountId,
410
+ hideCloseButton,
411
+ }: IntelligencePaneProps) => {
412
+ if (!thread) {
413
+ return (
414
+ <aside className="flex h-full w-full flex-col bg-surface-sunken">
415
+ <header className="flex h-pane-header shrink-0 items-center gap-1.5 border-b border-line px-row-inset">
416
+ <Sparkles className="size-3.5 text-fg-subtle" />
417
+ <span className="text-2xs font-semibold uppercase tracking-wider text-fg-subtle">
418
+ Intelligence
419
+ </span>
420
+ </header>
421
+ <div className="flex flex-1 flex-col items-center justify-center px-row-inset text-center">
422
+ <Sparkles className="size-8 text-fg-subtle" />
423
+ <p className="mt-3 text-sm text-fg-muted">Intelligence</p>
424
+ <p className="mt-1 text-2xs text-fg-subtle">
425
+ Sender trust, authenticity and similar messages will appear here.
426
+ </p>
427
+ </div>
428
+ </aside>
429
+ );
430
+ }
431
+
432
+ return (
433
+ <WiredPanel
434
+ thread={thread}
435
+ onClose={onClose}
436
+ mailboxId={mailboxId}
437
+ accountId={accountId}
438
+ hideCloseButton={hideCloseButton}
439
+ />
440
+ );
441
+ };