@remit/web-client 0.0.120 → 0.0.122
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/compose/ComposeForm.tsx +7 -6
- package/src/components/compose/compose-send-stops-autosave.render.test.ts +212 -0
- package/src/hooks/useIntelligenceData.test.ts +102 -0
- package/src/hooks/useIntelligenceData.ts +75 -4
- package/src/hooks/useSaveDraft.ts +11 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.122",
|
|
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": {
|
|
@@ -405,7 +405,7 @@ export const ComposeForm = ({
|
|
|
405
405
|
sourceMessage?.envelope.from[0]?.displayName ??
|
|
406
406
|
sourceMessage?.envelope.from[0]?.normalizedEmail;
|
|
407
407
|
|
|
408
|
-
const { saveStatus, saveError, saveDraft,
|
|
408
|
+
const { saveStatus, saveError, saveDraft, stopAutoSave } = useSaveDraft({
|
|
409
409
|
outboxMessageId,
|
|
410
410
|
onDraftCreated: setOutboxMessageId,
|
|
411
411
|
});
|
|
@@ -504,7 +504,7 @@ export const ComposeForm = ({
|
|
|
504
504
|
const handleSend = useCallback(async () => {
|
|
505
505
|
if (!selectedAccountId || toAddresses.length === 0) return;
|
|
506
506
|
|
|
507
|
-
|
|
507
|
+
stopAutoSave();
|
|
508
508
|
|
|
509
509
|
const replyData =
|
|
510
510
|
sourceMessage && (mode === "reply" || mode === "reply_all")
|
|
@@ -534,7 +534,7 @@ export const ComposeForm = ({
|
|
|
534
534
|
subject: subject || undefined,
|
|
535
535
|
textBody: textBody || undefined,
|
|
536
536
|
htmlBody: htmlBody || undefined,
|
|
537
|
-
sendImmediately:
|
|
537
|
+
sendImmediately: false,
|
|
538
538
|
...replyData,
|
|
539
539
|
},
|
|
540
540
|
})
|
|
@@ -570,6 +570,7 @@ export const ComposeForm = ({
|
|
|
570
570
|
});
|
|
571
571
|
if (sent === null) return;
|
|
572
572
|
|
|
573
|
+
stopAutoSave(messageId);
|
|
573
574
|
startSendPolling(messageId);
|
|
574
575
|
onClose();
|
|
575
576
|
}, [
|
|
@@ -584,7 +585,7 @@ export const ComposeForm = ({
|
|
|
584
585
|
outboxMessageId,
|
|
585
586
|
createMutation,
|
|
586
587
|
sendMutation,
|
|
587
|
-
|
|
588
|
+
stopAutoSave,
|
|
588
589
|
startSendPolling,
|
|
589
590
|
setOutboxMessageId,
|
|
590
591
|
pushError,
|
|
@@ -592,14 +593,14 @@ export const ComposeForm = ({
|
|
|
592
593
|
]);
|
|
593
594
|
|
|
594
595
|
const handleDiscard = useCallback(() => {
|
|
595
|
-
|
|
596
|
+
stopAutoSave(outboxMessageId);
|
|
596
597
|
if (outboxMessageId) {
|
|
597
598
|
deleteMutation.mutate({
|
|
598
599
|
path: { outboxMessageId },
|
|
599
600
|
});
|
|
600
601
|
}
|
|
601
602
|
onClose();
|
|
602
|
-
}, [
|
|
603
|
+
}, [stopAutoSave, outboxMessageId, deleteMutation, onClose]);
|
|
603
604
|
|
|
604
605
|
const handleAccountChange = useCallback(
|
|
605
606
|
(acct: RemitImapAccountResponse) => {
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #604: two seconds after a reply was sent, the draft editor fired one
|
|
3
|
+
* more autosave PATCH at the outbox entry the send had just moved out of draft,
|
|
4
|
+
* and the fatal-error overlay went up over a message that had gone out fine.
|
|
5
|
+
*
|
|
6
|
+
* `handleSend` already dropped the pending autosave timer, but the autosave
|
|
7
|
+
* effect reschedules whenever the save mutation settles — and the last PATCH of
|
|
8
|
+
* a typing burst settles after the send request has left. So the guarantee
|
|
9
|
+
* asserted here is about the entry, not the timer: once it has been sent,
|
|
10
|
+
* nothing writes to it again.
|
|
11
|
+
*
|
|
12
|
+
* The second case is the same rule met from the other side. Send pressed before
|
|
13
|
+
* the first autosave has run has no draft to send, so compose creates one and
|
|
14
|
+
* then sends it — and creating it as already-queued makes that send a second
|
|
15
|
+
* dispatch, which the server refuses.
|
|
16
|
+
*
|
|
17
|
+
* The stub holds the outbox rule the API holds: only a draft accepts a send.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import assert from "node:assert/strict";
|
|
21
|
+
import { afterEach, describe, it } from "node:test";
|
|
22
|
+
import type {
|
|
23
|
+
RemitImapAccountResponse,
|
|
24
|
+
RemitImapDescribeMessageResponse,
|
|
25
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
26
|
+
import { createElement, useEffect } from "react";
|
|
27
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
28
|
+
import { type HttpMock, httpError, mockFetch } from "../../test-support/http";
|
|
29
|
+
import { ComposeForm } from "./ComposeForm";
|
|
30
|
+
import { ComposeProvider, useCompose } from "./ComposeProvider";
|
|
31
|
+
|
|
32
|
+
const ACCOUNT_ID = "acc-1";
|
|
33
|
+
const OUTBOX_MESSAGE_ID = "ob-604";
|
|
34
|
+
const AUTOSAVE_DEBOUNCE_MS = 2000;
|
|
35
|
+
|
|
36
|
+
const account = {
|
|
37
|
+
accountId: ACCOUNT_ID,
|
|
38
|
+
email: "me@example.com",
|
|
39
|
+
smtpEnabled: true,
|
|
40
|
+
} as unknown as RemitImapAccountResponse;
|
|
41
|
+
|
|
42
|
+
const sourceMessage = {
|
|
43
|
+
message: { messageId: "msg-1" },
|
|
44
|
+
envelope: {
|
|
45
|
+
subject: "Lunch",
|
|
46
|
+
messageIdValue: "<m1@example.com>",
|
|
47
|
+
from: [{ normalizedEmail: "them@example.com", displayName: "Them" }],
|
|
48
|
+
replyTo: [],
|
|
49
|
+
to: [],
|
|
50
|
+
cc: [],
|
|
51
|
+
},
|
|
52
|
+
references: [],
|
|
53
|
+
bodyParts: [],
|
|
54
|
+
} as unknown as RemitImapDescribeMessageResponse;
|
|
55
|
+
|
|
56
|
+
const outboxEntry = (status: string) => ({
|
|
57
|
+
outboxMessageId: OUTBOX_MESSAGE_ID,
|
|
58
|
+
accountId: ACCOUNT_ID,
|
|
59
|
+
fromAddress: account.email,
|
|
60
|
+
toAddresses: ["them@example.com"],
|
|
61
|
+
ccAddresses: [],
|
|
62
|
+
bccAddresses: [],
|
|
63
|
+
references: [],
|
|
64
|
+
subject: "Re: Lunch",
|
|
65
|
+
textBody: "yes",
|
|
66
|
+
status,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
let harness: DomHarness | undefined;
|
|
70
|
+
let http: HttpMock | undefined;
|
|
71
|
+
let closed = 0;
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
harness?.close();
|
|
75
|
+
harness = undefined;
|
|
76
|
+
http?.restore();
|
|
77
|
+
http = undefined;
|
|
78
|
+
closed = 0;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const callsTo = (suffix: string) =>
|
|
82
|
+
(http?.calls ?? []).filter((call) => call.path.endsWith(suffix));
|
|
83
|
+
|
|
84
|
+
const patchCount = (): number =>
|
|
85
|
+
(http?.calls ?? []).filter((call) => call.method === "PATCH").length;
|
|
86
|
+
|
|
87
|
+
const Opened = ({ outboxMessageId }: { outboxMessageId?: string }) => {
|
|
88
|
+
const { state, openCompose } = useCompose();
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
openCompose({ mode: "reply", account, sourceMessage, outboxMessageId });
|
|
92
|
+
}, [openCompose, outboxMessageId]);
|
|
93
|
+
|
|
94
|
+
if (!state.isOpen) return null;
|
|
95
|
+
|
|
96
|
+
return createElement(ComposeForm, {
|
|
97
|
+
mode: "reply",
|
|
98
|
+
account,
|
|
99
|
+
sourceMessage,
|
|
100
|
+
onClose: () => {
|
|
101
|
+
closed += 1;
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
interface MountOptions {
|
|
107
|
+
/** The draft compose opens on, when the user is resuming one. */
|
|
108
|
+
outboxMessageId?: string;
|
|
109
|
+
/** Hold the PATCH response open so a save is still in flight at send time. */
|
|
110
|
+
gatePatch?: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const mount = async (
|
|
114
|
+
options: MountOptions = {},
|
|
115
|
+
): Promise<{ releasePatch: () => void }> => {
|
|
116
|
+
let release = (): void => {};
|
|
117
|
+
const patchGate = options.gatePatch
|
|
118
|
+
? new Promise<void>((resolve) => {
|
|
119
|
+
release = resolve;
|
|
120
|
+
})
|
|
121
|
+
: Promise.resolve();
|
|
122
|
+
|
|
123
|
+
let status = options.outboxMessageId ? "draft" : "absent";
|
|
124
|
+
|
|
125
|
+
http = mockFetch(async (call) => {
|
|
126
|
+
if (call.path.endsWith("/config")) return { accounts: [account] };
|
|
127
|
+
|
|
128
|
+
if (call.method === "POST" && call.path.endsWith("/outbox")) {
|
|
129
|
+
status = call.body?.sendImmediately === true ? "queued" : "draft";
|
|
130
|
+
return outboxEntry(status);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (call.path.endsWith("/send")) {
|
|
134
|
+
if (status !== "draft") {
|
|
135
|
+
return httpError(409, `This message is already ${status}.`);
|
|
136
|
+
}
|
|
137
|
+
status = "queued";
|
|
138
|
+
return outboxEntry(status);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (call.method === "PATCH") {
|
|
142
|
+
await patchGate;
|
|
143
|
+
return outboxEntry(status);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return outboxEntry(status);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
harness = createDomHarness();
|
|
150
|
+
harness.renderApp(
|
|
151
|
+
createElement(
|
|
152
|
+
ComposeProvider,
|
|
153
|
+
null,
|
|
154
|
+
createElement(Opened, { outboxMessageId: options.outboxMessageId }),
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
await harness.flush();
|
|
158
|
+
await harness.wait(50);
|
|
159
|
+
|
|
160
|
+
return { releasePatch: release };
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const subjectField = (): HTMLElement => {
|
|
164
|
+
const field = harness?.query("[data-subject-field]");
|
|
165
|
+
if (!field) throw new Error("the compose subject field is not mounted");
|
|
166
|
+
return field;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const sendButton = (): HTMLElement => {
|
|
170
|
+
const button = harness?.byText("button", "Send");
|
|
171
|
+
if (!button) throw new Error("the compose send button is not mounted");
|
|
172
|
+
return button;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
describe("compose and the outbox entry it is sending (#604)", () => {
|
|
176
|
+
it("writes no further PATCH to an entry once it has been sent", async () => {
|
|
177
|
+
const { releasePatch } = await mount({
|
|
178
|
+
outboxMessageId: OUTBOX_MESSAGE_ID,
|
|
179
|
+
gatePatch: true,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
harness?.type(subjectField(), "Re: Lunch tomorrow");
|
|
183
|
+
await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 100);
|
|
184
|
+
assert.equal(patchCount(), 1, "the typing burst autosaves once");
|
|
185
|
+
|
|
186
|
+
harness?.click(sendButton());
|
|
187
|
+
await harness?.flush();
|
|
188
|
+
|
|
189
|
+
// The autosave that was still in flight settles after the send, which is
|
|
190
|
+
// what used to re-arm the debounce.
|
|
191
|
+
releasePatch();
|
|
192
|
+
await harness?.flush();
|
|
193
|
+
await harness?.wait(AUTOSAVE_DEBOUNCE_MS + 200);
|
|
194
|
+
|
|
195
|
+
assert.equal(callsTo("/send").length, 1, "the send went out");
|
|
196
|
+
assert.equal(patchCount(), 1, "no autosave PATCH followed the send");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("sends a reply pressed before the first autosave, in one dispatch", async () => {
|
|
200
|
+
await mount();
|
|
201
|
+
|
|
202
|
+
harness?.click(sendButton());
|
|
203
|
+
await harness?.flush();
|
|
204
|
+
await harness?.wait(100);
|
|
205
|
+
|
|
206
|
+
const created = callsTo("/outbox").filter((call) => call.method === "POST");
|
|
207
|
+
assert.equal(created.length, 1, "the message was created once");
|
|
208
|
+
assert.equal(created[0]?.body?.sendImmediately, false);
|
|
209
|
+
assert.equal(callsTo("/send").length, 1, "the send went out");
|
|
210
|
+
assert.equal(closed, 1, "compose closed on a successful send");
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -243,6 +243,108 @@ describe("buildAuthenticityIntel", () => {
|
|
|
243
243
|
}
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
+
describe("a passing signature over a claim that does not hold", () => {
|
|
247
|
+
// The InfoMedics invoice phish: an attacker's own free Atlassian tenant,
|
|
248
|
+
// so SPF/DKIM/DMARC genuinely pass for a domain nobody recognises, and the
|
|
249
|
+
// provider's own filter already called it spam.
|
|
250
|
+
const infoMedics = makeThread({
|
|
251
|
+
fromEmail: "jira@serviceupdatebank.atlassian.net",
|
|
252
|
+
fromName: "InfoMedics",
|
|
253
|
+
subject: "Vordering",
|
|
254
|
+
authenticity: {
|
|
255
|
+
fromDomain: "serviceupdatebank.atlassian.net",
|
|
256
|
+
dkimDomain: "custmx.one.com",
|
|
257
|
+
dkimMismatch: false,
|
|
258
|
+
displayNameCorrespondence: "Unrelated",
|
|
259
|
+
offDomainLinkDomains: ["betaal-vordering.example"],
|
|
260
|
+
},
|
|
261
|
+
} as Partial<RemitImapThreadMessageResponse>);
|
|
262
|
+
|
|
263
|
+
test("is never presented as verified", () => {
|
|
264
|
+
const result = buildAuthenticityIntel(infoMedics, 0);
|
|
265
|
+
assert.notEqual(result.verdict, "aligned");
|
|
266
|
+
assert.doesNotMatch(result.summary, /We verified/i);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("names the display name and the link destination", () => {
|
|
270
|
+
const result = buildAuthenticityIntel(infoMedics, 0);
|
|
271
|
+
assert.equal(result.verdict, "caution");
|
|
272
|
+
assert.match(result.summary, /really was sent by/);
|
|
273
|
+
assert.match(result.summary, /"InfoMedics"/);
|
|
274
|
+
assert.match(result.summary, /betaal-vordering\.example/);
|
|
275
|
+
assert.doesNotMatch(result.summary, /DKIM|SPF|DMARC/i);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("a lookalike display name reads as an imitation", () => {
|
|
279
|
+
const result = buildAuthenticityIntel(
|
|
280
|
+
makeThread({
|
|
281
|
+
fromEmail: "billing@1nfomedics.nl",
|
|
282
|
+
fromName: "InfoMedics",
|
|
283
|
+
authenticity: {
|
|
284
|
+
fromDomain: "1nfomedics.nl",
|
|
285
|
+
dkimDomain: "1nfomedics.nl",
|
|
286
|
+
dkimMismatch: false,
|
|
287
|
+
displayNameCorrespondence: "Lookalike",
|
|
288
|
+
offDomainLinkDomains: [],
|
|
289
|
+
},
|
|
290
|
+
} as Partial<RemitImapThreadMessageResponse>),
|
|
291
|
+
0,
|
|
292
|
+
);
|
|
293
|
+
assert.equal(result.verdict, "caution");
|
|
294
|
+
assert.match(result.summary, /only looks like/);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("stays verified when the comparisons agreed", () => {
|
|
298
|
+
const result = buildAuthenticityIntel(
|
|
299
|
+
makeThread({
|
|
300
|
+
fromEmail: "notifications@notifications.github.com",
|
|
301
|
+
fromName: "GitHub",
|
|
302
|
+
authenticity: {
|
|
303
|
+
fromDomain: "notifications.github.com",
|
|
304
|
+
dkimDomain: "github.com",
|
|
305
|
+
dkimMismatch: false,
|
|
306
|
+
displayNameCorrespondence: "Corresponds",
|
|
307
|
+
offDomainLinkDomains: [],
|
|
308
|
+
},
|
|
309
|
+
} as Partial<RemitImapThreadMessageResponse>),
|
|
310
|
+
0,
|
|
311
|
+
);
|
|
312
|
+
assert.equal(result.verdict, "aligned");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("stays verified when nothing was compared", () => {
|
|
316
|
+
const result = buildAuthenticityIntel(
|
|
317
|
+
makeThread({
|
|
318
|
+
fromEmail: "alice@example.com",
|
|
319
|
+
authenticity: {
|
|
320
|
+
fromDomain: "example.com",
|
|
321
|
+
dkimDomain: "example.com",
|
|
322
|
+
dkimMismatch: false,
|
|
323
|
+
},
|
|
324
|
+
} as Partial<RemitImapThreadMessageResponse>),
|
|
325
|
+
0,
|
|
326
|
+
);
|
|
327
|
+
assert.equal(result.verdict, "aligned");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("a drifted link list carries no destination to name", () => {
|
|
331
|
+
const result = buildAuthenticityIntel(
|
|
332
|
+
makeThread({
|
|
333
|
+
fromEmail: "alice@example.com",
|
|
334
|
+
authenticity: {
|
|
335
|
+
fromDomain: "example.com",
|
|
336
|
+
dkimDomain: "example.com",
|
|
337
|
+
dkimMismatch: false,
|
|
338
|
+
displayNameCorrespondence: "Corresponds",
|
|
339
|
+
offDomainLinkDomains: "elsewhere.example",
|
|
340
|
+
},
|
|
341
|
+
} as unknown as Partial<RemitImapThreadMessageResponse>),
|
|
342
|
+
0,
|
|
343
|
+
);
|
|
344
|
+
assert.equal(result.verdict, "aligned");
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
246
348
|
describe("unparseable sender drives the red tier", () => {
|
|
247
349
|
test("mismatch + addressUnreadable when the domain has no dot", () => {
|
|
248
350
|
const thread = makeThread({
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
RemitImapAddressResponse,
|
|
7
7
|
RemitImapThreadMessageResponse,
|
|
8
8
|
} from "@remit/api-http-client/types.gen.ts";
|
|
9
|
+
import { DisplayNameCorrespondence } from "@remit/domain-enums";
|
|
9
10
|
import type {
|
|
10
11
|
AuthenticityIntel,
|
|
11
12
|
IntelligenceData,
|
|
@@ -97,6 +98,65 @@ function buildSenderFlags(
|
|
|
97
98
|
};
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/** The brand the display name asserts, or `undefined` when it asserts none. */
|
|
102
|
+
function claimedBrandOf(
|
|
103
|
+
thread: RemitImapThreadMessageResponse,
|
|
104
|
+
): string | undefined {
|
|
105
|
+
if (!thread.fromName) return undefined;
|
|
106
|
+
if (thread.fromName === thread.fromEmail) return undefined;
|
|
107
|
+
return thread.fromName;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function joinDomains(domains: readonly string[]): string {
|
|
111
|
+
if (domains.length === 1) return domains[0];
|
|
112
|
+
return `${domains.slice(0, -1).join(", ")} and ${domains[domains.length - 1]}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The clauses naming what does not line up on a message whose signature checks
|
|
117
|
+
* out. Empty when everything the backend compared agreed — including when it
|
|
118
|
+
* compared nothing, which is every message the provider's filter did not
|
|
119
|
+
* already call spam.
|
|
120
|
+
*/
|
|
121
|
+
function describeSenderMismatch(
|
|
122
|
+
auth: NonNullable<RemitImapThreadMessageResponse["authenticity"]>,
|
|
123
|
+
claimedBrand: string | undefined,
|
|
124
|
+
): string[] {
|
|
125
|
+
const clauses: string[] = [];
|
|
126
|
+
const correspondence = auth.displayNameCorrespondence;
|
|
127
|
+
|
|
128
|
+
if (claimedBrand) {
|
|
129
|
+
if (correspondence === DisplayNameCorrespondence.Unrelated) {
|
|
130
|
+
clauses.push(
|
|
131
|
+
`The name it shows, "${claimedBrand}", has nothing to do with that domain.`,
|
|
132
|
+
);
|
|
133
|
+
} else if (correspondence === DisplayNameCorrespondence.Lookalike) {
|
|
134
|
+
clauses.push(
|
|
135
|
+
`The name it shows, "${claimedBrand}", only looks like that domain.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const linkDomains = readDomainList(auth.offDomainLinkDomains);
|
|
141
|
+
if (linkDomains.length > 0) {
|
|
142
|
+
clauses.push(`Its links go to ${joinDomains(linkDomains.slice(0, 3))}.`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return clauses;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The field is a JSON blob on the message row, so a value written by an older
|
|
150
|
+
* or drifted writer reaches here as whatever it happens to be. Anything that is
|
|
151
|
+
* not a list of non-empty strings carries no destination to name.
|
|
152
|
+
*/
|
|
153
|
+
function readDomainList(value: unknown): string[] {
|
|
154
|
+
if (!Array.isArray(value)) return [];
|
|
155
|
+
return value.filter(
|
|
156
|
+
(entry): entry is string => typeof entry === "string" && entry.length > 0,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
100
160
|
/**
|
|
101
161
|
* Build authenticity intel from the thread message's authenticity field.
|
|
102
162
|
*
|
|
@@ -130,6 +190,20 @@ export function buildAuthenticityIntel(
|
|
|
130
190
|
};
|
|
131
191
|
}
|
|
132
192
|
if (!auth.dkimMismatch) {
|
|
193
|
+
const claimed = claimedBrandOf(thread);
|
|
194
|
+
const unlike = describeSenderMismatch(auth, claimed);
|
|
195
|
+
if (unlike.length > 0) {
|
|
196
|
+
return {
|
|
197
|
+
verdict: "caution",
|
|
198
|
+
fromDomain: auth.fromDomain,
|
|
199
|
+
dkimDomain: auth.dkimDomain,
|
|
200
|
+
claimedBrand: claimed,
|
|
201
|
+
summary: [
|
|
202
|
+
`This message really was sent by ${auth.fromDomain}.`,
|
|
203
|
+
...unlike,
|
|
204
|
+
].join(" "),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
133
207
|
return {
|
|
134
208
|
verdict: "aligned",
|
|
135
209
|
fromDomain: auth.fromDomain,
|
|
@@ -142,10 +216,7 @@ export function buildAuthenticityIntel(
|
|
|
142
216
|
|
|
143
217
|
const fromDomain = auth.fromDomain;
|
|
144
218
|
const dkimDomain = auth.dkimDomain;
|
|
145
|
-
const claimedBrand =
|
|
146
|
-
thread.fromName && thread.fromName !== thread.fromEmail
|
|
147
|
-
? thread.fromName
|
|
148
|
-
: undefined;
|
|
219
|
+
const claimedBrand = claimedBrandOf(thread);
|
|
149
220
|
const summary = claimedBrand
|
|
150
221
|
? `The display name claims "${claimedBrand}", but this message was actually sent from ${dkimDomain ?? "another sender"} — not ${fromDomain}. Real senders use their own address.`
|
|
151
222
|
: `This message claims to be from ${fromDomain}, but it was actually sent from ${dkimDomain ?? "a different sender"}.`;
|
|
@@ -32,6 +32,7 @@ export const useSaveDraft = ({
|
|
|
32
32
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
|
|
33
33
|
const [saveError, setSaveError] = useState<unknown>(null);
|
|
34
34
|
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
35
|
+
const closedIdsRef = useRef<Set<string>>(new Set());
|
|
35
36
|
const queryClient = useQueryClient();
|
|
36
37
|
|
|
37
38
|
const createMutation = useMutation(
|
|
@@ -88,6 +89,7 @@ export const useSaveDraft = ({
|
|
|
88
89
|
|
|
89
90
|
const saveDraft = useCallback(
|
|
90
91
|
(data: DraftData) => {
|
|
92
|
+
if (outboxMessageId && closedIdsRef.current.has(outboxMessageId)) return;
|
|
91
93
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
92
94
|
timerRef.current = setTimeout(() => {
|
|
93
95
|
// Keep the real error, not just a vague "error" status — the caller
|
|
@@ -99,7 +101,7 @@ export const useSaveDraft = ({
|
|
|
99
101
|
});
|
|
100
102
|
}, 2000);
|
|
101
103
|
},
|
|
102
|
-
[executeSave],
|
|
104
|
+
[executeSave, outboxMessageId],
|
|
103
105
|
);
|
|
104
106
|
|
|
105
107
|
const saveImmediately = useCallback(
|
|
@@ -110,9 +112,15 @@ export const useSaveDraft = ({
|
|
|
110
112
|
[executeSave],
|
|
111
113
|
);
|
|
112
114
|
|
|
113
|
-
|
|
115
|
+
// Called with an id, the entry is closed to autosave for good. Sending and
|
|
116
|
+
// discarding both take the entry out of draft, and the compose effect that
|
|
117
|
+
// schedules autosaves re-runs on every mutation settling — so dropping the
|
|
118
|
+
// pending timer alone leaves the next render free to schedule another write
|
|
119
|
+
// against an entry the server will refuse (#604).
|
|
120
|
+
const stopAutoSave = useCallback((closedOutboxMessageId?: string) => {
|
|
114
121
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
122
|
+
if (closedOutboxMessageId) closedIdsRef.current.add(closedOutboxMessageId);
|
|
115
123
|
}, []);
|
|
116
124
|
|
|
117
|
-
return { saveStatus, saveError, saveDraft, saveImmediately,
|
|
125
|
+
return { saveStatus, saveError, saveDraft, saveImmediately, stopAutoSave };
|
|
118
126
|
};
|