@remit/web-client 0.0.179 → 0.0.181

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.
@@ -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
+ });