@remit/web-client 0.0.173 → 0.0.175

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.173",
3
+ "version": "0.0.175",
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": {
@@ -40,6 +40,7 @@ import {
40
40
  import { useMessageBodyContent } from "../../hooks/useMessageBodyContent";
41
41
  import { useSaveDraft } from "../../hooks/useSaveDraft";
42
42
  import { useSignature } from "../../hooks/useSignature.js";
43
+ import { softErrorMeta } from "../../lib/error-classifier";
43
44
  import { accountIsMissingSmtp } from "../settings/account-form-helpers.js";
44
45
  import { useErrorBanners } from "../ui/ErrorBannerProvider.js";
45
46
  import {
@@ -212,6 +213,17 @@ type SendReadiness =
212
213
  | { status: "blocked"; reason: string }
213
214
  | { status: "ready"; accountId: string };
214
215
 
216
+ /**
217
+ * Naming To, not "a recipient". Sending goes through the draft, and a draft is
218
+ * created against `CreateOutboxMessageInput`, whose `@minItems(1)` is on
219
+ * `toAddresses` alone — so a message addressed only in Cc has a recipient and
220
+ * still cannot be sent from here, and being told to add one it can already see
221
+ * leaves it with nothing to do. The server's own send guard counts Cc and Bcc,
222
+ * because a Bcc-only envelope is real mail; it is answering whether the message
223
+ * has anywhere to go, which is not the question this one asks.
224
+ */
225
+ const NO_TO_ADDRESS_MESSAGE = "Add a To address before sending.";
226
+
215
227
  const isFormEmpty = (
216
228
  toAddresses: AddressEntry[],
217
229
  ccAddresses: AddressEntry[],
@@ -536,7 +548,7 @@ export const ComposeForm = ({
536
548
  [onDraftCreated],
537
549
  );
538
550
 
539
- const { saveStatus, saveError, saveDraft, saveImmediately, stopAutoSave } =
551
+ const { saveState, saveError, saveDraft, saveImmediately, stopAutoSave } =
540
552
  useSaveDraft({
541
553
  outboxMessageId,
542
554
  onDraftCreated: adoptCreatedDraft,
@@ -555,12 +567,17 @@ export const ComposeForm = ({
555
567
  });
556
568
  }, [saveError, pushError]);
557
569
 
558
- const sendMutation = useMutation(
559
- outboxDetailOperationsSendOutboxMessageMutation(),
560
- );
570
+ // A refused send is reported below, next to the message it did not send, and
571
+ // the composer stays up so the user can fix the address and press it again.
572
+ // A 5xx still escalates.
573
+ const sendMutation = useMutation({
574
+ ...outboxDetailOperationsSendOutboxMessageMutation(),
575
+ meta: softErrorMeta,
576
+ });
561
577
 
562
578
  const deleteMutation = useMutation({
563
579
  ...outboxDetailOperationsDeleteOutboxMessageMutation(),
580
+ meta: softErrorMeta,
564
581
  onError: (error) => {
565
582
  // Discard closes the dialog optimistically. A soft 4xx (409/404 the
566
583
  // draft is already gone) must not pass silently as success — surface a
@@ -621,7 +638,7 @@ export const ComposeForm = ({
621
638
  return { status: "blocked", reason: SMTP_MISSING_MESSAGE };
622
639
  }
623
640
  if (toAddresses.length === 0) {
624
- return { status: "blocked", reason: "Add at least one recipient." };
641
+ return { status: "blocked", reason: NO_TO_ADDRESS_MESSAGE };
625
642
  }
626
643
  return { status: "ready", accountId: selectedAccountId };
627
644
  }, [
@@ -864,7 +881,7 @@ export const ComposeForm = ({
864
881
  onSend={attemptSend}
865
882
  onBlocked={reportBlocked}
866
883
  onDiscard={handleDiscard}
867
- saveStatus={saveStatus}
884
+ save={saveState}
868
885
  />
869
886
  }
870
887
  >
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Forward opens with a subject and a quote and deliberately no recipient. The
3
+ * autosave gate only skips a form that is blank everywhere, so the seeded
4
+ * subject used to fire a create — and a create carries `@minItems(1)` on
5
+ * `toAddresses`, so the server refused it 400. That refusal then went to the
6
+ * full-screen fatal page, which unmounts the composer and takes the message
7
+ * with it: the one failure mode a composer must never have.
8
+ *
9
+ * Both halves are held here. The invalid request is not made at all while the
10
+ * draft has no recipient to create it with — and the composer says so, because
11
+ * a message being held unsaved in silence is the same loss with the noise taken
12
+ * out. A write that does fail lands in a banner beside the message rather than
13
+ * over it. A 5xx and a 401 keep their own rules.
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { afterEach, describe, it } from "node:test";
18
+ import type {
19
+ RemitImapAccountResponse,
20
+ RemitImapDescribeMessageResponse,
21
+ } from "@remit/api-http-client/types.gen.ts";
22
+ import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
23
+ import {
24
+ type AnyRouter,
25
+ createMemoryHistory,
26
+ createRootRoute,
27
+ createRoute,
28
+ createRouter,
29
+ RouterContextProvider,
30
+ } from "@tanstack/react-router";
31
+ import { createElement, Fragment, useState } from "react";
32
+ import { __resetFatalError } from "../../lib/fatal-error";
33
+ import {
34
+ handleMutationCacheError,
35
+ handleQueryCacheError,
36
+ } from "../../lib/query-error-handler";
37
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
38
+ import { type HttpMock, httpError, mockFetch } from "../../test-support/http";
39
+ import { FatalErrorOverlay } from "../ui/FatalErrorOverlay";
40
+ import { ComposeForm } from "./ComposeForm";
41
+ import { ComposeProvider } from "./ComposeProvider";
42
+
43
+ const ACCOUNT_ID = "acc-1";
44
+ const OUTBOX_MESSAGE_ID = "ob-fwd";
45
+ const AUTOSAVE_DEBOUNCE_MS = 2000;
46
+
47
+ const account = {
48
+ accountId: ACCOUNT_ID,
49
+ email: "me@example.com",
50
+ smtpEnabled: true,
51
+ } as unknown as RemitImapAccountResponse;
52
+
53
+ const sourceMessage = {
54
+ message: { messageId: "msg-1" },
55
+ envelope: {
56
+ subject: "Lunch",
57
+ messageIdValue: "<m1@example.com>",
58
+ from: [{ normalizedEmail: "them@example.com", displayName: "Them" }],
59
+ replyTo: [],
60
+ to: [],
61
+ cc: [],
62
+ },
63
+ references: [],
64
+ bodyParts: [],
65
+ } as unknown as RemitImapDescribeMessageResponse;
66
+
67
+ const outboxEntry = () => ({
68
+ outboxMessageId: OUTBOX_MESSAGE_ID,
69
+ accountId: ACCOUNT_ID,
70
+ fromAddress: account.email,
71
+ toAddresses: ["them@example.com"],
72
+ ccAddresses: [],
73
+ bccAddresses: [],
74
+ references: [],
75
+ subject: "Fwd: Lunch",
76
+ textBody: "here you go",
77
+ status: "draft",
78
+ });
79
+
80
+ let harness: DomHarness | undefined;
81
+ let http: HttpMock | undefined;
82
+
83
+ afterEach(() => {
84
+ harness?.close();
85
+ harness = undefined;
86
+ http?.restore();
87
+ http = undefined;
88
+ __resetFatalError();
89
+ });
90
+
91
+ const creates = () =>
92
+ (http?.calls ?? []).filter(
93
+ (call) => call.method === "POST" && call.path.endsWith("/outbox"),
94
+ );
95
+
96
+ const patches = () =>
97
+ (http?.calls ?? []).filter((call) => call.method === "PATCH");
98
+
99
+ const sends = () =>
100
+ (http?.calls ?? []).filter((call) => call.path.endsWith("/send"));
101
+
102
+ const fatalOverlay = () =>
103
+ harness?.query('[data-testid="fatal-error-overlay"]') ?? null;
104
+
105
+ const bannerAlerts = () =>
106
+ harness?.queryAll('[aria-label="Notifications"] [role="alert"]') ?? [];
107
+
108
+ // The draft the composer is on is its owner's to hand it — here, the test's.
109
+ const Opened = ({ outboxMessageId }: { outboxMessageId?: string }) => {
110
+ const [draftId, setDraftId] = useState(outboxMessageId);
111
+
112
+ return createElement(ComposeForm, {
113
+ mode: "forward",
114
+ account,
115
+ sourceMessage,
116
+ outboxMessageId: draftId,
117
+ onDraftCreated: setDraftId,
118
+ onClose: () => {},
119
+ });
120
+ };
121
+
122
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
123
+
124
+ const rootRoute = createRootRoute();
125
+ const mailboxRoute = createRoute({
126
+ getParentRoute: () => rootRoute,
127
+ path: "/mail/$mailboxId",
128
+ validateSearch: (search: Record<string, unknown>) => search,
129
+ });
130
+
131
+ const testRouter = (): AnyRouter =>
132
+ createRouter({
133
+ routeTree: rootRoute.addChildren([mailboxRoute]),
134
+ history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
135
+ }) as unknown as AnyRouter;
136
+
137
+ interface MountOptions {
138
+ /** The draft compose opens on, when the user is resuming one. */
139
+ outboxMessageId?: string;
140
+ /** The status every PATCH answers with, when it is to fail. */
141
+ patchStatus?: number;
142
+ }
143
+
144
+ /**
145
+ * The stub holds the constraint the API holds: a create with no recipient is
146
+ * refused, in the words the backend's schema validation uses.
147
+ */
148
+ const mount = async (options: MountOptions = {}): Promise<void> => {
149
+ http = mockFetch(async (call) => {
150
+ if (call.path.endsWith("/config")) return { accounts: [account] };
151
+
152
+ if (call.method === "POST" && call.path.endsWith("/outbox")) {
153
+ const to = call.body?.toAddresses;
154
+ if (!Array.isArray(to) || to.length === 0) {
155
+ return httpError(
156
+ 400,
157
+ "body/requestBody/toAddresses must NOT have fewer than 1 items",
158
+ );
159
+ }
160
+ return outboxEntry();
161
+ }
162
+
163
+ if (call.method === "PATCH") {
164
+ if (options.patchStatus) {
165
+ return httpError(options.patchStatus, "the draft moved on");
166
+ }
167
+ return outboxEntry();
168
+ }
169
+
170
+ return outboxEntry();
171
+ });
172
+
173
+ const queryClient = new QueryClient({
174
+ queryCache: new QueryCache({ onError: handleQueryCacheError }),
175
+ mutationCache: new MutationCache({ onError: handleMutationCacheError }),
176
+ defaultOptions: {
177
+ queries: { retry: false },
178
+ mutations: { retry: false },
179
+ },
180
+ });
181
+
182
+ harness = createDomHarness({ queryClient });
183
+ harness.renderApp(
184
+ createElement(
185
+ Fragment,
186
+ null,
187
+ createElement(FatalErrorOverlay),
188
+ createElement(RouterContextProvider, {
189
+ router: testRouter(),
190
+ // biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
191
+ children: createElement(
192
+ ComposeProvider,
193
+ null,
194
+ createElement(Opened, { outboxMessageId: options.outboxMessageId }),
195
+ ),
196
+ }),
197
+ ),
198
+ );
199
+ await harness.flush();
200
+ await harness.wait(50);
201
+ };
202
+
203
+ const subjectField = (): HTMLInputElement => {
204
+ const field = harness?.query<HTMLInputElement>("[data-subject-field]");
205
+ if (!field) throw new Error("the compose subject field is not mounted");
206
+ return field;
207
+ };
208
+
209
+ const recipientField = (): HTMLInputElement => {
210
+ const field = harness?.query<HTMLInputElement>("#address-field-To");
211
+ if (!field) throw new Error("the compose recipient field is not mounted");
212
+ return field;
213
+ };
214
+
215
+ const addRecipient = async (email: string): Promise<void> => {
216
+ harness?.type(recipientField(), email);
217
+ harness?.dispatch(
218
+ recipientField(),
219
+ new (
220
+ harness.window as unknown as { KeyboardEvent: typeof KeyboardEvent }
221
+ ).KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
222
+ );
223
+ await harness?.flush();
224
+ };
225
+
226
+ describe("a forwarded message and the draft it cannot create yet", () => {
227
+ it("attempts no create while the forward has no recipient", async () => {
228
+ await mount();
229
+
230
+ assert.equal(
231
+ subjectField().value,
232
+ "Fwd: Lunch",
233
+ "the forward seeded its subject, so the form is not blank",
234
+ );
235
+
236
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
237
+
238
+ assert.equal(creates().length, 0, "no create was attempted");
239
+ assert.equal(fatalOverlay(), null, "the composer stayed on screen");
240
+ assert.equal(bannerAlerts().length, 0, "nothing was raised as a failure");
241
+ });
242
+
243
+ it("says the draft is not being saved, and what would make it save", async () => {
244
+ await mount();
245
+
246
+ harness?.type(subjectField(), "Fwd: Lunch and the three paragraphs after");
247
+ await harness?.flush();
248
+
249
+ const indicator = harness?.byText("output", "Not saved");
250
+ assert.match(
251
+ indicator?.textContent ?? "",
252
+ /add a To address/i,
253
+ "the composer names the field the create schema actually requires",
254
+ );
255
+
256
+ await addRecipient("them@example.com");
257
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
258
+
259
+ assert.equal(creates().length, 1, "the held content was written");
260
+ assert.equal(
261
+ creates()[0]?.body?.subject,
262
+ "Fwd: Lunch and the three paragraphs after",
263
+ "including everything typed while it was held",
264
+ );
265
+ assert.match(harness?.text() ?? "", /Draft saved/);
266
+ });
267
+
268
+ it("creates the draft, with everything the forward seeded, once a recipient is there", async () => {
269
+ await mount();
270
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
271
+
272
+ await addRecipient("them@example.com");
273
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
274
+
275
+ const created = creates();
276
+ assert.equal(created.length, 1, "the draft was created once");
277
+ assert.deepEqual(created[0]?.body?.toAddresses, ["them@example.com"]);
278
+ assert.equal(created[0]?.body?.subject, "Fwd: Lunch");
279
+ assert.equal(fatalOverlay(), null, "the composer stayed on screen");
280
+ });
281
+
282
+ it("refuses to send a forward that has no recipient, and says why", async () => {
283
+ await mount();
284
+
285
+ const send = harness?.byText("button", "Send");
286
+ if (!send) throw new Error("the send button is not mounted");
287
+ harness?.click(send);
288
+ await harness?.flush();
289
+ await harness?.wait(100);
290
+
291
+ assert.match(harness?.text() ?? "", /Add a To address before sending/);
292
+ assert.equal(creates().length, 0, "nothing was created");
293
+ assert.equal(sends().length, 0, "nothing was sent");
294
+ });
295
+
296
+ it("drops the held sentence the moment the To address arrives", async () => {
297
+ await mount();
298
+
299
+ harness?.type(subjectField(), "Fwd: Lunch and the three paragraphs after");
300
+ await harness?.flush();
301
+ assert.match(harness?.text() ?? "", /Not saved/);
302
+
303
+ await addRecipient("them@example.com");
304
+
305
+ // Before the debounce, not after it: the sentence stopped being true the
306
+ // moment the address landed, and standing for another two seconds tells
307
+ // the user their draft is being dropped when it is on its way.
308
+ assert.doesNotMatch(
309
+ harness?.text() ?? "",
310
+ /Not saved/,
311
+ "a reason that no longer holds is off screen at once",
312
+ );
313
+ });
314
+ });
315
+
316
+ describe("a failed autosave and the message it is holding", () => {
317
+ it("banners a refused write and leaves the composer and its text alone", async () => {
318
+ await mount({ outboxMessageId: OUTBOX_MESSAGE_ID, patchStatus: 409 });
319
+
320
+ harness?.type(subjectField(), "Fwd: Lunch on Thursday");
321
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
322
+
323
+ assert.ok(patches().length > 0, "the write was attempted");
324
+ assert.equal(
325
+ fatalOverlay(),
326
+ null,
327
+ "a refused autosave must not take the composer down",
328
+ );
329
+ assert.match(harness?.text() ?? "", /Couldn't save draft/);
330
+ assert.equal(
331
+ subjectField().value,
332
+ "Fwd: Lunch on Thursday",
333
+ "what was written is still on screen",
334
+ );
335
+ });
336
+
337
+ it("escalates a 401 — a dismissible banner is no way back in", async () => {
338
+ await mount({ outboxMessageId: OUTBOX_MESSAGE_ID, patchStatus: 401 });
339
+
340
+ harness?.type(subjectField(), "Fwd: Lunch on Thursday");
341
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
342
+
343
+ assert.ok(
344
+ fatalOverlay(),
345
+ "a signed-out session must reach the page that signs back in",
346
+ );
347
+ });
348
+
349
+ it("still escalates a 5xx from the same write", async () => {
350
+ await mount({ outboxMessageId: OUTBOX_MESSAGE_ID, patchStatus: 500 });
351
+
352
+ harness?.type(subjectField(), "Fwd: Lunch on Thursday");
353
+ await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 300);
354
+
355
+ assert.ok(fatalOverlay(), "our API answering 'I'm broken' is never soft");
356
+ });
357
+ });
@@ -31,6 +31,7 @@ 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
+ import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
34
35
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
35
36
  import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
36
37
  import { useThreadRow } from "@/hooks/useThreadRow";
@@ -408,7 +409,7 @@ function BriefPhone() {
408
409
  previousThread,
409
410
  handleDeselectIfRemoved,
410
411
  } = useBriefPane();
411
- const { intelligenceOpen, onToggleIntelligence } = useMailContext();
412
+ const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
412
413
 
413
414
  if (conversation) {
414
415
  return (
@@ -420,21 +421,21 @@ function BriefPhone() {
420
421
  selectedMessageId={conversation.messageId}
421
422
  authenticity={conversation.authenticity}
422
423
  onBack={onCloseThread}
423
- onOpenIntelligence={onToggleIntelligence}
424
+ onOpenIntelligence={drawer.toggle}
424
425
  onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
425
426
  onSwipePrevious={
426
427
  previousThread ? () => onOpenThread(previousThread) : undefined
427
428
  }
428
- mobileIntelligenceOpen={intelligenceOpen}
429
+ mobileIntelligenceOpen={drawer.isOpen}
429
430
  />
430
431
  <Drawer
431
- isOpen={intelligenceOpen}
432
- onClose={onToggleIntelligence}
432
+ isOpen={drawer.isOpen}
433
+ onClose={drawer.close}
433
434
  ariaLabel="Message details"
434
435
  side="right"
435
436
  >
436
437
  <IntelligencePane
437
- onClose={onToggleIntelligence}
438
+ onClose={drawer.close}
438
439
  thread={selectedThread}
439
440
  mailboxId={selectedThread?.mailboxId}
440
441
  accountId={selectedThread?.accountId}
@@ -41,6 +41,7 @@ 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
+ import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
44
45
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
45
46
  import { useStarredThreads } from "@/hooks/useStarredThreads";
46
47
  import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
@@ -409,7 +410,7 @@ function FlaggedPhone() {
409
410
  previousThread,
410
411
  handleDeselectIfRemoved,
411
412
  } = useFlaggedPane();
412
- const { intelligenceOpen, onToggleIntelligence } = useMailContext();
413
+ const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
413
414
 
414
415
  if (conversation) {
415
416
  return (
@@ -421,21 +422,21 @@ function FlaggedPhone() {
421
422
  selectedMessageId={conversation.messageId}
422
423
  authenticity={conversation.authenticity}
423
424
  onBack={onCloseThread}
424
- onOpenIntelligence={onToggleIntelligence}
425
+ onOpenIntelligence={drawer.toggle}
425
426
  onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
426
427
  onSwipePrevious={
427
428
  previousThread ? () => onOpenThread(previousThread) : undefined
428
429
  }
429
- mobileIntelligenceOpen={intelligenceOpen}
430
+ mobileIntelligenceOpen={drawer.isOpen}
430
431
  />
431
432
  <Drawer
432
- isOpen={intelligenceOpen}
433
- onClose={onToggleIntelligence}
433
+ isOpen={drawer.isOpen}
434
+ onClose={drawer.close}
434
435
  ariaLabel="Message details"
435
436
  side="right"
436
437
  >
437
438
  <IntelligencePane
438
- onClose={onToggleIntelligence}
439
+ onClose={drawer.close}
439
440
  thread={selectedThread}
440
441
  mailboxId={selectedThread?.mailboxId}
441
442
  accountId={selectedThread?.accountId}
@@ -72,6 +72,7 @@ import {
72
72
  } from "@/hooks/useDeleteMessages";
73
73
  import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
74
74
  import { useIntelligenceData } from "@/hooks/useIntelligenceData";
75
+ import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
75
76
  import { useLayoutTier } from "@/hooks/useLayoutTier";
76
77
  import { useMailboxAccount } from "@/hooks/useMailboxAccount";
77
78
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
@@ -507,23 +508,6 @@ function MailboxPaneProvider({
507
508
  const focusedThread =
508
509
  threads.find((t) => t.messageId === triageFocusedId) ?? selectedThread;
509
510
 
510
- // Auto-open intelligence pane on DKIM mismatch.
511
- const autoOpenedForRef = useRef<string | null>(null);
512
- useEffect(() => {
513
- const id = selectedThread?.messageId ?? null;
514
- if (!id) return;
515
- if (autoOpenedForRef.current === id) return;
516
- if (selectedThread?.authenticity?.dkimMismatch) {
517
- autoOpenedForRef.current = id;
518
- if (!intelligenceOpen) onToggleIntelligence();
519
- }
520
- }, [
521
- selectedThread?.messageId,
522
- selectedThread?.authenticity?.dkimMismatch,
523
- intelligenceOpen,
524
- onToggleIntelligence,
525
- ]);
526
-
527
511
  // The mailbox's own unseen total. A count over the loaded pages undercounts
528
512
  // every mailbox larger than one page and creeps upward as the user scrolls,
529
513
  // so there is no fallback: until the mailbox resolves there is no number.
@@ -1085,41 +1069,41 @@ function MailboxReading() {
1085
1069
  const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
1086
1070
  const hasThread = Boolean(conversation);
1087
1071
 
1088
- // The drawer is modal, so it opens only when it is asked for — and only for
1089
- // the thread it was asked for. `intelligenceOpen` is the rail's persisted
1090
- // preference and the DKIM auto-open sets it on every tier, so driving the
1091
- // drawer from it would throw a scrim over a message the moment one was
1092
- // selected. Naming the thread is also what closes it again when the reader
1093
- // moves on: a bare flag would still be set when they came back.
1094
- const [drawerThreadId, setDrawerThreadId] = useState<string | null>(null);
1095
- const openThreadId = conversation?.threadId ?? null;
1096
- // Derived rather than stored: the drawer is up only while the thread it was
1097
- // opened for is still the one on screen, so moving to another one closes it
1098
- // with no effect to run. Closing it from an effect would paint one frame of
1099
- // an open drawer over the newly opened thread first.
1100
- const drawerOpen =
1101
- !railFits && openThreadId !== null && drawerThreadId === openThreadId;
1102
-
1103
- const closeIntelligenceDrawer = useCallback(
1104
- () => setDrawerThreadId(null),
1105
- [],
1106
- );
1107
- const openIntelligenceDrawer = useCallback(
1108
- () => setDrawerThreadId(openThreadId),
1109
- [openThreadId],
1110
- );
1111
- // The banner's "Why?" always an open, never a close.
1112
- const openIntelligence = railFits
1113
- ? onToggleIntelligence
1114
- : openIntelligenceDrawer;
1072
+ // The rail opens itself on a DKIM mismatch. It lives here, behind the rail's
1073
+ // own width gate, because raising it writes `#intelligence` into the address
1074
+ // and a panel the address names with no renderer behind it is a panel that
1075
+ // opens nothing (`docs/architecture/url-state.md`, R6). Below this width the
1076
+ // banner is the announcement and its "Why?" is the way in.
1077
+ const autoOpenedForRef = useRef<string | null>(null);
1078
+ useEffect(() => {
1079
+ if (!railFits) return;
1080
+ const id = selectedThread?.messageId ?? null;
1081
+ if (!id) return;
1082
+ if (autoOpenedForRef.current === id) return;
1083
+ if (!selectedThread?.authenticity?.dkimMismatch) return;
1084
+ autoOpenedForRef.current = id;
1085
+ if (!intelligenceOpen) onToggleIntelligence();
1086
+ }, [
1087
+ railFits,
1088
+ selectedThread?.messageId,
1089
+ selectedThread?.authenticity?.dkimMismatch,
1090
+ intelligenceOpen,
1091
+ onToggleIntelligence,
1092
+ ]);
1093
+
1094
+ const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
1095
+ const drawerOpen = !railFits && drawer.isOpen;
1096
+ const closeIntelligenceDrawer = drawer.close;
1097
+ const openIntelligence = railFits ? onToggleIntelligence : drawer.open;
1115
1098
  // The toolbar's control, which toggles whichever surface this width has.
1099
+ const { toggle: toggleDrawer } = drawer;
1116
1100
  const toggleIntelligence = useCallback(() => {
1117
1101
  if (railFits) {
1118
1102
  onToggleIntelligence();
1119
1103
  return;
1120
1104
  }
1121
- setDrawerThreadId(drawerOpen ? null : openThreadId);
1122
- }, [railFits, onToggleIntelligence, drawerOpen, openThreadId]);
1105
+ toggleDrawer();
1106
+ }, [railFits, onToggleIntelligence, toggleDrawer]);
1123
1107
  const intelligenceShowing =
1124
1108
  hasThread && (railFits ? intelligenceOpen : drawerOpen);
1125
1109
 
@@ -1219,13 +1203,12 @@ function MailboxPhone() {
1219
1203
  selectedThread,
1220
1204
  conversation,
1221
1205
  onOpenThread,
1222
- intelligenceOpen,
1223
- onToggleIntelligence,
1224
1206
  onBack,
1225
1207
  nextThread,
1226
1208
  previousThread,
1227
1209
  handleDeselectIfRemoved,
1228
1210
  } = useMailboxPane();
1211
+ const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
1229
1212
 
1230
1213
  if (conversation) {
1231
1214
  return (
@@ -1237,21 +1220,21 @@ function MailboxPhone() {
1237
1220
  selectedMessageId={conversation.messageId}
1238
1221
  authenticity={conversation.authenticity}
1239
1222
  onBack={onBack}
1240
- onOpenIntelligence={onToggleIntelligence}
1223
+ onOpenIntelligence={drawer.toggle}
1241
1224
  onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
1242
1225
  onSwipePrevious={
1243
1226
  previousThread ? () => onOpenThread(previousThread) : undefined
1244
1227
  }
1245
- mobileIntelligenceOpen={intelligenceOpen}
1228
+ mobileIntelligenceOpen={drawer.isOpen}
1246
1229
  />
1247
1230
  <Drawer
1248
- isOpen={intelligenceOpen}
1249
- onClose={onToggleIntelligence}
1231
+ isOpen={drawer.isOpen}
1232
+ onClose={drawer.close}
1250
1233
  ariaLabel="Message details"
1251
1234
  side="right"
1252
1235
  >
1253
1236
  <IntelligencePane
1254
- onClose={onToggleIntelligence}
1237
+ onClose={drawer.close}
1255
1238
  thread={selectedThread}
1256
1239
  mailboxId={mailboxId}
1257
1240
  accountId={mailboxAccountId}