@remit/web-client 0.0.180 → 0.0.182

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.180",
3
+ "version": "0.0.182",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -373,6 +373,24 @@ export const ComposeForm = ({
373
373
  * the subject the reader edited.
374
374
  */
375
375
  const resumedDraftRef = useRef(outboxMessageId !== undefined);
376
+ /**
377
+ * The identity this form has already taken from the surface that mounted it.
378
+ * A reply learns which account the message reached only once the mailbox it
379
+ * was delivered to resolves, which lands after the first render — so From
380
+ * follows the prop instead of only its initial value. Applied once per
381
+ * identity, so a reader who then picks another one keeps it.
382
+ */
383
+ const appliedAccountIdRef = useRef(account?.accountId);
384
+
385
+ useEffect(() => {
386
+ const accountId = account?.accountId;
387
+ if (!accountId || appliedAccountIdRef.current === accountId) return;
388
+ appliedAccountIdRef.current = accountId;
389
+ // A saved draft carries the account it was written from; that is the one
390
+ // it reopens on.
391
+ if (resumedDraftRef.current) return;
392
+ setSelectedAccountId(accountId);
393
+ }, [account?.accountId]);
376
394
 
377
395
  // The document this form is on changed under it, so it starts again: another
378
396
  // draft, or no draft at all. Without this the previous one's fields stay on
@@ -3,6 +3,7 @@ import {
3
3
  messageOperationsDescribeMessageOptions,
4
4
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
5
  import { useQuery } from "@tanstack/react-query";
6
+ import { useMailboxAccount } from "@/hooks/useMailboxAccount";
6
7
  import {
7
8
  type ReplyAddress,
8
9
  useAdoptReplyDraft,
@@ -38,6 +39,17 @@ export const ConversationCompose = ({ surface }: { surface: ReplyAddress }) => {
38
39
  staleTime: Infinity,
39
40
  });
40
41
 
42
+ // The answer leaves from the identity the message reached, so the account is
43
+ // the one owning the mailbox it was delivered to. Taking the head of the
44
+ // configured list answered every account's mail from the first identity, and
45
+ // left the reader's own address in the Cc of a Reply All (#819).
46
+ const { accountId: deliveredToAccountId } = useMailboxAccount(
47
+ sourceMessage?.message.mailboxId,
48
+ );
49
+ const account = config?.accounts?.find(
50
+ (candidate) => candidate.accountId === deliveredToAccountId,
51
+ );
52
+
41
53
  return (
42
54
  <div className="border-b border-line bg-canvas">
43
55
  {/* No key on the draft: the first autosave writes the id it created into
@@ -46,7 +58,7 @@ export const ConversationCompose = ({ surface }: { surface: ReplyAddress }) => {
46
58
  <ComposeForm
47
59
  layout="flow"
48
60
  mode={surface.mode}
49
- account={config?.accounts?.[0]}
61
+ account={account}
50
62
  sourceMessage={sourceMessage}
51
63
  outboxMessageId={surface.outboxMessageId}
52
64
  onDraftCreated={adoptCreatedDraft}
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { unifiedThreadOperationsListAllThreadsOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
16
16
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
17
- import { ReadingPaneEmpty, useAppShellLayout } from "@remit/ui";
17
+ import { ReadingPaneEmpty } from "@remit/ui";
18
18
  import { useQuery } from "@tanstack/react-query";
19
19
  import { useNavigate } from "@tanstack/react-router";
20
20
  import {
@@ -24,14 +24,15 @@ import {
24
24
  useContext,
25
25
  useMemo,
26
26
  } from "react";
27
- import { Drawer } from "@/components/layout/Drawer";
28
27
  import { ConversationView } from "@/components/mail/ConversationView";
29
28
  import { DailyBrief } from "@/components/mail/DailyBrief";
29
+ import { IntelligenceDrawer } from "@/components/mail/IntelligenceDrawer";
30
30
  import { IntelligencePane } from "@/components/mail/IntelligencePane";
31
31
  import { MessageToolbar } from "@/components/mail/MessageToolbar";
32
32
  import type { OpenMessageOptions } from "@/components/mail/ThreadListInteraction";
33
33
  import { useDeleteMessages } from "@/hooks/useDeleteMessages";
34
34
  import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
35
+ import { useIntelligenceSurface } from "@/hooks/useIntelligenceSurface";
35
36
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
36
37
  import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
37
38
  import { useThreadRow } from "@/hooks/useThreadRow";
@@ -328,52 +329,64 @@ function BriefList() {
328
329
  * Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
329
330
  */
330
331
  function BriefReading() {
331
- const { conversation, actions, onReply } = useBriefPane();
332
- const { intelligenceOpen, onToggleIntelligence } = useMailContext();
333
- // The rail's own width gate, not the shell tier: between 1024 and 1280 the
334
- // reading pane is mounted but the rail is not, so "enabled" would promise an
335
- // open that cannot happen.
336
- const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
332
+ const {
333
+ conversation,
334
+ selectedThread,
335
+ actions,
336
+ onReply,
337
+ handleDeselectIfRemoved,
338
+ } = useBriefPane();
337
339
  const hasThread = Boolean(conversation);
338
- const canToggleIntelligence = railFits && hasThread;
340
+ const intelligence = useIntelligenceSurface(conversation?.threadId);
339
341
 
340
342
  return (
341
- <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
342
- <MessageToolbar
343
- hasThread={hasThread}
344
- intelligenceOpen={canToggleIntelligence && intelligenceOpen}
345
- canToggleIntelligence={canToggleIntelligence}
346
- onToggleIntelligence={onToggleIntelligence}
347
- onReply={onReply ? () => onReply("reply") : undefined}
348
- onReplyAll={onReply ? () => onReply("reply-all") : undefined}
349
- onForward={onReply ? () => onReply("forward") : undefined}
350
- onDelete={hasThread ? actions.deleteThread : undefined}
351
- onToggleStar={hasThread ? actions.toggleStar : undefined}
352
- isStarred={actions.isStarred}
353
- moveContext={
354
- hasThread && actions.accountId && actions.mailboxId
355
- ? {
356
- accountId: actions.accountId,
357
- currentMailboxId: actions.mailboxId,
358
- onMove: actions.moveThread,
359
- }
360
- : undefined
361
- }
343
+ <>
344
+ <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
345
+ <MessageToolbar
346
+ hasThread={hasThread}
347
+ intelligenceOpen={intelligence.isShowing}
348
+ canToggleIntelligence={intelligence.canToggle}
349
+ onToggleIntelligence={intelligence.toggle}
350
+ onReply={onReply ? () => onReply("reply") : undefined}
351
+ onReplyAll={onReply ? () => onReply("reply-all") : undefined}
352
+ onForward={onReply ? () => onReply("forward") : undefined}
353
+ onDelete={hasThread ? actions.deleteThread : undefined}
354
+ onToggleStar={hasThread ? actions.toggleStar : undefined}
355
+ isStarred={actions.isStarred}
356
+ moveContext={
357
+ hasThread && actions.accountId && actions.mailboxId
358
+ ? {
359
+ accountId: actions.accountId,
360
+ currentMailboxId: actions.mailboxId,
361
+ onMove: actions.moveThread,
362
+ }
363
+ : undefined
364
+ }
365
+ />
366
+ <div className="min-h-0 flex-1 overflow-hidden">
367
+ {conversation ? (
368
+ <ConversationView
369
+ threadId={conversation.threadId}
370
+ mailboxId={conversation.mailboxId}
371
+ subject={conversation.subject}
372
+ selectedMessageId={conversation.messageId}
373
+ authenticity={conversation.authenticity}
374
+ onOpenIntelligence={intelligence.open}
375
+ />
376
+ ) : (
377
+ <ReadingPaneEmpty />
378
+ )}
379
+ </div>
380
+ </section>
381
+ <IntelligenceDrawer
382
+ isOpen={intelligence.drawerOpen}
383
+ onClose={intelligence.closeDrawer}
384
+ thread={selectedThread}
385
+ mailboxId={selectedThread?.mailboxId}
386
+ accountId={selectedThread?.accountId}
387
+ onAfterOptimisticRemove={handleDeselectIfRemoved}
362
388
  />
363
- <div className="min-h-0 flex-1 overflow-hidden">
364
- {conversation ? (
365
- <ConversationView
366
- threadId={conversation.threadId}
367
- mailboxId={conversation.mailboxId}
368
- subject={conversation.subject}
369
- selectedMessageId={conversation.messageId}
370
- authenticity={conversation.authenticity}
371
- />
372
- ) : (
373
- <ReadingPaneEmpty />
374
- )}
375
- </div>
376
- </section>
389
+ </>
377
390
  );
378
391
  }
379
392
 
@@ -428,21 +441,14 @@ function BriefPhone() {
428
441
  }
429
442
  mobileIntelligenceOpen={drawer.isOpen}
430
443
  />
431
- <Drawer
444
+ <IntelligenceDrawer
432
445
  isOpen={drawer.isOpen}
433
446
  onClose={drawer.close}
434
- ariaLabel="Message details"
435
- side="right"
436
- >
437
- <IntelligencePane
438
- onClose={drawer.close}
439
- thread={selectedThread}
440
- mailboxId={selectedThread?.mailboxId}
441
- accountId={selectedThread?.accountId}
442
- hideCloseButton
443
- onAfterOptimisticRemove={handleDeselectIfRemoved}
444
- />
445
- </Drawer>
447
+ thread={selectedThread}
448
+ mailboxId={selectedThread?.mailboxId}
449
+ accountId={selectedThread?.accountId}
450
+ onAfterOptimisticRemove={handleDeselectIfRemoved}
451
+ />
446
452
  </>
447
453
  );
448
454
  }
@@ -1,7 +1,4 @@
1
- import {
2
- configOperationsGetConfigOptions,
3
- threadDetailOperationsListThreadMessagesOptions,
4
- } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
1
+ import { threadDetailOperationsListThreadMessagesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
2
  import type { RemitImapMessageAuthenticity } from "@remit/api-http-client/types.gen.ts";
6
3
  import { MobileReadingPane } from "@remit/ui";
7
4
  import { useQuery } from "@tanstack/react-query";
@@ -196,19 +193,17 @@ export const ConversationView = ({
196
193
  }
197
194
  }, [orderedMessages, focusedIndex, toggleExpanded]);
198
195
 
199
- const { data: config } = useQuery({
200
- ...configOperationsGetConfigOptions(),
201
- staleTime: Infinity,
202
- });
203
- const activeAccount = config?.accounts?.[0];
204
-
205
- // Mark messages as read immediately when expanded.
196
+ // Mark messages as read immediately when expanded. The unread badge that has
197
+ // to be refreshed belongs to the account this mailbox is in, so the
198
+ // invalidation is scoped to that one rather than to the first configured
199
+ // account, whose list was being refetched while the right one went stale
200
+ // (#819).
206
201
  useMarkAsRead({
207
202
  messages,
208
203
  expandedIds,
209
204
  threadId,
210
205
  mailboxId,
211
- accountId: activeAccount?.accountId,
206
+ accountId: mailboxAccountId,
212
207
  });
213
208
 
214
209
  // Star toggle functionality
@@ -25,7 +25,7 @@
25
25
  * On phone, use `<FlaggedPane.Phone />` instead.
26
26
  */
27
27
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
28
- import { ReadingPaneEmpty, useAppShellLayout } from "@remit/ui";
28
+ import { ReadingPaneEmpty } from "@remit/ui";
29
29
  import { useNavigate } from "@tanstack/react-router";
30
30
  import {
31
31
  createContext,
@@ -34,14 +34,15 @@ import {
34
34
  useContext,
35
35
  useMemo,
36
36
  } from "react";
37
- import { Drawer } from "@/components/layout/Drawer";
38
37
  import { ConversationView } from "@/components/mail/ConversationView";
39
38
  import { FlaggedList } from "@/components/mail/FlaggedList";
39
+ import { IntelligenceDrawer } from "@/components/mail/IntelligenceDrawer";
40
40
  import { IntelligencePane } from "@/components/mail/IntelligencePane";
41
41
  import { MessageToolbar } from "@/components/mail/MessageToolbar";
42
42
  import type { OpenMessageOptions } from "@/components/mail/ThreadListInteraction";
43
43
  import { useDeleteMessages } from "@/hooks/useDeleteMessages";
44
44
  import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
45
+ import { useIntelligenceSurface } from "@/hooks/useIntelligenceSurface";
45
46
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
46
47
  import { useStarredThreads } from "@/hooks/useStarredThreads";
47
48
  import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
@@ -331,52 +332,64 @@ function FlaggedListSlot() {
331
332
  * Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
332
333
  */
333
334
  function FlaggedReading() {
334
- const { conversation, actions, onReply } = useFlaggedPane();
335
- const { intelligenceOpen, onToggleIntelligence } = useMailContext();
336
- // The rail's own width gate, not the shell tier: between 1024 and 1280 the
337
- // reading pane is mounted but the rail is not, so "enabled" would promise an
338
- // open that cannot happen.
339
- const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
335
+ const {
336
+ conversation,
337
+ selectedThread,
338
+ actions,
339
+ onReply,
340
+ handleDeselectIfRemoved,
341
+ } = useFlaggedPane();
340
342
  const hasThread = Boolean(conversation);
341
- const canToggleIntelligence = railFits && hasThread;
343
+ const intelligence = useIntelligenceSurface(conversation?.threadId);
342
344
 
343
345
  return (
344
- <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
345
- <MessageToolbar
346
- hasThread={hasThread}
347
- intelligenceOpen={canToggleIntelligence && intelligenceOpen}
348
- canToggleIntelligence={canToggleIntelligence}
349
- onToggleIntelligence={onToggleIntelligence}
350
- onReply={onReply ? () => onReply("reply") : undefined}
351
- onReplyAll={onReply ? () => onReply("reply-all") : undefined}
352
- onForward={onReply ? () => onReply("forward") : undefined}
353
- onDelete={hasThread ? actions.deleteThread : undefined}
354
- onToggleStar={hasThread ? actions.toggleStar : undefined}
355
- isStarred={actions.isStarred}
356
- moveContext={
357
- hasThread && actions.accountId && actions.mailboxId
358
- ? {
359
- accountId: actions.accountId,
360
- currentMailboxId: actions.mailboxId,
361
- onMove: actions.moveThread,
362
- }
363
- : undefined
364
- }
346
+ <>
347
+ <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
348
+ <MessageToolbar
349
+ hasThread={hasThread}
350
+ intelligenceOpen={intelligence.isShowing}
351
+ canToggleIntelligence={intelligence.canToggle}
352
+ onToggleIntelligence={intelligence.toggle}
353
+ onReply={onReply ? () => onReply("reply") : undefined}
354
+ onReplyAll={onReply ? () => onReply("reply-all") : undefined}
355
+ onForward={onReply ? () => onReply("forward") : undefined}
356
+ onDelete={hasThread ? actions.deleteThread : undefined}
357
+ onToggleStar={hasThread ? actions.toggleStar : undefined}
358
+ isStarred={actions.isStarred}
359
+ moveContext={
360
+ hasThread && actions.accountId && actions.mailboxId
361
+ ? {
362
+ accountId: actions.accountId,
363
+ currentMailboxId: actions.mailboxId,
364
+ onMove: actions.moveThread,
365
+ }
366
+ : undefined
367
+ }
368
+ />
369
+ <div className="min-h-0 flex-1 overflow-hidden">
370
+ {conversation ? (
371
+ <ConversationView
372
+ threadId={conversation.threadId}
373
+ mailboxId={conversation.mailboxId}
374
+ subject={conversation.subject}
375
+ selectedMessageId={conversation.messageId}
376
+ authenticity={conversation.authenticity}
377
+ onOpenIntelligence={intelligence.open}
378
+ />
379
+ ) : (
380
+ <ReadingPaneEmpty />
381
+ )}
382
+ </div>
383
+ </section>
384
+ <IntelligenceDrawer
385
+ isOpen={intelligence.drawerOpen}
386
+ onClose={intelligence.closeDrawer}
387
+ thread={selectedThread}
388
+ mailboxId={selectedThread?.mailboxId}
389
+ accountId={selectedThread?.accountId}
390
+ onAfterOptimisticRemove={handleDeselectIfRemoved}
365
391
  />
366
- <div className="min-h-0 flex-1 overflow-hidden">
367
- {conversation ? (
368
- <ConversationView
369
- threadId={conversation.threadId}
370
- mailboxId={conversation.mailboxId}
371
- subject={conversation.subject}
372
- selectedMessageId={conversation.messageId}
373
- authenticity={conversation.authenticity}
374
- />
375
- ) : (
376
- <ReadingPaneEmpty />
377
- )}
378
- </div>
379
- </section>
392
+ </>
380
393
  );
381
394
  }
382
395
 
@@ -429,21 +442,14 @@ function FlaggedPhone() {
429
442
  }
430
443
  mobileIntelligenceOpen={drawer.isOpen}
431
444
  />
432
- <Drawer
445
+ <IntelligenceDrawer
433
446
  isOpen={drawer.isOpen}
434
447
  onClose={drawer.close}
435
- ariaLabel="Message details"
436
- side="right"
437
- >
438
- <IntelligencePane
439
- onClose={drawer.close}
440
- thread={selectedThread}
441
- mailboxId={selectedThread?.mailboxId}
442
- accountId={selectedThread?.accountId}
443
- hideCloseButton
444
- onAfterOptimisticRemove={handleDeselectIfRemoved}
445
- />
446
- </Drawer>
448
+ thread={selectedThread}
449
+ mailboxId={selectedThread?.mailboxId}
450
+ accountId={selectedThread?.accountId}
451
+ onAfterOptimisticRemove={handleDeselectIfRemoved}
452
+ />
447
453
  </>
448
454
  );
449
455
  }
@@ -0,0 +1,44 @@
1
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
+ import { Drawer } from "@/components/layout/Drawer";
3
+ import { IntelligencePane } from "@/components/mail/IntelligencePane";
4
+
5
+ /**
6
+ * The intelligence pane as a modal drawer — the surface every list pane uses
7
+ * wherever the rail has no room, which is the phone and the two-pane desktop
8
+ * band between 1024 and 1280px. Its open state is the caller's
9
+ * (`useIntelligenceSurface`); what is inside it is the same panel the rail
10
+ * mounts, minus its own close, because the drawer's header already carries one.
11
+ */
12
+ export function IntelligenceDrawer({
13
+ isOpen,
14
+ onClose,
15
+ thread,
16
+ mailboxId,
17
+ accountId,
18
+ onAfterOptimisticRemove,
19
+ }: {
20
+ isOpen: boolean;
21
+ onClose: () => void;
22
+ thread?: RemitImapThreadMessageResponse;
23
+ mailboxId?: string;
24
+ accountId?: string;
25
+ onAfterOptimisticRemove?: (messageIds: string[]) => void;
26
+ }) {
27
+ return (
28
+ <Drawer
29
+ isOpen={isOpen}
30
+ onClose={onClose}
31
+ ariaLabel="Message details"
32
+ side="right"
33
+ >
34
+ <IntelligencePane
35
+ onClose={onClose}
36
+ thread={thread}
37
+ mailboxId={mailboxId}
38
+ accountId={accountId}
39
+ hideCloseButton
40
+ onAfterOptimisticRemove={onAfterOptimisticRemove}
41
+ />
42
+ </Drawer>
43
+ );
44
+ }
@@ -31,7 +31,6 @@ import {
31
31
  RefreshButton,
32
32
  type RescueCandidate,
33
33
  type SearchResult,
34
- useAppShellLayout,
35
34
  } from "@remit/ui";
36
35
  import { useInfiniteQuery } from "@tanstack/react-query";
37
36
  import { useNavigate } from "@tanstack/react-router";
@@ -46,9 +45,9 @@ import {
46
45
  useRef,
47
46
  useState,
48
47
  } from "react";
49
- import { Drawer } from "@/components/layout/Drawer";
50
48
  import { ConversationView } from "@/components/mail/ConversationView";
51
49
  import { DraftsView } from "@/components/mail/DraftsView";
50
+ import { IntelligenceDrawer } from "@/components/mail/IntelligenceDrawer";
52
51
  import { IntelligencePane } from "@/components/mail/IntelligencePane";
53
52
  import {
54
53
  MessageList,
@@ -73,6 +72,7 @@ import {
73
72
  import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
74
73
  import { useIntelligenceData } from "@/hooks/useIntelligenceData";
75
74
  import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
75
+ import { useIntelligenceSurface } from "@/hooks/useIntelligenceSurface";
76
76
  import { useLayoutTier } from "@/hooks/useLayoutTier";
77
77
  import { useMailboxAccount } from "@/hooks/useMailboxAccount";
78
78
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
@@ -1059,19 +1059,14 @@ function MailboxReading() {
1059
1059
  mailboxAccountLoading,
1060
1060
  selectedThread,
1061
1061
  conversation,
1062
- intelligenceOpen,
1063
- onToggleIntelligence,
1064
1062
  onReply,
1065
1063
  onToolbarDelete,
1066
1064
  onToolbarStar,
1067
1065
  onToolbarMove,
1068
1066
  handleDeselectIfRemoved,
1069
1067
  } = useMailboxPane();
1070
- // Which surface intelligence has here: the rail between 1280 and up, the
1071
- // mobile drawer below that, where the reading pane is mounted and the rail
1072
- // has no room.
1073
- const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
1074
1068
  const hasThread = Boolean(conversation);
1069
+ const intelligence = useIntelligenceSurface(conversation?.threadId);
1075
1070
 
1076
1071
  // The rail opens itself on a DKIM mismatch. It lives here, behind the rail's
1077
1072
  // own width gate, because raising it writes `#intelligence` into the address
@@ -1079,6 +1074,7 @@ function MailboxReading() {
1079
1074
  // opens nothing (`docs/architecture/url-state.md`, R6). Below this width the
1080
1075
  // banner is the announcement and its "Why?" is the way in.
1081
1076
  const autoOpenedForRef = useRef<string | null>(null);
1077
+ const { railFits, openRail } = intelligence;
1082
1078
  useEffect(() => {
1083
1079
  if (!railFits) return;
1084
1080
  const id = selectedThread?.messageId ?? null;
@@ -1086,31 +1082,14 @@ function MailboxReading() {
1086
1082
  if (autoOpenedForRef.current === id) return;
1087
1083
  if (!selectedThread?.authenticity?.dkimMismatch) return;
1088
1084
  autoOpenedForRef.current = id;
1089
- if (!intelligenceOpen) onToggleIntelligence();
1085
+ openRail();
1090
1086
  }, [
1091
1087
  railFits,
1088
+ openRail,
1092
1089
  selectedThread?.messageId,
1093
1090
  selectedThread?.authenticity?.dkimMismatch,
1094
- intelligenceOpen,
1095
- onToggleIntelligence,
1096
1091
  ]);
1097
1092
 
1098
- const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
1099
- const drawerOpen = !railFits && drawer.isOpen;
1100
- const closeIntelligenceDrawer = drawer.close;
1101
- const openIntelligence = railFits ? onToggleIntelligence : drawer.open;
1102
- // The toolbar's control, which toggles whichever surface this width has.
1103
- const { toggle: toggleDrawer } = drawer;
1104
- const toggleIntelligence = useCallback(() => {
1105
- if (railFits) {
1106
- onToggleIntelligence();
1107
- return;
1108
- }
1109
- toggleDrawer();
1110
- }, [railFits, onToggleIntelligence, toggleDrawer]);
1111
- const intelligenceShowing =
1112
- hasThread && (railFits ? intelligenceOpen : drawerOpen);
1113
-
1114
1093
  const detailPane = conversation ? (
1115
1094
  <ConversationView
1116
1095
  threadId={conversation.threadId}
@@ -1118,9 +1097,7 @@ function MailboxReading() {
1118
1097
  subject={conversation.subject}
1119
1098
  selectedMessageId={conversation.messageId}
1120
1099
  authenticity={conversation.authenticity}
1121
- onOpenIntelligence={
1122
- conversation.authenticity?.dkimMismatch ? openIntelligence : undefined
1123
- }
1100
+ onOpenIntelligence={intelligence.open}
1124
1101
  />
1125
1102
  ) : (
1126
1103
  <ReadingPaneEmpty />
@@ -1132,9 +1109,9 @@ function MailboxReading() {
1132
1109
  <MessageToolbar
1133
1110
  hasThread={hasThread}
1134
1111
  messageId={conversation?.messageId}
1135
- intelligenceOpen={intelligenceShowing}
1136
- canToggleIntelligence={hasThread}
1137
- onToggleIntelligence={toggleIntelligence}
1112
+ intelligenceOpen={intelligence.isShowing}
1113
+ canToggleIntelligence={intelligence.canToggle}
1114
+ onToggleIntelligence={intelligence.toggle}
1138
1115
  onReply={onReply ? () => onReply("reply") : undefined}
1139
1116
  onReplyAll={onReply ? () => onReply("reply-all") : undefined}
1140
1117
  onForward={onReply ? () => onReply("forward") : undefined}
@@ -1154,21 +1131,14 @@ function MailboxReading() {
1154
1131
  />
1155
1132
  <div className="min-h-0 flex-1 overflow-hidden">{detailPane}</div>
1156
1133
  </section>
1157
- <Drawer
1158
- isOpen={drawerOpen}
1159
- onClose={closeIntelligenceDrawer}
1160
- ariaLabel="Message details"
1161
- side="right"
1162
- >
1163
- <IntelligencePane
1164
- onClose={closeIntelligenceDrawer}
1165
- thread={selectedThread}
1166
- mailboxId={mailboxId}
1167
- accountId={mailboxAccountId}
1168
- hideCloseButton
1169
- onAfterOptimisticRemove={handleDeselectIfRemoved}
1170
- />
1171
- </Drawer>
1134
+ <IntelligenceDrawer
1135
+ isOpen={intelligence.drawerOpen}
1136
+ onClose={intelligence.closeDrawer}
1137
+ thread={selectedThread}
1138
+ mailboxId={mailboxId}
1139
+ accountId={mailboxAccountId}
1140
+ onAfterOptimisticRemove={handleDeselectIfRemoved}
1141
+ />
1172
1142
  </>
1173
1143
  );
1174
1144
  }
@@ -1232,21 +1202,14 @@ function MailboxPhone() {
1232
1202
  }
1233
1203
  mobileIntelligenceOpen={drawer.isOpen}
1234
1204
  />
1235
- <Drawer
1205
+ <IntelligenceDrawer
1236
1206
  isOpen={drawer.isOpen}
1237
1207
  onClose={drawer.close}
1238
- ariaLabel="Message details"
1239
- side="right"
1240
- >
1241
- <IntelligencePane
1242
- onClose={drawer.close}
1243
- thread={selectedThread}
1244
- mailboxId={mailboxId}
1245
- accountId={mailboxAccountId}
1246
- hideCloseButton
1247
- onAfterOptimisticRemove={handleDeselectIfRemoved}
1248
- />
1249
- </Drawer>
1208
+ thread={selectedThread}
1209
+ mailboxId={mailboxId}
1210
+ accountId={mailboxAccountId}
1211
+ onAfterOptimisticRemove={handleDeselectIfRemoved}
1212
+ />
1250
1213
  </>
1251
1214
  );
1252
1215
  }
@@ -9,7 +9,8 @@ import { AuthenticityBanner } from "@/components/mail/AuthenticityBanner";
9
9
  *
10
10
  * Between 1024 and 1280px the shell has room for the reading pane but not the
11
11
  * intelligence rail, so the link opens the same right-anchored drawer the phone
12
- * uses `MailboxPane.Reading` mounts it whenever the rail does not fit. The
12
+ * uses. Every list pane mounts it wherever the rail does not fit — the mailbox,
13
+ * the brief and Flagged all read that from `useIntelligenceSurface` (#817). The
13
14
  * warning itself reads as one sentence: the link sits in the text flow at the
14
15
  * body's own size rather than floating off to the right as chrome.
15
16
  */
@@ -73,8 +74,8 @@ const intelligence: IntelligenceData = {
73
74
  };
74
75
 
75
76
  /**
76
- * The reading pane as `MailboxPane.Reading` composes it at this width: the
77
- * banner above the thread body, and the drawer it opens over the panes.
77
+ * The reading pane as every list pane composes it at this width: the banner
78
+ * above the thread body, and the drawer it opens over the panes.
78
79
  */
79
80
  const TwoPaneReading = () => {
80
81
  const [drawerOpen, setDrawerOpen] = useState(false);
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Which identity answers a message, on an instance holding more than one.
3
+ *
4
+ * The account a message reached is the mailbox it was delivered to, so the
5
+ * reply leaves from that identity, the Reply All keeps that identity out of
6
+ * its own Cc, and reading the message refreshes that account's folder list.
7
+ * All three read the first configured account before (#819), which answered
8
+ * every account's mail as the first one.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { afterEach, describe, it } from "node:test";
13
+ import type {
14
+ RemitImapDescribeMessageResponse,
15
+ RemitImapThreadMessageResponse,
16
+ } from "@remit/api-http-client/types.gen.ts";
17
+ import {
18
+ type AnyRouter,
19
+ createMemoryHistory,
20
+ createRootRoute,
21
+ createRoute,
22
+ createRouter,
23
+ Outlet,
24
+ RouterProvider,
25
+ } from "@tanstack/react-router";
26
+ import { createElement } from "react";
27
+ import { ComposeProvider } from "@/components/compose/ComposeProvider";
28
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
29
+ import {
30
+ makeAccount,
31
+ makeMailbox,
32
+ makeThreadMessage,
33
+ } from "@/test-support/fixtures";
34
+ import { type HttpMock, mockFetch } from "@/test-support/http";
35
+ import { MARK_READ_DELAY_MS } from "../../hooks/useMarkAsRead";
36
+ import { ConversationView } from "./ConversationView";
37
+
38
+ const FIRST_ACCOUNT_ID = "acc-first";
39
+ const REACHED_ACCOUNT_ID = "acc-reached";
40
+ const FIRST_MAILBOX_ID = "mbx-first-inbox";
41
+ const REACHED_MAILBOX_ID = "mbx-reached-inbox";
42
+ const THREAD_ID = "thread-1";
43
+ const MESSAGE_ID = "msg-1";
44
+
45
+ const REACHED_EMAIL = "bob@example.com";
46
+
47
+ // Two identities, in the order the config hands them over. The message below
48
+ // reached the second one, so nothing about it can be answered off the head of
49
+ // this list.
50
+ const firstAccount = makeAccount({
51
+ accountId: FIRST_ACCOUNT_ID,
52
+ email: "alice@example.com",
53
+ username: "alice@example.com",
54
+ smtpUsername: "alice@example.com",
55
+ });
56
+
57
+ const reachedAccount = makeAccount({
58
+ accountId: REACHED_ACCOUNT_ID,
59
+ email: REACHED_EMAIL,
60
+ username: REACHED_EMAIL,
61
+ smtpUsername: REACHED_EMAIL,
62
+ });
63
+
64
+ const firstMailbox = makeMailbox({
65
+ mailboxId: FIRST_MAILBOX_ID,
66
+ accountId: FIRST_ACCOUNT_ID,
67
+ fullPath: "INBOX",
68
+ });
69
+
70
+ const reachedMailbox = makeMailbox({
71
+ mailboxId: REACHED_MAILBOX_ID,
72
+ accountId: REACHED_ACCOUNT_ID,
73
+ fullPath: "INBOX",
74
+ });
75
+
76
+ const threadMessage = (isRead: boolean): RemitImapThreadMessageResponse =>
77
+ makeThreadMessage({
78
+ messageId: MESSAGE_ID,
79
+ threadId: THREAD_ID,
80
+ mailboxId: REACHED_MAILBOX_ID,
81
+ accountId: REACHED_ACCOUNT_ID,
82
+ subject: "Lunch Thursday?",
83
+ fromName: "Ada Lovelace",
84
+ fromEmail: "ada@example.com",
85
+ isRead,
86
+ });
87
+
88
+ // Sent to the second identity, with a third party alongside it. A Reply All
89
+ // answers Ada and keeps Carol; the reader's own address is the one address that
90
+ // must not come back in the Cc.
91
+ const describeMessage: RemitImapDescribeMessageResponse = {
92
+ message: {
93
+ messageId: MESSAGE_ID,
94
+ mailboxId: REACHED_MAILBOX_ID,
95
+ uid: 1,
96
+ rfc822Size: 512,
97
+ internalDate: 1_767_225_600_000,
98
+ },
99
+ envelope: {
100
+ messageId: MESSAGE_ID,
101
+ date: 1_767_225_600_000,
102
+ subject: "Lunch Thursday?",
103
+ messageIdValue: "<ada-1@example.com>",
104
+ from: [
105
+ {
106
+ addressId: "addr-ada",
107
+ displayName: "Ada Lovelace",
108
+ normalizedEmail: "ada@example.com",
109
+ addressRole: "from",
110
+ addressOrder: 0,
111
+ },
112
+ ],
113
+ to: [
114
+ {
115
+ addressId: "addr-reached",
116
+ normalizedEmail: REACHED_EMAIL,
117
+ addressRole: "to",
118
+ addressOrder: 0,
119
+ },
120
+ ],
121
+ cc: [
122
+ {
123
+ addressId: "addr-carol",
124
+ normalizedEmail: "carol@example.com",
125
+ addressRole: "cc",
126
+ addressOrder: 0,
127
+ },
128
+ ],
129
+ bcc: [],
130
+ replyTo: [],
131
+ category: "uncategorized",
132
+ senderTrust: "unknown",
133
+ },
134
+ flags: ["\\Seen"],
135
+ bodyParts: [],
136
+ references: [],
137
+ };
138
+
139
+ let harness: DomHarness | undefined;
140
+ let http: HttpMock | undefined;
141
+
142
+ afterEach(() => {
143
+ harness?.close();
144
+ harness = undefined;
145
+ http?.restore();
146
+ http = undefined;
147
+ });
148
+
149
+ // The router reads `self` at construction; the shared jsdom globals stop at
150
+ // `window`.
151
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
152
+
153
+ const MESSAGE_PATH = `/mail/${REACHED_MAILBOX_ID}/${THREAD_ID}/${MESSAGE_ID}`;
154
+
155
+ const testRouter = (href: string): AnyRouter => {
156
+ const rootRoute = createRootRoute({
157
+ component: () =>
158
+ createElement(ComposeProvider, null, createElement(Outlet)),
159
+ });
160
+ const mailboxRoute = createRoute({
161
+ getParentRoute: () => rootRoute,
162
+ path: "/mail/$mailboxId",
163
+ validateSearch: (search: Record<string, unknown>) => search,
164
+ });
165
+ const threadRoute = createRoute({
166
+ getParentRoute: () => mailboxRoute,
167
+ path: "$threadId",
168
+ });
169
+ const messageRoute = createRoute({
170
+ getParentRoute: () => threadRoute,
171
+ path: "$messageId",
172
+ component: () =>
173
+ createElement(ConversationView, {
174
+ threadId: THREAD_ID,
175
+ mailboxId: REACHED_MAILBOX_ID,
176
+ subject: "Lunch Thursday?",
177
+ selectedMessageId: MESSAGE_ID,
178
+ }),
179
+ });
180
+ const replyRoute = createRoute({
181
+ getParentRoute: () => messageRoute,
182
+ path: "$mode/{-$outboxMessageId}",
183
+ });
184
+ const routeTree = rootRoute.addChildren([
185
+ mailboxRoute.addChildren([
186
+ threadRoute.addChildren([messageRoute.addChildren([replyRoute])]),
187
+ ]),
188
+ ]);
189
+ return createRouter({
190
+ routeTree,
191
+ history: createMemoryHistory({ initialEntries: [href] }),
192
+ }) as unknown as AnyRouter;
193
+ };
194
+
195
+ const mountAt = async (
196
+ href: string,
197
+ { isRead = true }: { isRead?: boolean } = {},
198
+ ): Promise<DomHarness> => {
199
+ const messages = [threadMessage(isRead)];
200
+ http = mockFetch((call) => {
201
+ if (call.path.endsWith("/config")) {
202
+ return { accounts: [firstAccount, reachedAccount] };
203
+ }
204
+ if (call.path.endsWith(`/accounts/${FIRST_ACCOUNT_ID}/mailboxes`)) {
205
+ return { items: [firstMailbox] };
206
+ }
207
+ if (call.path.endsWith(`/accounts/${REACHED_ACCOUNT_ID}/mailboxes`)) {
208
+ return { items: [reachedMailbox] };
209
+ }
210
+ if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
211
+ return { items: messages };
212
+ }
213
+ if (call.path.endsWith(`/messages/${MESSAGE_ID}`)) return describeMessage;
214
+ return { items: [] };
215
+ });
216
+
217
+ const router = testRouter(href);
218
+ await router.load();
219
+ harness = createDomHarness();
220
+ harness.renderApp(createElement(RouterProvider, { router }));
221
+ await harness.flush();
222
+ await harness.wait(20);
223
+ await harness.flush();
224
+ return harness;
225
+ };
226
+
227
+ /** The identity the From row is standing on. */
228
+ const chosenIdentity = (mounted: DomHarness): string => {
229
+ const selector = mounted.query<HTMLSelectElement>("#from-account-selector");
230
+ assert.ok(selector, "the From row offers the configured identities");
231
+ return selector.value;
232
+ };
233
+
234
+ /** The addresses held as chips in one of the composer's recipient fields. */
235
+ const recipients = (mounted: DomHarness, label: string): string[] => {
236
+ const input = mounted.query(`#address-field-${label}`);
237
+ assert.ok(input, `the ${label} field is on screen`);
238
+ const field = input.parentElement;
239
+ assert.ok(field, `the ${label} field holds its chips`);
240
+ return [...field.querySelectorAll("[aria-label^='Remove ']")].map((button) =>
241
+ (button.getAttribute("aria-label") ?? "").replace("Remove ", ""),
242
+ );
243
+ };
244
+
245
+ const mailboxListReads = (accountId: string): number =>
246
+ (http?.calls ?? []).filter(
247
+ (call) =>
248
+ call.method === "GET" &&
249
+ call.path.endsWith(`/accounts/${accountId}/mailboxes`),
250
+ ).length;
251
+
252
+ describe("answering from the identity the message reached", () => {
253
+ it("opens the reply on the account the message was delivered to", async () => {
254
+ const mounted = await mountAt(`${MESSAGE_PATH}/reply`);
255
+
256
+ assert.equal(
257
+ chosenIdentity(mounted),
258
+ REACHED_ACCOUNT_ID,
259
+ "the reply leaves from the identity the message reached, not the first one configured",
260
+ );
261
+ });
262
+
263
+ it("keeps the reached identity out of its own Reply All", async () => {
264
+ const mounted = await mountAt(`${MESSAGE_PATH}/reply-all`);
265
+
266
+ assert.deepEqual(
267
+ recipients(mounted, "To"),
268
+ ["ada@example.com"],
269
+ "the answer goes back to the sender",
270
+ );
271
+ assert.deepEqual(
272
+ recipients(mounted, "Cc"),
273
+ ["carol@example.com"],
274
+ "everyone else on the message is kept, and the reader's own address is not copied back to itself",
275
+ );
276
+ });
277
+
278
+ it("refreshes the folder list of the account the message was read in", async () => {
279
+ const mounted = await mountAt(MESSAGE_PATH, { isRead: false });
280
+
281
+ const readsBefore = {
282
+ first: mailboxListReads(FIRST_ACCOUNT_ID),
283
+ reached: mailboxListReads(REACHED_ACCOUNT_ID),
284
+ };
285
+
286
+ await mounted.wait(MARK_READ_DELAY_MS + 100);
287
+ await mounted.flush();
288
+
289
+ assert.equal(
290
+ (http?.to("/messages/flags") ?? []).length,
291
+ 1,
292
+ "the message the reader dwelled on was marked read",
293
+ );
294
+ assert.ok(
295
+ mailboxListReads(REACHED_ACCOUNT_ID) > readsBefore.reached,
296
+ "the unread badge of the account the message was read in is refreshed",
297
+ );
298
+ assert.equal(
299
+ mailboxListReads(FIRST_ACCOUNT_ID),
300
+ readsBefore.first,
301
+ "no other account's folder list is disturbed",
302
+ );
303
+ });
304
+ });
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Every list pane reaches intelligence at every width its reading pane mounts
3
+ * (#817).
4
+ *
5
+ * The shell mounts the reading pane from 1024px and the intelligence rail only
6
+ * from 1280px. The brief and Flagged read the rail's gate as the answer for
7
+ * both, so between the two the toolbar's control rendered greyed out with no
8
+ * drawer behind it, and the authenticity banner's "Why?" was never wired at
9
+ * all — the DKIM explanation was unreachable on the brief at every desktop
10
+ * width. Only the mailbox was ever fixed (#744), which is what left three
11
+ * copies of one rule to drift apart.
12
+ *
13
+ * Mounted in the real `AppShellSlotted`, seeded at 1100px: which panes this
14
+ * width has is the shell's own answer here, not a stub's.
15
+ */
16
+
17
+ import assert from "node:assert/strict";
18
+ import { afterEach, describe, it } from "node:test";
19
+ import { AppShellSlotted } from "@remit/ui";
20
+ import {
21
+ type AnyRouter,
22
+ createMemoryHistory,
23
+ createRootRoute,
24
+ createRoute,
25
+ createRouter,
26
+ Outlet,
27
+ RouterProvider,
28
+ } from "@tanstack/react-router";
29
+ import { type ComponentType, createElement, type ReactNode } from "react";
30
+ import { ComposeProvider } from "@/components/compose/ComposeProvider";
31
+ import { type OpenThreadPath, useOpenThreadPath } from "@/routing";
32
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
33
+ import { makeThreadMessage } from "@/test-support/fixtures";
34
+ import { type HttpMock, mockFetch } from "@/test-support/http";
35
+ import { BriefPane } from "./BriefPane";
36
+ import { FlaggedPane } from "./FlaggedPane";
37
+
38
+ /** Below the rail's 1280px gate, above the reading pane's 1024px one. */
39
+ const TWO_PANE_WIDTH = 1100;
40
+
41
+ const THREAD_ID = "thread-1";
42
+ const MESSAGE_ID = "msg-1";
43
+
44
+ const SHOW_INTELLIGENCE = "Show intelligence sidebar";
45
+ const HIDE_INTELLIGENCE = "Hide intelligence sidebar";
46
+
47
+ /** The one row shape that carries a warning: DKIM signed by another domain. */
48
+ const row = makeThreadMessage({
49
+ messageId: MESSAGE_ID,
50
+ threadId: THREAD_ID,
51
+ subject: "Your parcel could not be delivered",
52
+ fromName: "Mondial Relay",
53
+ fromEmail: "delivery.notice@gmail.example",
54
+ hasStars: true,
55
+ star: "yellow",
56
+ authenticity: {
57
+ dkimMismatch: true,
58
+ fromDomain: "mondialrelay.fr",
59
+ dkimDomain: "gmail.example",
60
+ },
61
+ });
62
+
63
+ /** What the reading pane reads each message's own headers and body from. */
64
+ const describedMessage = {
65
+ messageId: MESSAGE_ID,
66
+ envelope: {
67
+ from: [
68
+ {
69
+ addressId: "addr-1",
70
+ name: "Mondial Relay",
71
+ email: "delivery.notice@gmail.example",
72
+ },
73
+ ],
74
+ to: [],
75
+ cc: [],
76
+ bcc: [],
77
+ },
78
+ bodyParts: [],
79
+ };
80
+
81
+ let harness: DomHarness | undefined;
82
+ let http: HttpMock | undefined;
83
+
84
+ afterEach(() => {
85
+ harness?.close();
86
+ harness = undefined;
87
+ http?.restore();
88
+ http = undefined;
89
+ });
90
+
91
+ // The router reads `self` at construction; the shared jsdom globals stop at
92
+ // `window`.
93
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
94
+
95
+ interface PaneUnderTest {
96
+ name: string;
97
+ /** The list's own segment under `/mail`, which is its whole route. */
98
+ segment: string;
99
+ Provider: ComponentType<{
100
+ thread: OpenThreadPath | undefined;
101
+ children: ReactNode;
102
+ }>;
103
+ Reading: ComponentType;
104
+ }
105
+
106
+ const panes: PaneUnderTest[] = [
107
+ {
108
+ name: "the brief",
109
+ segment: "brief",
110
+ Provider: BriefPane,
111
+ Reading: BriefPane.Reading,
112
+ },
113
+ {
114
+ name: "Flagged",
115
+ segment: "flagged",
116
+ Provider: FlaggedPane,
117
+ Reading: FlaggedPane.Reading,
118
+ },
119
+ ];
120
+
121
+ const testRouter = (pane: PaneUnderTest): AnyRouter => {
122
+ const rootRoute = createRootRoute({
123
+ component: () =>
124
+ createElement(ComposeProvider, null, createElement(Outlet)),
125
+ });
126
+ const mailRoute = createRoute({
127
+ getParentRoute: () => rootRoute,
128
+ path: "/mail",
129
+ validateSearch: (search: Record<string, unknown>) => search,
130
+ component: Outlet,
131
+ });
132
+ // Present so `useBrowsedList` has the route its `from` names, the way the
133
+ // generated tree does.
134
+ const mailboxRoute = createRoute({
135
+ getParentRoute: () => mailRoute,
136
+ path: "/$mailboxId",
137
+ component: Outlet,
138
+ });
139
+ const listRoute = createRoute({
140
+ getParentRoute: () => mailRoute,
141
+ path: `/${pane.segment}`,
142
+ component: () =>
143
+ createElement(pane.Provider, {
144
+ thread: useOpenThreadPath(),
145
+ // biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
146
+ children: createElement(Outlet),
147
+ }),
148
+ });
149
+ const threadRoute = createRoute({
150
+ getParentRoute: () => listRoute,
151
+ path: "$threadId",
152
+ component: Outlet,
153
+ });
154
+ // The shell the route mounts, seeded at a width with no room for the rail.
155
+ const messageRoute = createRoute({
156
+ getParentRoute: () => threadRoute,
157
+ path: "$messageId",
158
+ component: () =>
159
+ createElement(AppShellSlotted, {
160
+ initialWidth: TWO_PANE_WIDTH,
161
+ nav: null,
162
+ list: null,
163
+ reading: createElement(pane.Reading),
164
+ }),
165
+ });
166
+ const routeTree = rootRoute.addChildren([
167
+ mailRoute.addChildren([
168
+ mailboxRoute,
169
+ listRoute.addChildren([threadRoute.addChildren([messageRoute])]),
170
+ ]),
171
+ ]);
172
+ return createRouter({
173
+ routeTree,
174
+ history: createMemoryHistory({
175
+ initialEntries: [`/mail/${pane.segment}/${THREAD_ID}/${MESSAGE_ID}`],
176
+ }),
177
+ }) as unknown as AnyRouter;
178
+ };
179
+
180
+ const mount = async (pane: PaneUnderTest): Promise<DomHarness> => {
181
+ http = mockFetch((call) => {
182
+ if (call.path.endsWith("/config")) return { accounts: [] };
183
+ if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
184
+ return { items: [row] };
185
+ }
186
+ if (call.path.includes("/messages/")) return describedMessage;
187
+ if (call.path.includes("/threads")) return { items: [row] };
188
+ return { items: [] };
189
+ });
190
+
191
+ const router = testRouter(pane);
192
+ await router.load();
193
+ const mounted = createDomHarness({ viewportWidth: TWO_PANE_WIDTH });
194
+ harness = mounted;
195
+ mounted.renderApp(createElement(RouterProvider, { router }));
196
+ await settle(mounted);
197
+ return mounted;
198
+ };
199
+
200
+ const settle = async (mounted: DomHarness): Promise<void> => {
201
+ await mounted.flush();
202
+ await mounted.wait(20);
203
+ await mounted.flush();
204
+ };
205
+
206
+ const drawer = (mounted: DomHarness): HTMLElement | null =>
207
+ mounted.query('[role="dialog"][aria-label="Message details"]');
208
+
209
+ describe("intelligence is reachable wherever the reading pane mounts (#817)", () => {
210
+ for (const pane of panes) {
211
+ it(`${pane.name} offers a live toolbar control below the rail's width`, async () => {
212
+ const mounted = await mount(pane);
213
+
214
+ const toggle = mounted.byLabel(SHOW_INTELLIGENCE) as HTMLButtonElement;
215
+ assert.equal(
216
+ toggle.disabled,
217
+ false,
218
+ "the control was greyed out at a width where the drawer is the surface",
219
+ );
220
+
221
+ mounted.click(toggle);
222
+ await settle(mounted);
223
+
224
+ assert.ok(drawer(mounted), "pressing it opened nothing");
225
+ assert.ok(
226
+ mounted.query(`[aria-label="${HIDE_INTELLIGENCE}"]`),
227
+ "the toolbar still reports the surface as closed",
228
+ );
229
+ });
230
+
231
+ it(`${pane.name} reaches the DKIM explanation from the banner`, async () => {
232
+ const mounted = await mount(pane);
233
+
234
+ mounted.click(mounted.byText("button", "Why?"));
235
+ await settle(mounted);
236
+
237
+ assert.ok(
238
+ drawer(mounted),
239
+ "the banner's Why? reached no intelligence surface",
240
+ );
241
+ });
242
+ }
243
+ });
@@ -344,6 +344,26 @@ describe("buildAuthenticityIntel", () => {
344
344
  assert.match(result.summary, /only looks like/);
345
345
  });
346
346
 
347
+ test("a display name spelling a foreign address names the domain it cannot come from", () => {
348
+ const result = buildAuthenticityIntel(
349
+ makeThread({
350
+ fromEmail: "aramirez@secresaludguaviare.gov.co",
351
+ fromName: "matthijs@ischen.nl",
352
+ authenticity: {
353
+ fromDomain: "secresaludguaviare.gov.co",
354
+ dkimDomain: "secresaludguaviare.gov.co",
355
+ dkimMismatch: false,
356
+ displayNameCorrespondence: "ForeignAddress",
357
+ offDomainLinkDomains: [],
358
+ },
359
+ } as Partial<RemitImapThreadMessageResponse>),
360
+ 0,
361
+ );
362
+ assert.equal(result.verdict, "caution");
363
+ assert.match(result.summary, /"matthijs@ischen\.nl"/);
364
+ assert.match(result.summary, /secresaludguaviare\.gov\.co/);
365
+ });
366
+
347
367
  test("stays verified when the comparisons agreed", () => {
348
368
  const result = buildAuthenticityIntel(
349
369
  makeThread({
@@ -167,6 +167,10 @@ function describeSenderMismatch(
167
167
  clauses.push(
168
168
  `The name it shows, "${claimedBrand}", only looks like ${auth.fromDomain}.`,
169
169
  );
170
+ } else if (correspondence === DisplayNameCorrespondence.ForeignAddress) {
171
+ clauses.push(
172
+ `The name it shows, "${claimedBrand}", is an address ${auth.fromDomain} cannot send from.`,
173
+ );
170
174
  }
171
175
  }
172
176
 
@@ -0,0 +1,64 @@
1
+ import { useAppShellLayout } from "@remit/ui";
2
+ import { useCallback } from "react";
3
+ import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
4
+ import { useMailContext } from "@/lib/mail-context";
5
+
6
+ /**
7
+ * Which surface intelligence has for the open thread: the rail from 1280 up,
8
+ * the drawer below that, where the reading pane is mounted and the rail has no
9
+ * room. Every list pane asks this the same way — the brief and Flagged read the
10
+ * rail's own gate as the answer for both tiers and offered a disabled control
11
+ * with nothing behind it between 1024 and 1280 (#817).
12
+ */
13
+ export interface IntelligenceSurface {
14
+ /** There is a thread to say anything about, so the toolbar's control acts. */
15
+ canToggle: boolean;
16
+ /** What the toolbar reports: the rail above 1280, the drawer below it. */
17
+ isShowing: boolean;
18
+ /** The toolbar's control, over whichever surface this width has. */
19
+ toggle: () => void;
20
+ /** The banner's "Why?" — an open, never a close. */
21
+ open: () => void;
22
+ drawerOpen: boolean;
23
+ closeDrawer: () => void;
24
+ /** Whether this width has room for the rail, so the rail is the surface. */
25
+ railFits: boolean;
26
+ /** Raise the rail if it fits and is down. The DKIM auto-open's way in. */
27
+ openRail: () => void;
28
+ }
29
+
30
+ export const useIntelligenceSurface = (
31
+ openThreadId: string | undefined,
32
+ ): IntelligenceSurface => {
33
+ const { intelligenceOpen, onToggleIntelligence } = useMailContext();
34
+ const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
35
+ const threadId = openThreadId ?? null;
36
+ const drawer = useIntelligenceDrawer(threadId);
37
+ // The rail wins wherever it fits, so the drawer stays down there even if it
38
+ // was asked for at a narrower width the reader has since grown out of.
39
+ const drawerOpen = !railFits && drawer.isOpen;
40
+
41
+ const { open: openDrawer, toggle: toggleDrawer } = drawer;
42
+ const toggle = useCallback(() => {
43
+ if (railFits) {
44
+ onToggleIntelligence();
45
+ return;
46
+ }
47
+ toggleDrawer();
48
+ }, [railFits, onToggleIntelligence, toggleDrawer]);
49
+ const openRail = useCallback(() => {
50
+ if (!railFits || intelligenceOpen) return;
51
+ onToggleIntelligence();
52
+ }, [railFits, intelligenceOpen, onToggleIntelligence]);
53
+
54
+ return {
55
+ canToggle: threadId !== null,
56
+ isShowing: threadId !== null && (railFits ? intelligenceOpen : drawerOpen),
57
+ toggle,
58
+ open: railFits ? onToggleIntelligence : openDrawer,
59
+ drawerOpen,
60
+ closeDrawer: drawer.close,
61
+ railFits,
62
+ openRail,
63
+ };
64
+ };