@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.
- package/package.json +1 -1
- package/src/components/mail/BlockedImagesNotice.render.test.ts +54 -5
- package/src/components/mail/BlockedImagesNotice.tsx +40 -18
- package/src/components/mail/BulkRunProvider.tsx +14 -1
- package/src/components/mail/MessageBody.blocked-sender.test.ts +193 -0
- package/src/components/mail/MessageBody.tsx +21 -2
- package/src/components/mail/MessageCard.blocked-sender.test.ts +246 -0
- package/src/components/mail/MessageCard.tsx +8 -1
- package/src/components/mail/MessageList.run-reporting.test.ts +1 -0
- package/src/components/mail/MessageListItem.test.ts +59 -3
- package/src/components/mail/MessageSettlementAlert.render.test.ts +182 -0
- package/src/components/mail/MessageSettlementAlert.tsx +77 -19
- package/src/components/mail/blocked-images-notice.stories.tsx +90 -0
- package/src/components/mail/bulk-run-state.test.ts +1 -0
- package/src/components/mail/conversation-reply-identity.render.test.ts +1 -0
- package/src/components/mail/conversation-reply-reach.render.test.ts +1 -0
- package/src/components/mail/selection-wizard-run-controls.test.ts +1 -0
- package/src/components/mail/two-window-listeners-one-key.render.test.ts +1 -0
- package/src/components/ui/placement-refusal.test.ts +7 -20
- package/src/components/ui/placement-refusal.ts +10 -12
- package/src/hooks/useDeleteMessages.ts +19 -0
- package/src/hooks/useMailboxAccount.ts +5 -0
- package/src/hooks/useToggleStar.test.ts +1 -0
- package/src/hooks/useToggleTrusted.test.ts +1 -0
- package/src/lib/brief.test.ts +1 -0
- package/src/lib/bulk-actions.test.ts +29 -1
- package/src/lib/bulk-actions.ts +59 -16
- package/src/lib/dedupe-thread-messages.test.ts +1 -0
- package/src/lib/row-settlement.ts +21 -8
- package/src/lib/thread-search-tokens.test.ts +1 -0
- 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.
|
|
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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
19
|
-
privacy
|
|
39
|
+
{noticeCopy(blockedImageCount, isSenderBlocked)}
|
|
20
40
|
</span>
|
|
21
|
-
|
|
22
|
-
<
|
|
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={
|
|
33
|
-
|
|
34
|
-
className="text-accent hover:underline aria-busy:opacity-50"
|
|
45
|
+
onClick={onLoadOnce}
|
|
46
|
+
className="text-accent hover:underline"
|
|
35
47
|
>
|
|
36
|
-
|
|
48
|
+
Load once
|
|
37
49
|
</button>
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
+
});
|