@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,284 @@
1
+ import {
2
+ addressOperationsSearchAddressesOptions,
3
+ semanticSearchOperationsSemanticSearchOptions,
4
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
+ import type {
6
+ RemitImapAddressResponse,
7
+ RemitImapThreadMessageResponse,
8
+ } from "@remit/api-http-client/types.gen.ts";
9
+ import type {
10
+ AuthenticityIntel,
11
+ IntelligenceData,
12
+ MatchedChunk,
13
+ SenderFlagsIntel,
14
+ SenderIntel,
15
+ SimilarMessageIntel,
16
+ } from "@remit/ui";
17
+ import { useQuery } from "@tanstack/react-query";
18
+ import { useMemo } from "react";
19
+ import { isServerError } from "@/lib/error-classifier";
20
+ import { formatDate, formatEmailDate } from "@/lib/format";
21
+
22
+ /**
23
+ * Format a creation timestamp as a human-readable "first seen" label.
24
+ * - Today: "today"
25
+ * - Otherwise: "Jan 2025"
26
+ */
27
+ function formatFirstSeenLabel(createdAtMs: number): string {
28
+ const d = new Date(createdAtMs);
29
+ const now = new Date();
30
+ if (d.toDateString() === now.toDateString()) return "today";
31
+ return formatDate(d, { month: "short", year: "numeric" });
32
+ }
33
+
34
+ /**
35
+ * A sender address we cannot read: empty, missing the `@`, or whose domain
36
+ * part carries no dot (catches placeholders like `missing_domain` and other
37
+ * junk). Structural — never a hard-coded literal.
38
+ */
39
+ export function isSenderAddressUnverifiable(
40
+ fromEmail: string | undefined,
41
+ ): boolean {
42
+ if (!fromEmail) return true;
43
+ const [, domain] = fromEmail.split("@");
44
+ if (!domain) return true;
45
+ return !domain.includes(".");
46
+ }
47
+
48
+ const NO_SIGNAL_SUMMARY =
49
+ "We can't verify the sender of this email, which could mean it's from an insecure source.";
50
+
51
+ const UNREADABLE_SENDER_SUMMARY =
52
+ "We couldn't read this sender's address, so we can't confirm who really sent this message.";
53
+
54
+ /**
55
+ * Build sender intel from the thread message and address lookup.
56
+ *
57
+ * Engagement counters (`inboundCount`/`replyCount`) are passed through
58
+ * as-is from the address response. When absent (`undefined`) they remain
59
+ * `undefined` so the `SenderCard` engagement clause is suppressed rather
60
+ * than rendering a misleading "0 received" next to an earned trust badge.
61
+ */
62
+ export function buildSenderIntel(
63
+ thread: RemitImapThreadMessageResponse,
64
+ address: RemitImapAddressResponse | undefined,
65
+ ): SenderIntel {
66
+ return {
67
+ name: thread.fromName ?? thread.fromEmail ?? "Unknown",
68
+ email: thread.fromEmail ?? "",
69
+ trust: thread.senderTrust,
70
+ firstSeenLabel: address
71
+ ? formatFirstSeenLabel(address.createdAt)
72
+ : "unknown",
73
+ // Pass counters through as-is. undefined stays undefined so the panel
74
+ // suppresses the engagement clause when data is absent.
75
+ inboundCount: address?.inboundCount,
76
+ replyCount: address?.replyCount,
77
+ addressUnverified: isSenderAddressUnverifiable(thread.fromEmail),
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Build sender flags from the address response.
83
+ */
84
+ function buildSenderFlags(
85
+ address: RemitImapAddressResponse | undefined,
86
+ ): SenderFlagsIntel {
87
+ if (!address?.flags) return {};
88
+ return {
89
+ vip: address.flags.vip?.value === true,
90
+ muted: address.flags.muted?.value === true,
91
+ blocked: address.flags.blocked?.value === true,
92
+ unsubscribed: address.flags.unsubscribed?.value === true,
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Build authenticity intel from the thread message's authenticity field.
98
+ *
99
+ * `similarCount` is the number of semantic-search lookalikes; the panel uses
100
+ * it to render the "N similar messages" campaign-reveal CTA inside the
101
+ * danger panel. Passed in because it is derived from the semantic-search
102
+ * result, not the thread row.
103
+ */
104
+ export function buildAuthenticityIntel(
105
+ thread: RemitImapThreadMessageResponse,
106
+ similarCount: number,
107
+ ): AuthenticityIntel {
108
+ const surfacedSimilar = similarCount > 0 ? similarCount : undefined;
109
+
110
+ if (isSenderAddressUnverifiable(thread.fromEmail)) {
111
+ return {
112
+ verdict: "mismatch",
113
+ fromDomain: "",
114
+ addressUnreadable: true,
115
+ summary: UNREADABLE_SENDER_SUMMARY,
116
+ similarCount: surfacedSimilar,
117
+ };
118
+ }
119
+
120
+ const auth = thread.authenticity;
121
+ if (!auth) {
122
+ return {
123
+ verdict: "caution",
124
+ fromDomain: thread.fromEmail?.split("@")[1] ?? "",
125
+ summary: NO_SIGNAL_SUMMARY,
126
+ };
127
+ }
128
+ if (!auth.dkimMismatch) {
129
+ return {
130
+ verdict: "aligned",
131
+ fromDomain: auth.fromDomain,
132
+ dkimDomain: auth.dkimDomain,
133
+ summary: auth.dkimDomain
134
+ ? `We verified this message was really sent by ${auth.fromDomain}.`
135
+ : `Nothing looks unusual about this sender.`,
136
+ };
137
+ }
138
+
139
+ const fromDomain = auth.fromDomain;
140
+ const dkimDomain = auth.dkimDomain;
141
+ const claimedBrand =
142
+ thread.fromName && thread.fromName !== thread.fromEmail
143
+ ? thread.fromName
144
+ : undefined;
145
+ const summary = claimedBrand
146
+ ? `The display name claims "${claimedBrand}", but this message was actually sent from ${dkimDomain ?? "another sender"} — not ${fromDomain}. Real senders use their own address.`
147
+ : `This message claims to be from ${fromDomain}, but it was actually sent from ${dkimDomain ?? "a different sender"}.`;
148
+ return {
149
+ verdict: "mismatch",
150
+ fromDomain,
151
+ dkimDomain,
152
+ claimedBrand,
153
+ summary,
154
+ // Surface the lookalike count so the panel renders the "N similar
155
+ // messages" campaign-reveal button.
156
+ similarCount: surfacedSimilar,
157
+ };
158
+ }
159
+
160
+ export interface UseIntelligenceDataResult {
161
+ data: IntelligenceData | null;
162
+ /**
163
+ * True while similar-messages fetch is in flight. The rest of the data
164
+ * (sender, authenticity, category, flags) arrives synchronously from the
165
+ * already-loaded thread row.
166
+ */
167
+ isSimilarLoading: boolean;
168
+ /** Non-null when the similar-messages fetch failed. */
169
+ similarError: unknown;
170
+ /**
171
+ * True when the similar-messages failure is a fatal first-party 5xx. The
172
+ * global escalation overlay handles it; the panel must NOT degrade it to the
173
+ * benign grey "Similarity search unavailable" label. A soft (non-fatal)
174
+ * error or an empty result keeps the panel quiet.
175
+ */
176
+ similarErrorIsFatal: boolean;
177
+ /**
178
+ * True when the address lookup failed with a fatal first-party 5xx. The panel
179
+ * must not silently render the sender's flags (VIP/muted/blocked) as absent —
180
+ * that misrepresents the sender's real state. Escalation covers it.
181
+ */
182
+ addressErrorIsFatal: boolean;
183
+ /** Address id for the sender — needed for PATCH /addresses/{id} mutations. */
184
+ addressId: string | undefined;
185
+ /**
186
+ * Raw address response for the sender — forwarded to the quick-action
187
+ * mutation hooks so they can optimistically patch the cache.
188
+ */
189
+ address: RemitImapAddressResponse | undefined;
190
+ }
191
+
192
+ /**
193
+ * Maps a `RemitImapThreadMessageResponse` into `IntelligenceData` for the
194
+ * `IntelligencePanel` component. Three data sources:
195
+ *
196
+ * 1. The thread row itself (already loaded) → sender basics, trust, authenticity, category.
197
+ * 2. `GET /addresses/search?q=<email>` → address flags (VIP/muted/blocked/etc.)
198
+ * and first-seen timestamp. One round-trip per unique sender.
199
+ * 3. `GET /search/semantic` → similar messages. Last to arrive; sidebar still
200
+ * renders if this fails.
201
+ */
202
+ export function useIntelligenceData(
203
+ thread: RemitImapThreadMessageResponse | null | undefined,
204
+ currentMailboxId?: string,
205
+ ): UseIntelligenceDataResult {
206
+ const senderEmail = thread?.fromEmail ?? null;
207
+ const subject = thread?.subject ?? "";
208
+
209
+ // --- Address lookup: flags + first-seen timestamp ---
210
+ const { data: addressSearchResult, error: addressError } = useQuery({
211
+ ...addressOperationsSearchAddressesOptions({
212
+ query: { q: senderEmail ?? "", limit: 1 },
213
+ }),
214
+ enabled: Boolean(senderEmail),
215
+ staleTime: 30_000,
216
+ });
217
+
218
+ const address = addressSearchResult?.items?.[0];
219
+
220
+ // --- Semantic search: similar messages ---
221
+ const semanticQuery = [subject, senderEmail].filter(Boolean).join(" ");
222
+ const {
223
+ data: semanticResult,
224
+ isLoading: isSimilarLoading,
225
+ error: similarError,
226
+ } = useQuery({
227
+ ...semanticSearchOperationsSemanticSearchOptions({
228
+ query: { query: semanticQuery, limit: 5 },
229
+ }),
230
+ enabled: Boolean(thread && semanticQuery.length > 3),
231
+ staleTime: 60_000,
232
+ // A non-fatal failure (e.g. 404, no index yet) is acceptable — the sidebar
233
+ // degrades gracefully. A 5xx is NOT acceptable: it escalates globally and
234
+ // the panel must not present it as a benign "unavailable" state.
235
+ retry: 1,
236
+ });
237
+
238
+ const data = useMemo((): IntelligenceData | null => {
239
+ if (!thread) return null;
240
+
241
+ const similar: SimilarMessageIntel[] = (semanticResult?.items ?? [])
242
+ // Exclude the current message itself
243
+ .filter((r) => r.messageId !== thread.messageId)
244
+ .map((r) => ({
245
+ id: r.messageId,
246
+ // Prefer the mailbox the user is already viewing when the message also
247
+ // lives there, so opening a similar message stays in the same folder;
248
+ // otherwise fall back to the first mailbox it lives in.
249
+ mailboxId:
250
+ currentMailboxId != null && r.mailboxIds.includes(currentMailboxId)
251
+ ? currentMailboxId
252
+ : (r.mailboxIds[0] ?? ""),
253
+ fromName: r.fromName ?? "",
254
+ subject: r.subject ?? "(No subject)",
255
+ timeLabel: r.sentDate != null ? formatEmailDate(r.sentDate * 1000) : "",
256
+ matched: r.matchedChunkType as MatchedChunk,
257
+ }));
258
+
259
+ const sender = buildSenderIntel(thread, address);
260
+
261
+ const authenticity = buildAuthenticityIntel(thread, similar.length);
262
+
263
+ const category = {
264
+ value: thread.category ?? "personal",
265
+ overridden:
266
+ address?.flags?.category?.value != null &&
267
+ address.flags.category.value !== thread.category,
268
+ };
269
+
270
+ const flags = buildSenderFlags(address);
271
+
272
+ return { sender, authenticity, category, flags, similar };
273
+ }, [thread, address, semanticResult, currentMailboxId]);
274
+
275
+ return {
276
+ data,
277
+ isSimilarLoading,
278
+ similarError,
279
+ similarErrorIsFatal: isServerError(similarError),
280
+ addressErrorIsFatal: isServerError(addressError),
281
+ addressId: address?.addressId,
282
+ address,
283
+ };
284
+ }
@@ -0,0 +1,27 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ /**
4
+ * Returns true when the app is in dark mode.
5
+ *
6
+ * Observes the `dark` class on `document.documentElement` — the same
7
+ * mechanism Tailwind's `class` darkMode strategy uses. The initial value
8
+ * is read synchronously so there's no flash on first render.
9
+ */
10
+ export const useIsDark = (): boolean => {
11
+ const [isDark, setIsDark] = useState(
12
+ () =>
13
+ typeof document !== "undefined" &&
14
+ document.documentElement.classList.contains("dark"),
15
+ );
16
+
17
+ useEffect(() => {
18
+ const el = document.documentElement;
19
+ const observer = new MutationObserver(() => {
20
+ setIsDark(el.classList.contains("dark"));
21
+ });
22
+ observer.observe(el, { attributes: true, attributeFilter: ["class"] });
23
+ return () => observer.disconnect();
24
+ }, []);
25
+
26
+ return isDark;
27
+ };
@@ -0,0 +1,192 @@
1
+ import { useCallback, useEffect } from "react";
2
+
3
+ type KeyHandler = (event: KeyboardEvent) => void;
4
+
5
+ interface KeyBinding {
6
+ key: string;
7
+ handler: KeyHandler;
8
+ /** If true, only trigger when no modifier keys are pressed */
9
+ noModifiers?: boolean;
10
+ /**
11
+ * Shift requirement. `undefined` (default) ignores shift. `true` requires
12
+ * shift to be held; `false` requires it to be absent. Use this to bind a
13
+ * plain key and its shift-variant to different handlers (e.g. ArrowDown vs
14
+ * Shift+ArrowDown) without them firing each other.
15
+ */
16
+ requireShift?: boolean;
17
+ /**
18
+ * Meta/Ctrl requirement. `undefined` (default) follows `noModifiers`.
19
+ * `true` requires Cmd (mac) or Ctrl to be held — used for Cmd/Ctrl+A.
20
+ */
21
+ requireMeta?: boolean;
22
+ /** If true, prevent default browser behavior */
23
+ preventDefault?: boolean;
24
+ /**
25
+ * If true, stop the event from reaching any other keydown listener once
26
+ * this binding handles it (calls stopPropagation + stopImmediatePropagation).
27
+ * Combine with `capture` to win precedence over other window-level
28
+ * listeners on the same keypress (e.g. Esc clearing a selection must
29
+ * pre-empt a route-level Esc that would also navigate).
30
+ */
31
+ stopPropagation?: boolean;
32
+ }
33
+
34
+ interface UseKeyboardNavigationOptions {
35
+ /** Whether the keyboard navigation is enabled */
36
+ enabled?: boolean;
37
+ /** Key bindings to register */
38
+ bindings: KeyBinding[];
39
+ /**
40
+ * Register the listener on the capture phase instead of the bubble phase.
41
+ * A capture-phase listener runs before bubble-phase listeners on the same
42
+ * target, so paired with a binding's `stopPropagation` it can consume a key
43
+ * before other handlers see it.
44
+ */
45
+ capture?: boolean;
46
+ }
47
+
48
+ /**
49
+ * Hook for handling keyboard navigation.
50
+ * Registers global keyboard event listeners and calls handlers for matching keys.
51
+ */
52
+ export const useKeyboardNavigation = ({
53
+ enabled = true,
54
+ bindings,
55
+ capture = false,
56
+ }: UseKeyboardNavigationOptions) => {
57
+ const handleKeyDown = useCallback(
58
+ (event: KeyboardEvent) => {
59
+ // Skip if focus is in an input/textarea
60
+ const target = event.target as HTMLElement;
61
+ if (
62
+ target.tagName === "INPUT" ||
63
+ target.tagName === "TEXTAREA" ||
64
+ target.isContentEditable
65
+ ) {
66
+ return;
67
+ }
68
+
69
+ for (const binding of bindings) {
70
+ const keyMatches =
71
+ event.key.toLowerCase() === binding.key.toLowerCase();
72
+ if (!keyMatches) continue;
73
+
74
+ // Shift gate: when a binding pins shift on/off, it only matches that
75
+ // exact state so plain and shift variants stay distinct.
76
+ if (
77
+ binding.requireShift !== undefined &&
78
+ binding.requireShift !== event.shiftKey
79
+ ) {
80
+ continue;
81
+ }
82
+
83
+ const metaHeld = event.ctrlKey || event.metaKey;
84
+
85
+ // Meta gate: requireMeta bindings (Cmd/Ctrl+A) need meta/ctrl held.
86
+ if (binding.requireMeta !== undefined) {
87
+ if (binding.requireMeta !== metaHeld) continue;
88
+ } else {
89
+ const noModifiersRequired = binding.noModifiers ?? true;
90
+ // Treat shift as a modifier for the no-modifier guard only when
91
+ // the binding hasn't opted into an explicit shift requirement.
92
+ const hasModifiers =
93
+ metaHeld ||
94
+ event.altKey ||
95
+ (binding.requireShift === undefined && event.shiftKey);
96
+ if (noModifiersRequired && hasModifiers) continue;
97
+ }
98
+
99
+ if (binding.preventDefault) {
100
+ event.preventDefault();
101
+ }
102
+ if (binding.stopPropagation) {
103
+ event.stopPropagation();
104
+ event.stopImmediatePropagation();
105
+ }
106
+ binding.handler(event);
107
+ return;
108
+ }
109
+ },
110
+ [bindings],
111
+ );
112
+
113
+ useEffect(() => {
114
+ if (!enabled) return;
115
+
116
+ window.addEventListener("keydown", handleKeyDown, capture);
117
+ return () => window.removeEventListener("keydown", handleKeyDown, capture);
118
+ }, [enabled, handleKeyDown, capture]);
119
+ };
120
+
121
+ /**
122
+ * Simple hook for list navigation with j/k keys.
123
+ */
124
+ export const useListNavigation = <T extends { id: string }>({
125
+ items,
126
+ selectedId,
127
+ onSelect,
128
+ enabled = true,
129
+ }: {
130
+ items: T[];
131
+ selectedId: string | undefined;
132
+ onSelect: (id: string) => void;
133
+ enabled?: boolean;
134
+ }) => {
135
+ const currentIndex = selectedId
136
+ ? items.findIndex((item) => item.id === selectedId)
137
+ : -1;
138
+
139
+ const selectNext = useCallback(() => {
140
+ if (items.length === 0) return;
141
+ const nextIndex =
142
+ currentIndex < items.length - 1 ? currentIndex + 1 : currentIndex;
143
+ if (nextIndex >= 0 && nextIndex < items.length) {
144
+ onSelect(items[nextIndex].id);
145
+ }
146
+ }, [items, currentIndex, onSelect]);
147
+
148
+ const selectPrevious = useCallback(() => {
149
+ if (items.length === 0) return;
150
+ const prevIndex = currentIndex > 0 ? currentIndex - 1 : 0;
151
+ if (prevIndex >= 0 && prevIndex < items.length) {
152
+ onSelect(items[prevIndex].id);
153
+ }
154
+ }, [items, currentIndex, onSelect]);
155
+
156
+ const selectFirst = useCallback(() => {
157
+ if (items.length > 0) {
158
+ onSelect(items[0].id);
159
+ }
160
+ }, [items, onSelect]);
161
+
162
+ const selectLast = useCallback(() => {
163
+ if (items.length > 0) {
164
+ onSelect(items[items.length - 1].id);
165
+ }
166
+ }, [items, onSelect]);
167
+
168
+ useKeyboardNavigation({
169
+ enabled,
170
+ bindings: [
171
+ { key: "j", handler: selectNext, preventDefault: true },
172
+ { key: "ArrowDown", handler: selectNext, preventDefault: true },
173
+ { key: "k", handler: selectPrevious, preventDefault: true },
174
+ { key: "ArrowUp", handler: selectPrevious, preventDefault: true },
175
+ { key: "g", handler: selectFirst, preventDefault: true },
176
+ {
177
+ key: "G",
178
+ handler: selectLast,
179
+ noModifiers: false,
180
+ preventDefault: true,
181
+ },
182
+ ],
183
+ });
184
+
185
+ return {
186
+ currentIndex,
187
+ selectNext,
188
+ selectPrevious,
189
+ selectFirst,
190
+ selectLast,
191
+ };
192
+ };
@@ -0,0 +1,47 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { isSinglePaneTier, resolveLayoutTier } from "./useLayoutTier";
4
+
5
+ describe("resolveLayoutTier breakpoints (#784)", () => {
6
+ it("is phone below 768", () => {
7
+ assert.equal(resolveLayoutTier(767), "phone");
8
+ assert.equal(resolveLayoutTier(390), "phone");
9
+ assert.equal(resolveLayoutTier(0), "phone");
10
+ });
11
+
12
+ it("is tablet from 768 up to 1023", () => {
13
+ assert.equal(resolveLayoutTier(768), "tablet");
14
+ assert.equal(resolveLayoutTier(900), "tablet");
15
+ assert.equal(resolveLayoutTier(1023), "tablet");
16
+ });
17
+
18
+ it("is desktop from 1024 up", () => {
19
+ assert.equal(resolveLayoutTier(1024), "desktop");
20
+ assert.equal(resolveLayoutTier(1440), "desktop");
21
+ });
22
+ });
23
+
24
+ describe("isSinglePaneTier — compose surface must mount below desktop", () => {
25
+ // Regression: AppShellSlotted mounts the reading pane only at desktop, and
26
+ // the compose surface lives in the single pane below it. Tablet (768–1023)
27
+ // must be single-pane so "c" / the FAB can open compose — keying the pane
28
+ // choice off "phone" alone left tablet with no compose surface.
29
+ it("is single-pane at phone", () => {
30
+ assert.equal(isSinglePaneTier("phone"), true);
31
+ });
32
+
33
+ it("is single-pane at tablet (the regression tier)", () => {
34
+ assert.equal(isSinglePaneTier("tablet"), true);
35
+ });
36
+
37
+ it("is NOT single-pane at desktop (reading pane hosts compose)", () => {
38
+ assert.equal(isSinglePaneTier("desktop"), false);
39
+ });
40
+
41
+ it("treats every below-desktop width as single-pane via resolveLayoutTier", () => {
42
+ assert.equal(isSinglePaneTier(resolveLayoutTier(390)), true);
43
+ assert.equal(isSinglePaneTier(resolveLayoutTier(768)), true);
44
+ assert.equal(isSinglePaneTier(resolveLayoutTier(1023)), true);
45
+ assert.equal(isSinglePaneTier(resolveLayoutTier(1024)), false);
46
+ });
47
+ });
@@ -0,0 +1,44 @@
1
+ import { useMediaQuery } from "./useMediaQuery";
2
+
3
+ /**
4
+ * Responsive layout tiers for the mail shell:
5
+ * - `phone` (< 768px) single pane: list → reading swaps in place.
6
+ * - `tablet` (768–1023px) two panes: list + reading, nav rail is drawer-backed.
7
+ * - `desktop` (>= 1024px) full four-pane shell (nav · list · reading · pane 4).
8
+ *
9
+ * Pane 4 (intelligence) is desktop-only; `useIsDesktop` stays the pane-4 gate.
10
+ */
11
+ export type LayoutTier = "phone" | "tablet" | "desktop";
12
+
13
+ export const TABLET_MIN_WIDTH = 768;
14
+ export const DESKTOP_MIN_WIDTH = 1024;
15
+
16
+ /** Pure breakpoint resolution, so the tier logic is testable without a DOM. */
17
+ export const resolveLayoutTier = (width: number): LayoutTier => {
18
+ if (width >= DESKTOP_MIN_WIDTH) return "desktop";
19
+ if (width >= TABLET_MIN_WIDTH) return "tablet";
20
+ return "phone";
21
+ };
22
+
23
+ /**
24
+ * Whether the mail shell shows a SINGLE pane (no reading pane) at this tier.
25
+ * AppShellSlotted mounts the reading pane only at desktop (≥1024px), so both
26
+ * phone AND tablet are single-pane: the one pane swaps in place between the
27
+ * list, an open thread, and the compose surface. Compose therefore must mount
28
+ * via the single-pane view at tablet too — keying the pane choice off "phone"
29
+ * alone left tablet with no compose surface ("c" / FAB opened nothing).
30
+ */
31
+ export const isSinglePaneTier = (tier: LayoutTier): boolean =>
32
+ tier !== "desktop";
33
+
34
+ /**
35
+ * Returns the current layout tier. SSR-safe (falls back to `phone` before
36
+ * hydration, matching `useMediaQuery`'s server default).
37
+ */
38
+ export const useLayoutTier = (): LayoutTier => {
39
+ const isTabletUp = useMediaQuery(`(min-width: ${TABLET_MIN_WIDTH}px)`);
40
+ const isDesktop = useMediaQuery(`(min-width: ${DESKTOP_MIN_WIDTH}px)`);
41
+ if (isDesktop) return "desktop";
42
+ if (isTabletUp) return "tablet";
43
+ return "phone";
44
+ };