@remit/web-client 0.0.272 → 0.0.274

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/src/components/mail/BlockedImagesNotice.render.test.ts +54 -5
  3. package/src/components/mail/BlockedImagesNotice.tsx +40 -18
  4. package/src/components/mail/BulkRunProvider.tsx +14 -1
  5. package/src/components/mail/MessageBody.blocked-sender.test.ts +193 -0
  6. package/src/components/mail/MessageBody.tsx +21 -2
  7. package/src/components/mail/MessageCard.blocked-sender.test.ts +246 -0
  8. package/src/components/mail/MessageCard.tsx +8 -1
  9. package/src/components/mail/MessageList.run-reporting.test.ts +1 -0
  10. package/src/components/mail/MessageListItem.test.ts +59 -3
  11. package/src/components/mail/MessageSettlementAlert.render.test.ts +182 -0
  12. package/src/components/mail/MessageSettlementAlert.tsx +77 -19
  13. package/src/components/mail/blocked-images-notice.stories.tsx +90 -0
  14. package/src/components/mail/bulk-run-state.test.ts +1 -0
  15. package/src/components/mail/conversation-reply-identity.render.test.ts +1 -0
  16. package/src/components/mail/conversation-reply-reach.render.test.ts +1 -0
  17. package/src/components/mail/selection-wizard-run-controls.test.ts +1 -0
  18. package/src/components/mail/two-window-listeners-one-key.render.test.ts +1 -0
  19. package/src/components/ui/placement-refusal.test.ts +7 -20
  20. package/src/components/ui/placement-refusal.ts +10 -12
  21. package/src/hooks/useDeleteMessages.ts +19 -0
  22. package/src/hooks/useMailboxAccount.ts +5 -0
  23. package/src/hooks/useToggleStar.test.ts +1 -0
  24. package/src/hooks/useToggleTrusted.test.ts +1 -0
  25. package/src/lib/brief.test.ts +1 -0
  26. package/src/lib/bulk-actions.test.ts +29 -1
  27. package/src/lib/bulk-actions.ts +59 -16
  28. package/src/lib/dedupe-thread-messages.test.ts +1 -0
  29. package/src/lib/row-settlement.ts +21 -8
  30. package/src/lib/thread-search-tokens.test.ts +1 -0
  31. package/src/test-support/fixtures.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.272",
3
+ "version": "0.0.274",
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": {
@@ -1,10 +1,17 @@
1
1
  /**
2
2
  * React-render smoke test for `BlockedImagesNotice` — the privacy "images
3
- * blocked" bar extracted from `MessageBody`. Pins the never-disable-buttons
4
- * rule (#943): the "Always trust" button stays active while the trust toggle
5
- * is in flight. It must NOT render `disabled`; the in-flight state is reflected
6
- * via `aria-busy` and a pending label instead, so the control stays focusable
7
- * and clickable (the handler no-ops the second click on the consumer side).
3
+ * blocked" bar extracted from `MessageBody`. Pins two rules.
4
+ *
5
+ * The never-disable-buttons rule (#943): the "Always trust" button stays active
6
+ * while the trust toggle is in flight. It must NOT render `disabled`; the
7
+ * in-flight state is reflected via `aria-busy` and a pending label instead, so
8
+ * the control stays focusable and clickable (the handler no-ops the second
9
+ * click on the consumer side).
10
+ *
11
+ * The blocked-sender rule (#352): `BlockedFlag` says images never load "even on
12
+ * explicit click", so for a blocked sender the load affordances are absent
13
+ * rather than disabled, and the bar states the reason so an image-free message
14
+ * is never silent about why.
8
15
  *
9
16
  * Uses `react-dom/server`'s `renderToString` so the assertion runs without
10
17
  * jsdom/happy-dom (no new deps), matching `MessageBodyErrorBanner.render.test`.
@@ -26,6 +33,7 @@ const render = (
26
33
  blockedImageCount: 3,
27
34
  canAlwaysTrust: true,
28
35
  isTrustPending: false,
36
+ isSenderBlocked: false,
29
37
  onLoadOnce: noop,
30
38
  onAlwaysTrust: noop,
31
39
  ...overrides,
@@ -63,3 +71,44 @@ describe("BlockedImagesNotice — never-disable the Always trust button (#943)",
63
71
  assert.match(html, /<button\b[^>]*>Load once<\/button>/);
64
72
  });
65
73
  });
74
+
75
+ describe("BlockedImagesNotice — a blocked sender gets no way in (#352)", () => {
76
+ it("renders no load affordance at all for a blocked sender", () => {
77
+ const html = render({ isSenderBlocked: true });
78
+ assert.doesNotMatch(
79
+ html,
80
+ /<button/,
81
+ "blocked means never load, even on explicit click — the buttons are absent",
82
+ );
83
+ });
84
+
85
+ it("states that the sender is blocked, so an image-free message is not silent", () => {
86
+ const html = render({ isSenderBlocked: true });
87
+ assert.match(html, /you blocked this sender/);
88
+ assert.match(html, /3 images hidden/);
89
+ });
90
+
91
+ it("offers nothing to disable — absence, not a dead control", () => {
92
+ const html = render({ isSenderBlocked: true });
93
+ assert.doesNotMatch(
94
+ html,
95
+ /<(?:button|a|input|select|textarea)\b|\brole="button"|\btabindex=/,
96
+ "a greyed-out control still reads as 'try again'; the bar carries no interactive element at all",
97
+ );
98
+ assert.doesNotMatch(html, /Load once/);
99
+ assert.doesNotMatch(html, /Always trust/);
100
+ });
101
+
102
+ it("stays blocked when the sender is somehow trusted as well", () => {
103
+ const html = render({ isSenderBlocked: true, canAlwaysTrust: true });
104
+ assert.doesNotMatch(html, /<button/);
105
+ assert.match(html, /you blocked this sender/);
106
+ });
107
+
108
+ it("singularizes the count", () => {
109
+ assert.match(
110
+ render({ isSenderBlocked: true, blockedImageCount: 1 }),
111
+ /1 image hidden/,
112
+ );
113
+ });
114
+ });
@@ -2,40 +2,62 @@ interface BlockedImagesNoticeProps {
2
2
  blockedImageCount: number;
3
3
  canAlwaysTrust: boolean;
4
4
  isTrustPending: boolean;
5
+ /**
6
+ * The From-address carries the `blocked` flag. Blocking is a stronger
7
+ * instruction than "not trusted": images never load, "even on explicit
8
+ * click", so the bar states why the message is image-free instead of
9
+ * offering a way past it. The affordances are absent, never disabled — a
10
+ * dead button reads as broken (#943).
11
+ */
12
+ isSenderBlocked: boolean;
5
13
  onLoadOnce: () => void;
6
14
  onAlwaysTrust: () => void;
7
15
  }
8
16
 
17
+ const noticeCopy = (
18
+ blockedImageCount: number,
19
+ isSenderBlocked: boolean,
20
+ ): string => {
21
+ const images = `${blockedImageCount} image${blockedImageCount > 1 ? "s" : ""}`;
22
+ if (isSenderBlocked) return `${images} hidden — you blocked this sender`;
23
+ return `${images} blocked for privacy`;
24
+ };
25
+
9
26
  export const BlockedImagesNotice = ({
10
27
  blockedImageCount,
11
28
  canAlwaysTrust,
12
29
  isTrustPending,
30
+ isSenderBlocked,
13
31
  onLoadOnce,
14
32
  onAlwaysTrust,
15
33
  }: BlockedImagesNoticeProps) => (
16
- <div className="mb-3 flex items-center justify-between rounded-md bg-surface-sunken/50 px-3 py-2 text-sm">
34
+ <div
35
+ data-testid="blocked-images-notice"
36
+ className="mb-3 flex items-center justify-between rounded-md bg-surface-sunken/50 px-3 py-2 text-sm"
37
+ >
17
38
  <span className="text-fg-muted">
18
- {blockedImageCount} image{blockedImageCount > 1 ? "s" : ""} blocked for
19
- privacy
39
+ {noticeCopy(blockedImageCount, isSenderBlocked)}
20
40
  </span>
21
- <div className="flex items-center gap-3">
22
- <button
23
- type="button"
24
- onClick={onLoadOnce}
25
- className="text-accent hover:underline"
26
- >
27
- Load once
28
- </button>
29
- {canAlwaysTrust && (
41
+ {!isSenderBlocked && (
42
+ <div className="flex items-center gap-3">
30
43
  <button
31
44
  type="button"
32
- onClick={onAlwaysTrust}
33
- aria-busy={isTrustPending}
34
- className="text-accent hover:underline aria-busy:opacity-50"
45
+ onClick={onLoadOnce}
46
+ className="text-accent hover:underline"
35
47
  >
36
- {isTrustPending ? "Trusting…" : "Always trust"}
48
+ Load once
37
49
  </button>
38
- )}
39
- </div>
50
+ {canAlwaysTrust && (
51
+ <button
52
+ type="button"
53
+ onClick={onAlwaysTrust}
54
+ aria-busy={isTrustPending}
55
+ className="text-accent hover:underline aria-busy:opacity-50"
56
+ >
57
+ {isTrustPending ? "Trusting…" : "Always trust"}
58
+ </button>
59
+ )}
60
+ </div>
61
+ )}
40
62
  </div>
41
63
  );
@@ -149,7 +149,7 @@ export const BulkRunProvider = ({ children }: { children: ReactNode }) => {
149
149
  // itself that does not make `start` its own dependency.
150
150
  const startRef = useRef<BulkRunContextValue["start"]>(async () => ({
151
151
  kind: "ran",
152
- outcome: { done: 0, failedIds: [], cancelled: false },
152
+ outcome: { done: 0, failedIds: [], refused: 0, cancelled: false },
153
153
  }));
154
154
 
155
155
  /**
@@ -282,6 +282,19 @@ export const BulkRunProvider = ({ children }: { children: ReactNode }) => {
282
282
  outcome.error,
283
283
  ),
284
284
  );
285
+ } else if (outcome.refused > 0) {
286
+ // One banner for the run, not one per chunk: a select-all is walked a
287
+ // hundred ids at a time, and a refusal in each would stack banners
288
+ // until the surface caps them. The count is the run's own total.
289
+ pushError({
290
+ severity: "warning",
291
+ title:
292
+ outcome.refused > 1
293
+ ? `Couldn't delete ${outcome.refused} of ${request.matched} messages yet`
294
+ : "Couldn't delete this message yet",
295
+ detail:
296
+ "Something else moved them while the delete was being prepared. They are back where they now sit — try again.",
297
+ });
285
298
  }
286
299
  // Nobody is left showing this run, so the ending is said where the user
287
300
  // now is. A screen still reporting on it says it in place instead.
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Image gating at the `MessageBody` seam (#352). `BlockedFlag` declares that a
3
+ * blocked sender's images never load "even on explicit click", and Settings >
4
+ * Senders promises the same. Blocked therefore suppresses auto-load AND removes
5
+ * the manual load affordances, and outranks `trusted` when both are set.
6
+ *
7
+ * Renders through the real pipeline — sanitizer, blocked-image count, notice —
8
+ * against the jsdom harness, so the assertions read the DOM the user gets.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { afterEach, describe, it } from "node:test";
13
+ import { createElement } from "react";
14
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
15
+ import { MessageBody } from "./MessageBody";
16
+
17
+ let harness: DomHarness | undefined;
18
+
19
+ // The sandboxed frame measures itself once the iframe loads. jsdom fires that
20
+ // load but has no ResizeObserver, and the resulting ReferenceError is raised
21
+ // inside a listener where no assertion can see it.
22
+ (globalThis as { ResizeObserver?: unknown }).ResizeObserver = class {
23
+ observe() {}
24
+ unobserve() {}
25
+ disconnect() {}
26
+ };
27
+
28
+ afterEach(() => {
29
+ harness?.close();
30
+ harness = undefined;
31
+ });
32
+
33
+ const MARKETING_HTML = `
34
+ <div>
35
+ <img src="https://tracker.example/hero.png" alt="Hero" />
36
+ <p>Up to 40% off everything this weekend.</p>
37
+ <img src="https://tracker.example/pixel.gif" alt="" />
38
+ </div>
39
+ `;
40
+
41
+ // Every remote-image vector an email can reach for, not just `img src`.
42
+ const MULTI_VECTOR_HTML = `
43
+ <div>
44
+ <img src="https://tracker.example/hero.png" srcset="https://tracker.example/hero@2x.png 2x" alt="Hero" />
45
+ <picture>
46
+ <source srcset="https://tracker.example/wide.png" type="image/png" />
47
+ <img src="https://tracker.example/narrow.png" alt="Deal" />
48
+ </picture>
49
+ <video poster="https://tracker.example/poster.jpg"><source src="https://tracker.example/clip.mp4" type="video/mp4" /></video>
50
+ </div>
51
+ `;
52
+
53
+ const mount = (
54
+ flags: { isTrusted?: boolean; isBlocked?: boolean },
55
+ overrides: { html?: string; messageId?: string } = {},
56
+ ) => {
57
+ harness = createDomHarness();
58
+ harness.renderApp(
59
+ createElement(MessageBody, {
60
+ html: MARKETING_HTML,
61
+ messageId: "msg-1",
62
+ fromAddressId: "addr-1",
63
+ category: "marketing",
64
+ ...flags,
65
+ ...overrides,
66
+ }),
67
+ );
68
+ return harness;
69
+ };
70
+
71
+ const rerender = (
72
+ dom: DomHarness,
73
+ flags: { isTrusted?: boolean; isBlocked?: boolean },
74
+ overrides: { html?: string; messageId?: string } = {},
75
+ ) => {
76
+ dom.renderApp(
77
+ createElement(MessageBody, {
78
+ html: MARKETING_HTML,
79
+ messageId: "msg-1",
80
+ fromAddressId: "addr-1",
81
+ category: "marketing",
82
+ ...flags,
83
+ ...overrides,
84
+ }),
85
+ );
86
+ };
87
+
88
+ const frameHtml = (dom: DomHarness): string =>
89
+ dom.query<HTMLIFrameElement>("iframe")?.getAttribute("srcdoc") ?? "";
90
+
91
+ // A blocked image keeps its original URL in `data-blocked-src` — only a live
92
+ // `src=` means the browser will fetch it.
93
+ const REMOTE_SRC = /\ssrc="https:\/\/tracker\.example\/hero\.png"/;
94
+
95
+ describe("MessageBody image gating — blocked senders (#352)", () => {
96
+ it("never auto-loads images for a blocked sender", () => {
97
+ const dom = mount({ isBlocked: true });
98
+ assert.match(
99
+ frameHtml(dom),
100
+ /data-blocked-src/,
101
+ "a blocked sender's remote images must stay placeholders",
102
+ );
103
+ assert.doesNotMatch(frameHtml(dom), REMOTE_SRC);
104
+ });
105
+
106
+ it("offers no manual load affordance for a blocked sender", () => {
107
+ const dom = mount({ isBlocked: true });
108
+ assert.equal(
109
+ dom.queryAll("button").length,
110
+ 0,
111
+ "never load, even on explicit click — the controls are absent, not disabled",
112
+ );
113
+ assert.doesNotMatch(dom.html(), /\bdisabled\b/);
114
+ });
115
+
116
+ it("says why the message has no images instead of going quiet", () => {
117
+ const dom = mount({ isBlocked: true });
118
+ assert.match(dom.text(), /2 images hidden/);
119
+ assert.match(dom.text(), /you blocked this sender/);
120
+ });
121
+
122
+ it("keeps images blocked when the sender is both blocked and trusted", () => {
123
+ const dom = mount({ isBlocked: true, isTrusted: true });
124
+ assert.match(frameHtml(dom), /data-blocked-src/);
125
+ assert.equal(dom.queryAll("button").length, 0);
126
+ assert.match(dom.text(), /you blocked this sender/);
127
+ });
128
+
129
+ it("emits no fetchable tracker URL through srcset, picture or poster either", () => {
130
+ const dom = mount({ isBlocked: true }, { html: MULTI_VECTOR_HTML });
131
+ // The placeholder swap parks the original `img src` in
132
+ // `data-blocked-src` for "load once"; anywhere else in the srcdoc, a
133
+ // tracker URL is a request the frame will make.
134
+ const live = frameHtml(dom).replace(/data-blocked-src="[^"]*"/g, "");
135
+ assert.doesNotMatch(
136
+ live,
137
+ /tracker\.example/,
138
+ "blocked means no remote image request, whichever attribute names it",
139
+ );
140
+ assert.match(
141
+ frameHtml(dom),
142
+ /data-blocked-src="https:\/\/tracker\.example/,
143
+ );
144
+ });
145
+ });
146
+
147
+ describe("MessageBody load-once is scoped to one message (#352)", () => {
148
+ it("drops the grant when the same instance is handed a different message", () => {
149
+ const dom = mount({});
150
+ dom.click(dom.byText("button", "Load once"));
151
+ assert.match(frameHtml(dom), REMOTE_SRC);
152
+
153
+ rerender(dom, {}, { messageId: "msg-2" });
154
+
155
+ assert.doesNotMatch(
156
+ frameHtml(dom),
157
+ REMOTE_SRC,
158
+ "a load-once decision belongs to the message it was taken on",
159
+ );
160
+ assert.match(frameHtml(dom), /data-blocked-src/);
161
+ assert.ok(dom.byText("button", "Load once"));
162
+ });
163
+
164
+ it("keeps the grant while the message stays the same", () => {
165
+ const dom = mount({});
166
+ dom.click(dom.byText("button", "Load once"));
167
+ rerender(dom, { isTrusted: false });
168
+ assert.match(frameHtml(dom), REMOTE_SRC);
169
+ });
170
+ });
171
+
172
+ describe("MessageBody image gating — the states blocking must not change", () => {
173
+ it("auto-loads a trusted sender's images and shows no notice", () => {
174
+ const dom = mount({ isTrusted: true });
175
+ assert.match(frameHtml(dom), REMOTE_SRC);
176
+ assert.doesNotMatch(frameHtml(dom), /data-blocked-src/);
177
+ assert.doesNotMatch(dom.text(), /hidden|blocked for privacy/);
178
+ });
179
+
180
+ it("still offers Load once and Always trust to a merely untrusted sender", () => {
181
+ const dom = mount({});
182
+ assert.match(frameHtml(dom), /data-blocked-src/);
183
+ assert.match(dom.text(), /2 images blocked for privacy/);
184
+ assert.ok(dom.byText("button", "Load once"));
185
+ assert.ok(dom.byText("button", "Always trust"));
186
+ });
187
+
188
+ it("loads the images once the untrusted sender's Load once is taken", () => {
189
+ const dom = mount({});
190
+ dom.click(dom.byText("button", "Load once"));
191
+ assert.match(frameHtml(dom), REMOTE_SRC);
192
+ });
193
+ });
@@ -47,9 +47,17 @@ interface MessageBodyProps {
47
47
  /**
48
48
  * Whether the From-address has the `trusted` flag set. Trusted senders
49
49
  * auto-load images. The "Load images" bar is suppressed entirely for
50
- * trusted senders.
50
+ * trusted senders. Ignored when `isBlocked` is set.
51
51
  */
52
52
  isTrusted?: boolean;
53
+ /**
54
+ * Whether the From-address has the `blocked` flag set. A blocked sender
55
+ * never loads images, "even on explicit click" (`BlockedFlag`), so this
56
+ * suppresses auto-load and removes the manual load affordances rather than
57
+ * greying them out. Blocked outranks trusted: it is the more restrictive
58
+ * instruction, and a sender carrying both is still blocked.
59
+ */
60
+ isBlocked?: boolean;
53
61
  /**
54
62
  * The current message id. Required for the body-content query cache key
55
63
  * and by `useToggleTrusted` for the optimistic cache patch.
@@ -99,13 +107,23 @@ export const MessageBody = ({
99
107
  html,
100
108
  text,
101
109
  isTrusted = false,
110
+ isBlocked = false,
102
111
  messageId,
103
112
  fromAddressId,
104
113
  category,
105
114
  className,
106
115
  }: MessageBodyProps) => {
116
+ // "Load once" is a decision about one message. Reset it when the instance is
117
+ // handed a different message so the grant can't ride along — today every
118
+ // caller keys this component by message, which makes the leak unreachable,
119
+ // but the guarantee should hold structurally rather than by a caller's key.
107
120
  const [allowImagesOnce, setAllowImagesOnce] = useState(false);
108
- const allowImages = isTrusted || allowImagesOnce;
121
+ const [grantedFor, setGrantedFor] = useState(messageId);
122
+ if (grantedFor !== messageId) {
123
+ setGrantedFor(messageId);
124
+ setAllowImagesOnce(false);
125
+ }
126
+ const allowImages = !isBlocked && (isTrusted || allowImagesOnce);
109
127
  const isDark = useIsDark();
110
128
  // `useToggleTrusted` surfaces its own failure (banner + rollback) and a fatal
111
129
  // 5xx escalates globally — no consumer-side error effect needed here.
@@ -197,6 +215,7 @@ export const MessageBody = ({
197
215
  blockedImageCount={blockedImageCount}
198
216
  canAlwaysTrust={canAlwaysTrust}
199
217
  isTrustPending={isTrustPending}
218
+ isSenderBlocked={isBlocked}
200
219
  onLoadOnce={() => setAllowImagesOnce(true)}
201
220
  onAlwaysTrust={handleAlwaysTrust}
202
221
  />
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Image gating at the `MessageCard` seam (#352). The expanded card resolves the
3
+ * From-address flags and hands them to the body: `blocked` is read there
4
+ * alongside `trusted`, and wins when both are set. Without that read the promise
5
+ * Settings > Senders makes — a blocked sender never loads images — has no reach
6
+ * into the one surface that renders mail.
7
+ */
8
+
9
+ import assert from "node:assert/strict";
10
+ import { afterEach, describe, it } from "node:test";
11
+ import type {
12
+ RemitImapAddressFlags,
13
+ RemitImapDescribeMessageResponse,
14
+ } from "@remit/api-http-client/types.gen.ts";
15
+ import {
16
+ type AnyRouter,
17
+ createMemoryHistory,
18
+ createRootRoute,
19
+ createRoute,
20
+ createRouter,
21
+ Outlet,
22
+ RouterProvider,
23
+ } from "@tanstack/react-router";
24
+ import { createElement } from "react";
25
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
26
+ import { makeThreadMessage } from "@/test-support/fixtures";
27
+ import { type HttpMock, mockFetch } from "@/test-support/http";
28
+ import { MessageCard } from "./MessageCard";
29
+
30
+ const MAILBOX_ID = "mbx-inbox";
31
+ const THREAD_ID = "thread-1";
32
+ const MESSAGE_ID = "msg-1";
33
+ const CONTENT_PATH = "/content/parts/1";
34
+
35
+ const MARKETING_HTML = `<div><img src="https://tracker.example/hero.png" alt="Hero"><p>Sale ends Sunday.</p></div>`;
36
+
37
+ let harness: DomHarness | undefined;
38
+ let http: HttpMock | undefined;
39
+
40
+ // The router reads `self` at construction; the shared jsdom globals stop at
41
+ // `window`.
42
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
43
+
44
+ // The sandboxed frame measures itself once the iframe loads. jsdom fires that
45
+ // load but has no ResizeObserver, and the resulting ReferenceError is raised
46
+ // inside a listener where no assertion can see it.
47
+ (globalThis as { ResizeObserver?: unknown }).ResizeObserver = class {
48
+ observe() {}
49
+ unobserve() {}
50
+ disconnect() {}
51
+ };
52
+
53
+ afterEach(() => {
54
+ harness?.close();
55
+ harness = undefined;
56
+ http?.restore();
57
+ http = undefined;
58
+ });
59
+
60
+ const threadMessage = makeThreadMessage({
61
+ messageId: MESSAGE_ID,
62
+ threadId: THREAD_ID,
63
+ mailboxId: MAILBOX_ID,
64
+ subject: "Sale ends Sunday",
65
+ fromName: "Deals",
66
+ fromEmail: "deals@shop.example",
67
+ category: "marketing",
68
+ isRead: true,
69
+ });
70
+
71
+ const describeMessage = (
72
+ flags: RemitImapAddressFlags,
73
+ ): RemitImapDescribeMessageResponse =>
74
+ ({
75
+ message: {
76
+ messageId: MESSAGE_ID,
77
+ mailboxId: MAILBOX_ID,
78
+ uid: 1,
79
+ rfc822Size: 512,
80
+ internalDate: 1_767_225_600_000,
81
+ status: "active",
82
+ syncStatus: "pending",
83
+ },
84
+ envelope: {
85
+ messageId: MESSAGE_ID,
86
+ date: 1_767_225_600_000,
87
+ subject: "Sale ends Sunday",
88
+ messageIdValue: "<deals-1@shop.example>",
89
+ from: [
90
+ {
91
+ addressId: "addr-deals",
92
+ displayName: "Deals",
93
+ normalizedEmail: "deals@shop.example",
94
+ addressRole: "from",
95
+ addressOrder: 0,
96
+ flags,
97
+ },
98
+ ],
99
+ to: [],
100
+ cc: [],
101
+ bcc: [],
102
+ replyTo: [],
103
+ category: "marketing",
104
+ senderTrust: "unknown",
105
+ },
106
+ flags: ["\\Seen"],
107
+ bodyParts: [
108
+ {
109
+ bodyPartId: "part-1",
110
+ mediaType: "TEXT",
111
+ mediaSubtype: "HTML",
112
+ contentUrl: `http://localhost${CONTENT_PATH}`,
113
+ isMultipart: false,
114
+ },
115
+ ],
116
+ references: [],
117
+ }) as unknown as RemitImapDescribeMessageResponse;
118
+
119
+ const testRouter = (): AnyRouter => {
120
+ const rootRoute = createRootRoute({ component: () => createElement(Outlet) });
121
+ const mailboxRoute = createRoute({
122
+ getParentRoute: () => rootRoute,
123
+ path: "/mail/$mailboxId",
124
+ validateSearch: (search: Record<string, unknown>) => search,
125
+ });
126
+ const threadRoute = createRoute({
127
+ getParentRoute: () => mailboxRoute,
128
+ path: "$threadId",
129
+ });
130
+ const messageRoute = createRoute({
131
+ getParentRoute: () => threadRoute,
132
+ path: "$messageId",
133
+ component: () =>
134
+ createElement(MessageCard, {
135
+ threadMessage,
136
+ isExpanded: true,
137
+ onToggle: () => undefined,
138
+ onToggleStar: () => undefined,
139
+ }),
140
+ });
141
+ const routeTree = rootRoute.addChildren([
142
+ mailboxRoute.addChildren([threadRoute.addChildren([messageRoute])]),
143
+ ]);
144
+ return createRouter({
145
+ routeTree,
146
+ history: createMemoryHistory({
147
+ initialEntries: [`/mail/${MAILBOX_ID}/${THREAD_ID}/${MESSAGE_ID}`],
148
+ }),
149
+ }) as unknown as AnyRouter;
150
+ };
151
+
152
+ const mount = async (flags: RemitImapAddressFlags): Promise<DomHarness> => {
153
+ http = mockFetch((call) => {
154
+ if (call.path === CONTENT_PATH) {
155
+ return new Response(MARKETING_HTML, {
156
+ status: 200,
157
+ headers: { "content-type": "text/html" },
158
+ });
159
+ }
160
+ if (call.path.endsWith(`/messages/${MESSAGE_ID}`)) {
161
+ return describeMessage(flags);
162
+ }
163
+ // `/config` answers its own shape. `accounts` is required by the contract,
164
+ // so a mock that hands back `{items: []}` for it is a body the API cannot
165
+ // produce, and anything reading it faithfully throws on a fixture rather
166
+ // than on a fault.
167
+ if (call.path.endsWith("/config")) {
168
+ return { accountConfig: {}, accounts: [] };
169
+ }
170
+ return { items: [] };
171
+ });
172
+
173
+ const router = testRouter();
174
+ await router.load();
175
+ const dom = createDomHarness();
176
+ harness = dom;
177
+ dom.renderApp(createElement(RouterProvider, { router }));
178
+ await dom.waitFor(
179
+ () => dom.query("iframe") !== null,
180
+ "the message body to render",
181
+ );
182
+ return dom;
183
+ };
184
+
185
+ const frameHtml = (dom: DomHarness): string =>
186
+ dom.query<HTMLIFrameElement>("iframe")?.getAttribute("srcdoc") ?? "";
187
+
188
+ // A blocked image keeps its original URL in `data-blocked-src`; only a live
189
+ // `src=` means the browser will fetch it.
190
+ const REMOTE_SRC = /\ssrc="https:\/\/tracker\.example\/hero\.png"/;
191
+
192
+ const notice = (dom: DomHarness) =>
193
+ dom.query('[data-testid="blocked-images-notice"]');
194
+
195
+ // A boolean, not the node: an assertion that fails on a jsdom element makes the
196
+ // runner serialize the whole tree and the process dies on memory, not on a diff.
197
+ const hasTrustedBadge = (dom: DomHarness): boolean =>
198
+ dom.query('[data-testid="trusted-sender-badge"]') !== null;
199
+
200
+ describe("MessageCard reads flags.blocked on the render path (#352)", () => {
201
+ it("keeps a blocked sender's images out and offers no way to load them", async () => {
202
+ const dom = await mount({ blocked: { value: true, setAt: 1 } });
203
+
204
+ assert.doesNotMatch(frameHtml(dom), REMOTE_SRC);
205
+ assert.match(frameHtml(dom), /data-blocked-src/);
206
+ assert.match(notice(dom)?.textContent ?? "", /you blocked this sender/);
207
+ assert.equal(
208
+ dom.queryAll('[data-testid="blocked-images-notice"] button').length,
209
+ 0,
210
+ "never load, even on explicit click",
211
+ );
212
+ });
213
+
214
+ it("stays blocked when the sender carries both blocked and trusted", async () => {
215
+ const dom = await mount({
216
+ blocked: { value: true, setAt: 1 },
217
+ trusted: { value: true, setAt: 1 },
218
+ });
219
+
220
+ assert.doesNotMatch(frameHtml(dom), REMOTE_SRC);
221
+ assert.match(notice(dom)?.textContent ?? "", /you blocked this sender/);
222
+ assert.equal(
223
+ hasTrustedBadge(dom),
224
+ false,
225
+ "a trusted badge beside 'you blocked this sender' tells the user two opposite things",
226
+ );
227
+ });
228
+
229
+ it("still auto-loads a trusted sender's images", async () => {
230
+ const dom = await mount({ trusted: { value: true, setAt: 1 } });
231
+
232
+ assert.match(frameHtml(dom), REMOTE_SRC);
233
+ assert.equal(notice(dom), null);
234
+ assert.equal(hasTrustedBadge(dom), true);
235
+ });
236
+
237
+ it("still offers the load affordances to a merely untrusted sender", async () => {
238
+ const dom = await mount({});
239
+
240
+ assert.doesNotMatch(frameHtml(dom), REMOTE_SRC);
241
+ assert.match(notice(dom)?.textContent ?? "", /blocked for privacy/);
242
+ assert.ok(
243
+ dom.queryAll('[data-testid="blocked-images-notice"] button').length > 0,
244
+ );
245
+ });
246
+ });