@remit/mailbox-service 0.0.46 → 0.0.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -95,7 +95,11 @@ export const classifyByHeaders = (parsed: ParsedMail): Category => {
|
|
|
95
95
|
if (matchesPrecedence(headers)) return MessageCategory.automated;
|
|
96
96
|
if (isMachineSender(parsed, lines)) return MessageCategory.automated;
|
|
97
97
|
|
|
98
|
-
if (
|
|
98
|
+
if (
|
|
99
|
+
fromDomain &&
|
|
100
|
+
pickAlignedOrFirstMismatch(extractDkimDomains(headers, lines), fromDomain)
|
|
101
|
+
.mismatch
|
|
102
|
+
) {
|
|
99
103
|
return MessageCategory.automated;
|
|
100
104
|
}
|
|
101
105
|
|
|
@@ -126,15 +130,12 @@ export const extractAuthenticity = (
|
|
|
126
130
|
const dkimDomains = extractDkimDomains(headers, lines);
|
|
127
131
|
if (dkimDomains.length === 0) return null;
|
|
128
132
|
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
// Pick the reported domain: first mismatching one on mismatch, first domain otherwise.
|
|
132
|
-
const reportedDomain = result.mismatchingDomain ?? dkimDomains[0];
|
|
133
|
+
const picked = pickAlignedOrFirstMismatch(dkimDomains, fromDomain);
|
|
133
134
|
|
|
134
135
|
return {
|
|
135
136
|
fromDomain,
|
|
136
|
-
dkimDomain:
|
|
137
|
-
dkimMismatch:
|
|
137
|
+
dkimDomain: picked.domain ?? undefined,
|
|
138
|
+
dkimMismatch: picked.mismatch,
|
|
138
139
|
};
|
|
139
140
|
};
|
|
140
141
|
|
|
@@ -296,38 +297,34 @@ const domainMatches = (
|
|
|
296
297
|
};
|
|
297
298
|
|
|
298
299
|
/**
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
* Alignment: signing domain equals From domain, or one is a subdomain of
|
|
304
|
-
* the other (parent/child). Any single aligned domain is enough to consider
|
|
305
|
-
* the message non-mismatching — a legitimate re-mailer signing under a
|
|
306
|
-
* subdomain is not suspicious.
|
|
307
|
-
*
|
|
308
|
-
* On mismatch the first non-aligned domain is reported so the UI can show
|
|
309
|
-
* "signed by relay.example.net, claims example.com".
|
|
300
|
+
* Whether a signing domain aligns with the From domain: equal, or one is a
|
|
301
|
+
* subdomain of the other (parent/child). A legitimate re-mailer signing under
|
|
302
|
+
* a subdomain is not suspicious, so either direction counts as aligned.
|
|
310
303
|
*/
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
304
|
+
const domainsAligned = (signingDomain: string, fromDomain: string): boolean =>
|
|
305
|
+
signingDomain === fromDomain ||
|
|
306
|
+
fromDomain.endsWith(`.${signingDomain}`) ||
|
|
307
|
+
signingDomain.endsWith(`.${fromDomain}`);
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Pick the domain to report out of a list of candidate signing domains: the
|
|
311
|
+
* first one aligned with the From domain, so a legitimate signature is never
|
|
312
|
+
* shadowed by an earlier unrelated one. When none align, the first is
|
|
313
|
+
* reported as the mismatching evidence — the UI can then show "signed by
|
|
314
|
+
* relay.example.net, claims example.com". Shared by the category heuristic
|
|
315
|
+
* (rule 9, which reads only `.mismatch`) and the authenticity extractor
|
|
316
|
+
* (which also reads `.domain`), so the two can never disagree.
|
|
317
|
+
*/
|
|
318
|
+
const pickAlignedOrFirstMismatch = (
|
|
319
|
+
domains: string[],
|
|
314
320
|
fromDomain: string,
|
|
315
|
-
): { mismatch: boolean;
|
|
316
|
-
const dkimDomains = extractDkimDomains(headers, lines);
|
|
317
|
-
if (dkimDomains.length === 0)
|
|
318
|
-
return { mismatch: false, mismatchingDomain: null };
|
|
321
|
+
): { mismatch: boolean; domain: string | null } => {
|
|
319
322
|
let firstMismatching: string | null = null;
|
|
320
|
-
for (const d of
|
|
321
|
-
if (
|
|
322
|
-
d === fromDomain ||
|
|
323
|
-
fromDomain.endsWith(`.${d}`) ||
|
|
324
|
-
d.endsWith(`.${fromDomain}`)
|
|
325
|
-
) {
|
|
326
|
-
return { mismatch: false, mismatchingDomain: null };
|
|
327
|
-
}
|
|
323
|
+
for (const d of domains) {
|
|
324
|
+
if (domainsAligned(d, fromDomain)) return { mismatch: false, domain: d };
|
|
328
325
|
if (!firstMismatching) firstMismatching = d;
|
|
329
326
|
}
|
|
330
|
-
return { mismatch:
|
|
327
|
+
return { mismatch: firstMismatching !== null, domain: firstMismatching };
|
|
331
328
|
};
|
|
332
329
|
|
|
333
330
|
const extractDkimDomains = (headers: Headers, lines: HeaderLines): string[] => {
|
|
@@ -87,6 +87,28 @@ describe("classifyDisplayNameCorrespondence", () => {
|
|
|
87
87
|
DisplayNameCorrespondence.Unrelated,
|
|
88
88
|
);
|
|
89
89
|
});
|
|
90
|
+
|
|
91
|
+
// Live phishing shape: a short, valuable brand name embedded as a
|
|
92
|
+
// coincidental substring of a longer, attacker-chosen domain. "ing" sits
|
|
93
|
+
// inside "secureingverify" the same way "irs"/"dhl"/"ups"/"kpn" sit inside
|
|
94
|
+
// countless lookalike domains — none of that is the domain naming the
|
|
95
|
+
// brand.
|
|
96
|
+
it("does not match a short brand name that is merely embedded in a longer domain label (ING)", () => {
|
|
97
|
+
assert.equal(
|
|
98
|
+
classifyDisplayNameCorrespondence(
|
|
99
|
+
"ING Fraudedesk",
|
|
100
|
+
"secure-ing-verify.tk",
|
|
101
|
+
),
|
|
102
|
+
DisplayNameCorrespondence.Unrelated,
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("still matches a short brand name against its own real domain", () => {
|
|
107
|
+
assert.equal(
|
|
108
|
+
classifyDisplayNameCorrespondence("ING", "ing.nl"),
|
|
109
|
+
DisplayNameCorrespondence.Corresponds,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
90
112
|
});
|
|
91
113
|
|
|
92
114
|
describe("extractOffDomainLinkDomains", () => {
|
|
@@ -90,11 +90,22 @@ const lookalikeThreshold = (length: number): number => {
|
|
|
90
90
|
/**
|
|
91
91
|
* Whether the From display name corresponds to the From domain.
|
|
92
92
|
*
|
|
93
|
-
* Containment decides: the normalised name, or any word of it,
|
|
94
|
-
*
|
|
95
|
-
* `notifications.github.com`; `InfoMedics`
|
|
93
|
+
* Containment decides: the normalised name, or any word of it, containing an
|
|
94
|
+
* entire domain candidate. `GitHub` contains the label `github` from
|
|
95
|
+
* `notifications.github.com`; `InfoMedics` contains no label of
|
|
96
96
|
* `serviceupdatebank.atlassian.net`.
|
|
97
97
|
*
|
|
98
|
+
* Only that direction counts — a domain candidate containing the (shorter)
|
|
99
|
+
* name does not. `ING Fraudedesk` is not a match for `secure-ing-verify.tk`
|
|
100
|
+
* just because the three-letter word "ing" sits inside "secureingverify":
|
|
101
|
+
* that is a coincidental substring of a longer label the domain owner chose,
|
|
102
|
+
* not a domain that names the brand. The direction this drops is exactly the
|
|
103
|
+
* one a short, valuable brand name is deliberately embedded into a longer,
|
|
104
|
+
* unrelated-looking domain to exploit — the live Dutch-bank shape this was
|
|
105
|
+
* fixed against (`ING`). A real short brand over its own domain (`ING` /
|
|
106
|
+
* `ing.nl`) still matches: name and label are then equal, and equality
|
|
107
|
+
* satisfies containment in either direction.
|
|
108
|
+
*
|
|
98
109
|
* A bounded edit distance is the secondary test, and only reaches names that
|
|
99
110
|
* nearly match a label — `InfoMedics` against `1nfomedics.nl`. It cannot promote
|
|
100
111
|
* an unrelated name on its own.
|
|
@@ -117,7 +128,7 @@ export const classifyDisplayNameCorrespondence = (
|
|
|
117
128
|
const terms = [name, ...words(raw)];
|
|
118
129
|
for (const term of terms) {
|
|
119
130
|
for (const candidate of candidates) {
|
|
120
|
-
if (
|
|
131
|
+
if (term.includes(candidate)) {
|
|
121
132
|
return DisplayNameCorrespondence.Corresponds;
|
|
122
133
|
}
|
|
123
134
|
}
|
package/src/index.ts
CHANGED
|
@@ -233,6 +233,13 @@ export {
|
|
|
233
233
|
normalizeSubject,
|
|
234
234
|
removeQuotedContent,
|
|
235
235
|
} from "./snippet.js";
|
|
236
|
+
export {
|
|
237
|
+
MoveNotSettledError,
|
|
238
|
+
type SpamReportConfig,
|
|
239
|
+
type SpamReportLogger,
|
|
240
|
+
type SpamReportParams,
|
|
241
|
+
SpamReportService,
|
|
242
|
+
} from "./spam-report.js";
|
|
236
243
|
export {
|
|
237
244
|
reconcileStaleMessage,
|
|
238
245
|
type StaleMessageReconcileDeps,
|
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
AddressFlags,
|
|
5
|
+
IAccountRepository,
|
|
6
|
+
IAddressRepository,
|
|
7
|
+
IMailboxRepository,
|
|
8
|
+
IMailboxSpecialUseRepository,
|
|
9
|
+
IMessageFlagPushRepository,
|
|
10
|
+
IMessageRepository,
|
|
11
|
+
IThreadMessageRepository,
|
|
12
|
+
} from "@remit/data-ports";
|
|
13
|
+
import { AddressRole } from "@remit/domain-enums";
|
|
14
|
+
import { FlagPushService } from "./flag-push.js";
|
|
15
|
+
import { MessageMoveService } from "./message-move.js";
|
|
16
|
+
import { MoveNotSettledError, SpamReportService } from "./spam-report.js";
|
|
17
|
+
|
|
18
|
+
const ACCOUNT = "acc-1";
|
|
19
|
+
const ACCOUNT_CONFIG = "cfg-1";
|
|
20
|
+
const ACCOUNT_EMAIL = "me@example.com";
|
|
21
|
+
const INBOX_MAILBOX = "mbx-inbox";
|
|
22
|
+
const JUNK_MAILBOX = "mbx-junk";
|
|
23
|
+
const MESSAGE_ID = "msg-1";
|
|
24
|
+
const ADDRESS_ID = "addr-1";
|
|
25
|
+
const THREAD_ID = "thread-1";
|
|
26
|
+
|
|
27
|
+
interface ThreadRow {
|
|
28
|
+
accountConfigId: string;
|
|
29
|
+
threadMessageId: string;
|
|
30
|
+
threadId: string;
|
|
31
|
+
messageId: string;
|
|
32
|
+
mailboxId: string;
|
|
33
|
+
messageIdHeader: string;
|
|
34
|
+
isRead?: boolean;
|
|
35
|
+
hasStars?: boolean;
|
|
36
|
+
hasAttachment?: boolean;
|
|
37
|
+
isDeleted?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface World {
|
|
41
|
+
service: SpamReportService;
|
|
42
|
+
messages: Map<string, Record<string, unknown>>;
|
|
43
|
+
addresses: Map<string, { flags: AddressFlags }>;
|
|
44
|
+
threadRows: ThreadRow[];
|
|
45
|
+
sent: unknown[];
|
|
46
|
+
sqsImpl: { send: (command: unknown) => Promise<unknown> };
|
|
47
|
+
markerPuts: Array<Record<string, unknown>>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const settleMove = (messages: Map<string, Record<string, unknown>>) => {
|
|
51
|
+
const m = messages.get(MESSAGE_ID);
|
|
52
|
+
assert.ok(m !== undefined);
|
|
53
|
+
m.status = "active";
|
|
54
|
+
m.syncStatus = "synced";
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const buildWorld = (
|
|
58
|
+
opts: {
|
|
59
|
+
startMailbox?: string;
|
|
60
|
+
fromEmail?: string;
|
|
61
|
+
originalMailboxId?: string;
|
|
62
|
+
} = {},
|
|
63
|
+
): World => {
|
|
64
|
+
const startMailbox = opts.startMailbox ?? INBOX_MAILBOX;
|
|
65
|
+
const fromEmail = opts.fromEmail ?? "sender@example.com";
|
|
66
|
+
|
|
67
|
+
const messages = new Map<string, Record<string, unknown>>([
|
|
68
|
+
[
|
|
69
|
+
MESSAGE_ID,
|
|
70
|
+
{
|
|
71
|
+
messageId: MESSAGE_ID,
|
|
72
|
+
mailboxId: startMailbox,
|
|
73
|
+
uid: 42,
|
|
74
|
+
rfc822Size: 100,
|
|
75
|
+
internalDate: 1_700_000_000_000,
|
|
76
|
+
envelopeId: "env-1",
|
|
77
|
+
rootBodyPartId: "body-1",
|
|
78
|
+
category: "primary",
|
|
79
|
+
hasListUnsubscribe: false,
|
|
80
|
+
syncStatus: "synced",
|
|
81
|
+
...(opts.originalMailboxId
|
|
82
|
+
? { originalMailboxId: opts.originalMailboxId }
|
|
83
|
+
: {}),
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const addresses = new Map<string, { flags: AddressFlags }>([
|
|
89
|
+
[ADDRESS_ID, { flags: {} }],
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
const threadRows: ThreadRow[] = [
|
|
93
|
+
{
|
|
94
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
95
|
+
threadMessageId: `tm:${THREAD_ID}::${MESSAGE_ID}`,
|
|
96
|
+
threadId: THREAD_ID,
|
|
97
|
+
messageId: MESSAGE_ID,
|
|
98
|
+
mailboxId: INBOX_MAILBOX,
|
|
99
|
+
messageIdHeader: "<abc@example.com>",
|
|
100
|
+
isRead: false,
|
|
101
|
+
hasStars: false,
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
const mailboxes = new Map<string, Record<string, unknown>>([
|
|
106
|
+
[
|
|
107
|
+
INBOX_MAILBOX,
|
|
108
|
+
{ mailboxId: INBOX_MAILBOX, fullPath: "INBOX", accountId: ACCOUNT },
|
|
109
|
+
],
|
|
110
|
+
[
|
|
111
|
+
JUNK_MAILBOX,
|
|
112
|
+
{ mailboxId: JUNK_MAILBOX, fullPath: "Junk", accountId: ACCOUNT },
|
|
113
|
+
],
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
const messageService = {
|
|
117
|
+
get: async (id: string | string[]) => {
|
|
118
|
+
if (Array.isArray(id)) {
|
|
119
|
+
return id.map((i) => messages.get(i)).filter(Boolean);
|
|
120
|
+
}
|
|
121
|
+
const m = messages.get(id);
|
|
122
|
+
if (!m) throw new Error(`no message ${id}`);
|
|
123
|
+
return m;
|
|
124
|
+
},
|
|
125
|
+
describe: async (id: string) => {
|
|
126
|
+
const m = messages.get(id);
|
|
127
|
+
if (!m) throw new Error(`no message ${id}`);
|
|
128
|
+
return {
|
|
129
|
+
message: [m],
|
|
130
|
+
messageFlag: [],
|
|
131
|
+
envelope: [],
|
|
132
|
+
messageReference: [],
|
|
133
|
+
envelopeAddress: [
|
|
134
|
+
{
|
|
135
|
+
envelopeAddressId: "ea-1",
|
|
136
|
+
messageId: id,
|
|
137
|
+
addressId: ADDRESS_ID,
|
|
138
|
+
normalizedEmail: fromEmail,
|
|
139
|
+
addressRole: AddressRole.From,
|
|
140
|
+
addressOrder: 0,
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
bodyPart: [],
|
|
144
|
+
bodyPartParameter: [],
|
|
145
|
+
rawMessageStorage: [],
|
|
146
|
+
bodyPartStorage: [],
|
|
147
|
+
bodyPartContent: [],
|
|
148
|
+
};
|
|
149
|
+
},
|
|
150
|
+
update: async (id: string, patch: Record<string, unknown>) => {
|
|
151
|
+
const m = messages.get(id);
|
|
152
|
+
if (m) Object.assign(m, patch);
|
|
153
|
+
return m;
|
|
154
|
+
},
|
|
155
|
+
updateForMove: async (id: string, patch: Record<string, unknown>) => {
|
|
156
|
+
const m = messages.get(id);
|
|
157
|
+
if (m) Object.assign(m, patch);
|
|
158
|
+
return m;
|
|
159
|
+
},
|
|
160
|
+
clearSpamReport: async (id: string) => {
|
|
161
|
+
const m = messages.get(id);
|
|
162
|
+
if (m) delete m.spamReport;
|
|
163
|
+
return m;
|
|
164
|
+
},
|
|
165
|
+
clearOriginalMailboxId: async (id: string) => {
|
|
166
|
+
const m = messages.get(id);
|
|
167
|
+
if (m) {
|
|
168
|
+
delete m.originalMailboxId;
|
|
169
|
+
delete m.originalUid;
|
|
170
|
+
}
|
|
171
|
+
return m;
|
|
172
|
+
},
|
|
173
|
+
} as unknown as IMessageRepository;
|
|
174
|
+
|
|
175
|
+
const addressService = {
|
|
176
|
+
mergeFlags: async (
|
|
177
|
+
_accountConfigId: string,
|
|
178
|
+
addressId: string,
|
|
179
|
+
patch: Record<string, unknown>,
|
|
180
|
+
) => {
|
|
181
|
+
const entry = addresses.get(addressId);
|
|
182
|
+
if (!entry) throw new Error(`no address ${addressId}`);
|
|
183
|
+
const next = { ...entry.flags } as Record<string, unknown>;
|
|
184
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
185
|
+
if (value === undefined) continue;
|
|
186
|
+
if (value === null) {
|
|
187
|
+
delete next[key];
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
next[key] = value;
|
|
191
|
+
}
|
|
192
|
+
entry.flags = next as AddressFlags;
|
|
193
|
+
return { addressId, flags: entry.flags };
|
|
194
|
+
},
|
|
195
|
+
} as unknown as IAddressRepository;
|
|
196
|
+
|
|
197
|
+
const accountService = {
|
|
198
|
+
get: async () => ({ accountId: ACCOUNT, email: ACCOUNT_EMAIL }),
|
|
199
|
+
} as unknown as IAccountRepository;
|
|
200
|
+
|
|
201
|
+
const mailboxSpecialUseService = {
|
|
202
|
+
findJunkMailbox: async () => ({
|
|
203
|
+
mailboxId: JUNK_MAILBOX,
|
|
204
|
+
fullPath: "Junk",
|
|
205
|
+
}),
|
|
206
|
+
findTrashMailbox: async () => null,
|
|
207
|
+
} as unknown as IMailboxSpecialUseRepository;
|
|
208
|
+
|
|
209
|
+
const mailboxService = {
|
|
210
|
+
get: async (_acc: string, id: string | string[]) => {
|
|
211
|
+
if (Array.isArray(id)) {
|
|
212
|
+
return id.map((i) => mailboxes.get(i)).filter(Boolean);
|
|
213
|
+
}
|
|
214
|
+
return mailboxes.get(id);
|
|
215
|
+
},
|
|
216
|
+
} as unknown as IMailboxRepository;
|
|
217
|
+
|
|
218
|
+
const threadMessageService = {
|
|
219
|
+
getByMessageId: async (_cfg: string, messageId: string) => {
|
|
220
|
+
const row = threadRows.find((r) => r.messageId === messageId);
|
|
221
|
+
if (!row) throw new Error(`no thread message ${messageId}`);
|
|
222
|
+
return row;
|
|
223
|
+
},
|
|
224
|
+
update: async (
|
|
225
|
+
_cfg: string,
|
|
226
|
+
threadMessageId: string,
|
|
227
|
+
patch: Record<string, unknown>,
|
|
228
|
+
) => {
|
|
229
|
+
const row = threadRows.find((r) => r.threadMessageId === threadMessageId);
|
|
230
|
+
if (row) Object.assign(row, patch);
|
|
231
|
+
return row;
|
|
232
|
+
},
|
|
233
|
+
} as unknown as IThreadMessageRepository;
|
|
234
|
+
|
|
235
|
+
const sent: unknown[] = [];
|
|
236
|
+
const sqsImpl = {
|
|
237
|
+
send: async (command: unknown) => {
|
|
238
|
+
sent.push(command);
|
|
239
|
+
return {};
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const messageMoveService = new MessageMoveService({
|
|
244
|
+
messageService,
|
|
245
|
+
mailboxService,
|
|
246
|
+
mailboxSpecialUseService,
|
|
247
|
+
threadMessageService,
|
|
248
|
+
sqsQueueUrl: "http://localhost:9324/000000000000/message-mgmt",
|
|
249
|
+
});
|
|
250
|
+
(
|
|
251
|
+
messageMoveService as unknown as {
|
|
252
|
+
sqs: { send: (c: unknown) => Promise<unknown> };
|
|
253
|
+
}
|
|
254
|
+
).sqs = sqsImpl;
|
|
255
|
+
|
|
256
|
+
const markerPuts: Array<Record<string, unknown>> = [];
|
|
257
|
+
const markerService: IMessageFlagPushRepository = {
|
|
258
|
+
put: async (input: Record<string, unknown>) => {
|
|
259
|
+
markerPuts.push(input);
|
|
260
|
+
return {
|
|
261
|
+
...input,
|
|
262
|
+
state: "pending",
|
|
263
|
+
createdAt: 1,
|
|
264
|
+
updatedAt: 1,
|
|
265
|
+
} as never;
|
|
266
|
+
},
|
|
267
|
+
find: async () => null,
|
|
268
|
+
updateState: async () => ({}) as never,
|
|
269
|
+
delete: async () => {},
|
|
270
|
+
listByAccountId: async () => [],
|
|
271
|
+
listByMailboxId: async () => [],
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const flagPushService = new FlagPushService({
|
|
275
|
+
markerService,
|
|
276
|
+
sqsQueueUrl: "http://localhost:9324/000000000000/flag-push",
|
|
277
|
+
});
|
|
278
|
+
(
|
|
279
|
+
flagPushService as unknown as {
|
|
280
|
+
sqs: { send: (c: unknown) => Promise<unknown> };
|
|
281
|
+
}
|
|
282
|
+
).sqs = sqsImpl;
|
|
283
|
+
|
|
284
|
+
const service = new SpamReportService({
|
|
285
|
+
messageService,
|
|
286
|
+
addressService,
|
|
287
|
+
accountService,
|
|
288
|
+
mailboxSpecialUseService,
|
|
289
|
+
messageMoveService,
|
|
290
|
+
flagPushService,
|
|
291
|
+
// Small and fast: these tests simulate settlement explicitly (via
|
|
292
|
+
// settleMove) rather than waiting out a real timeout.
|
|
293
|
+
moveSettleTimeoutMs: 30,
|
|
294
|
+
moveSettlePollMs: 5,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
service,
|
|
299
|
+
messages,
|
|
300
|
+
addresses,
|
|
301
|
+
threadRows,
|
|
302
|
+
sent,
|
|
303
|
+
sqsImpl,
|
|
304
|
+
markerPuts,
|
|
305
|
+
};
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const moveEvents = (sent: unknown[]) =>
|
|
309
|
+
sent.filter(
|
|
310
|
+
(cmd) =>
|
|
311
|
+
JSON.parse((cmd as { input: { MessageBody: string } }).input.MessageBody)
|
|
312
|
+
.type === "MESSAGE_MOVE",
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
describe("SpamReportService.reportSpam", () => {
|
|
316
|
+
it("sets the blocked flag and enqueues the move to Junk", async () => {
|
|
317
|
+
const { service, messages, addresses, sent, markerPuts } = buildWorld();
|
|
318
|
+
|
|
319
|
+
await service.reportSpam({
|
|
320
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
321
|
+
accountId: ACCOUNT,
|
|
322
|
+
messageId: MESSAGE_ID,
|
|
323
|
+
setBy: "user-1",
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
const address = addresses.get(ADDRESS_ID);
|
|
327
|
+
assert.equal(address?.flags.blocked?.value, true);
|
|
328
|
+
assert.equal(address?.flags.blocked?.setBy, "user-1");
|
|
329
|
+
|
|
330
|
+
const message = messages.get(MESSAGE_ID);
|
|
331
|
+
assert.equal(message?.mailboxId, JUNK_MAILBOX);
|
|
332
|
+
assert.equal(moveEvents(sent).length, 1);
|
|
333
|
+
|
|
334
|
+
assert.equal(markerPuts.length, 1);
|
|
335
|
+
assert.equal(markerPuts[0].flagName, "$Junk");
|
|
336
|
+
assert.equal(markerPuts[0].operation, "add");
|
|
337
|
+
|
|
338
|
+
assert.ok(message !== undefined);
|
|
339
|
+
const spamReport = message.spamReport as { reportedAt: number };
|
|
340
|
+
assert.ok(spamReport.reportedAt > 0);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("leaves the blocked flag in place when the move fails", async () => {
|
|
344
|
+
const world = buildWorld();
|
|
345
|
+
world.sqsImpl.send = async () => {
|
|
346
|
+
throw new Error("SQS unavailable");
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
await assert.rejects(
|
|
350
|
+
() =>
|
|
351
|
+
world.service.reportSpam({
|
|
352
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
353
|
+
accountId: ACCOUNT,
|
|
354
|
+
messageId: MESSAGE_ID,
|
|
355
|
+
}),
|
|
356
|
+
/SQS unavailable/,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const address = world.addresses.get(ADDRESS_ID);
|
|
360
|
+
assert.equal(address?.flags.blocked?.value, true);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("is idempotent under a double press", async () => {
|
|
364
|
+
const { service, sent } = buildWorld();
|
|
365
|
+
|
|
366
|
+
await service.reportSpam({
|
|
367
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
368
|
+
accountId: ACCOUNT,
|
|
369
|
+
messageId: MESSAGE_ID,
|
|
370
|
+
});
|
|
371
|
+
await service.reportSpam({
|
|
372
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
373
|
+
accountId: ACCOUNT,
|
|
374
|
+
messageId: MESSAGE_ID,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// The second call's move is a no-op: MessageMoveService.moveMessage sees
|
|
378
|
+
// the local mailboxId already equals Junk and skips without enqueueing.
|
|
379
|
+
assert.equal(moveEvents(sent).length, 1);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("moves the message but writes no sender block when the message is forged from the account's own address", async () => {
|
|
383
|
+
const { service, messages, addresses, sent } = buildWorld({
|
|
384
|
+
fromEmail: ACCOUNT_EMAIL,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
await service.reportSpam({
|
|
388
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
389
|
+
accountId: ACCOUNT,
|
|
390
|
+
messageId: MESSAGE_ID,
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const address = addresses.get(ADDRESS_ID);
|
|
394
|
+
assert.equal(address?.flags.blocked, undefined);
|
|
395
|
+
|
|
396
|
+
const message = messages.get(MESSAGE_ID);
|
|
397
|
+
assert.equal(message?.mailboxId, JUNK_MAILBOX);
|
|
398
|
+
assert.equal(moveEvents(sent).length, 1);
|
|
399
|
+
assert.ok(message !== undefined);
|
|
400
|
+
assert.ok((message.spamReport as { reportedAt: number }).reportedAt > 0);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
describe("SpamReportService.notSpam", () => {
|
|
405
|
+
it("restores the original mailbox and clears the flag without setting trust", async () => {
|
|
406
|
+
const { service, messages, addresses } = buildWorld();
|
|
407
|
+
|
|
408
|
+
await service.reportSpam({
|
|
409
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
410
|
+
accountId: ACCOUNT,
|
|
411
|
+
messageId: MESSAGE_ID,
|
|
412
|
+
});
|
|
413
|
+
settleMove(messages);
|
|
414
|
+
await service.notSpam({
|
|
415
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
416
|
+
accountId: ACCOUNT,
|
|
417
|
+
messageId: MESSAGE_ID,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const message = messages.get(MESSAGE_ID);
|
|
421
|
+
assert.equal(message?.mailboxId, INBOX_MAILBOX);
|
|
422
|
+
assert.equal(message?.spamReport, undefined);
|
|
423
|
+
|
|
424
|
+
const address = addresses.get(ADDRESS_ID);
|
|
425
|
+
assert.equal(address?.flags.blocked, undefined);
|
|
426
|
+
assert.equal(address?.flags.wellknown, undefined);
|
|
427
|
+
assert.equal(address?.flags.trusted, undefined);
|
|
428
|
+
assert.equal(address?.flags.vip, undefined);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("clears the block and provenance without a 500 when the message never actually moved (same-mailbox no-op)", async () => {
|
|
432
|
+
// report-spam pressed on a message the provider's filter already placed
|
|
433
|
+
// in Junk: MessageMoveService.moveMessage's same-mailbox guard skips, so
|
|
434
|
+
// originalMailboxId is never set — status never becomes "moving" either,
|
|
435
|
+
// so notSpam has nothing to wait on.
|
|
436
|
+
const { service, messages, addresses } = buildWorld({
|
|
437
|
+
startMailbox: JUNK_MAILBOX,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
await service.reportSpam({
|
|
441
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
442
|
+
accountId: ACCOUNT,
|
|
443
|
+
messageId: MESSAGE_ID,
|
|
444
|
+
});
|
|
445
|
+
await service.notSpam({
|
|
446
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
447
|
+
accountId: ACCOUNT,
|
|
448
|
+
messageId: MESSAGE_ID,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const message = messages.get(MESSAGE_ID);
|
|
452
|
+
assert.equal(message?.mailboxId, JUNK_MAILBOX, "left where it is");
|
|
453
|
+
assert.equal(message?.spamReport, undefined);
|
|
454
|
+
|
|
455
|
+
const address = addresses.get(ADDRESS_ID);
|
|
456
|
+
assert.equal(address?.flags.blocked, undefined);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it("clears a stale originalMailboxId left by an earlier, unrelated move instead of restoring to it", async () => {
|
|
460
|
+
// The message is already in Junk (an earlier, unrelated move put it
|
|
461
|
+
// there) and still carries that move's originalMailboxId. report-spam's
|
|
462
|
+
// own move is a same-mailbox no-op — moveMessage never touches
|
|
463
|
+
// originalMailboxId — so without an explicit clear, notSpam would
|
|
464
|
+
// restore to a folder this report-spam action never moved it out of.
|
|
465
|
+
const OTHER_MAILBOX = "mbx-other";
|
|
466
|
+
const { service, messages, addresses } = buildWorld({
|
|
467
|
+
startMailbox: JUNK_MAILBOX,
|
|
468
|
+
originalMailboxId: OTHER_MAILBOX,
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
await service.reportSpam({
|
|
472
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
473
|
+
accountId: ACCOUNT,
|
|
474
|
+
messageId: MESSAGE_ID,
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
assert.equal(messages.get(MESSAGE_ID)?.originalMailboxId, undefined);
|
|
478
|
+
|
|
479
|
+
await service.notSpam({
|
|
480
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
481
|
+
accountId: ACCOUNT,
|
|
482
|
+
messageId: MESSAGE_ID,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const message = messages.get(MESSAGE_ID);
|
|
486
|
+
assert.equal(message?.mailboxId, JUNK_MAILBOX, "left where it is");
|
|
487
|
+
assert.notEqual(message?.mailboxId, OTHER_MAILBOX);
|
|
488
|
+
|
|
489
|
+
const address = addresses.get(ADDRESS_ID);
|
|
490
|
+
assert.equal(address?.flags.blocked, undefined);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("does not clear originalMailboxId set by an earlier report-spam press — a second report then Undo still restores", async () => {
|
|
494
|
+
// The most common reason a message is already in Junk when reportSpam
|
|
495
|
+
// runs is a PREVIOUS reportSpam press, not some unrelated move — and
|
|
496
|
+
// that earlier press is exactly what owns the originalMailboxId Undo
|
|
497
|
+
// needs. A second press must not treat its own prior work as stale.
|
|
498
|
+
const { service, messages, sent } = buildWorld();
|
|
499
|
+
|
|
500
|
+
await service.reportSpam({
|
|
501
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
502
|
+
accountId: ACCOUNT,
|
|
503
|
+
messageId: MESSAGE_ID,
|
|
504
|
+
});
|
|
505
|
+
settleMove(messages);
|
|
506
|
+
|
|
507
|
+
await service.reportSpam({
|
|
508
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
509
|
+
accountId: ACCOUNT,
|
|
510
|
+
messageId: MESSAGE_ID,
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
assert.equal(
|
|
514
|
+
messages.get(MESSAGE_ID)?.originalMailboxId,
|
|
515
|
+
INBOX_MAILBOX,
|
|
516
|
+
"the second press must not destroy the first press's originalMailboxId",
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
await service.notSpam({
|
|
520
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
521
|
+
accountId: ACCOUNT,
|
|
522
|
+
messageId: MESSAGE_ID,
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
const message = messages.get(MESSAGE_ID);
|
|
526
|
+
assert.equal(
|
|
527
|
+
message?.mailboxId,
|
|
528
|
+
INBOX_MAILBOX,
|
|
529
|
+
"undo must actually restore the message, not leave it stuck in Junk",
|
|
530
|
+
);
|
|
531
|
+
// INBOX->Junk (press 1), Junk->INBOX (undo) — press 2 was a same-mailbox
|
|
532
|
+
// no-op and must not have enqueued a move of its own.
|
|
533
|
+
assert.equal(moveEvents(sent).length, 2);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("is idempotent under a double press — a second undo does not re-junk the message", async () => {
|
|
537
|
+
const { service, messages, sent } = buildWorld();
|
|
538
|
+
|
|
539
|
+
await service.reportSpam({
|
|
540
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
541
|
+
accountId: ACCOUNT,
|
|
542
|
+
messageId: MESSAGE_ID,
|
|
543
|
+
});
|
|
544
|
+
settleMove(messages);
|
|
545
|
+
await service.notSpam({
|
|
546
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
547
|
+
accountId: ACCOUNT,
|
|
548
|
+
messageId: MESSAGE_ID,
|
|
549
|
+
});
|
|
550
|
+
// originalMailboxId is already cleared, so the second call has nothing
|
|
551
|
+
// to wait on or restore — no need to settle again.
|
|
552
|
+
await service.notSpam({
|
|
553
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
554
|
+
accountId: ACCOUNT,
|
|
555
|
+
messageId: MESSAGE_ID,
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const message = messages.get(MESSAGE_ID);
|
|
559
|
+
assert.equal(message?.mailboxId, INBOX_MAILBOX);
|
|
560
|
+
assert.equal(message?.originalMailboxId, undefined);
|
|
561
|
+
// INBOX->Junk (report), Junk->INBOX (undo #1) — undo #2 must not add a
|
|
562
|
+
// third INBOX->Junk move.
|
|
563
|
+
assert.equal(moveEvents(sent).length, 2);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("throws without restoring or clearing provenance when the move has not settled yet (R2 wait)", async () => {
|
|
567
|
+
const { service, messages, addresses } = buildWorld();
|
|
568
|
+
|
|
569
|
+
await service.reportSpam({
|
|
570
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
571
|
+
accountId: ACCOUNT,
|
|
572
|
+
messageId: MESSAGE_ID,
|
|
573
|
+
});
|
|
574
|
+
// Deliberately NOT settled: status stays "moving", as it would while
|
|
575
|
+
// the original MESSAGE_MOVE is still genuinely in flight or retrying.
|
|
576
|
+
|
|
577
|
+
await assert.rejects(
|
|
578
|
+
() =>
|
|
579
|
+
service.notSpam({
|
|
580
|
+
accountConfigId: ACCOUNT_CONFIG,
|
|
581
|
+
accountId: ACCOUNT,
|
|
582
|
+
messageId: MESSAGE_ID,
|
|
583
|
+
}),
|
|
584
|
+
(error: unknown) => {
|
|
585
|
+
// Must be the dedicated type, not a plain Error — callers that
|
|
586
|
+
// surface failures to the user (packages/backend) allowlist this
|
|
587
|
+
// specific type rather than relaying an arbitrary message.
|
|
588
|
+
assert.ok(error instanceof MoveNotSettledError);
|
|
589
|
+
assert.match(error.message, /has not settled yet/);
|
|
590
|
+
return true;
|
|
591
|
+
},
|
|
592
|
+
);
|
|
593
|
+
|
|
594
|
+
// The sender block clear is independent of the move (R2 reconcile) and
|
|
595
|
+
// still lands even though the restore did not.
|
|
596
|
+
const address = addresses.get(ADDRESS_ID);
|
|
597
|
+
assert.equal(address?.flags.blocked, undefined);
|
|
598
|
+
|
|
599
|
+
// But nothing move-related was touched — a real move #2 must not be
|
|
600
|
+
// enqueued against an unsettled move #1.
|
|
601
|
+
const message = messages.get(MESSAGE_ID);
|
|
602
|
+
assert.equal(message?.mailboxId, JUNK_MAILBOX);
|
|
603
|
+
assert.ok(message !== undefined);
|
|
604
|
+
assert.ok((message.spamReport as { reportedAt: number }).reportedAt > 0);
|
|
605
|
+
});
|
|
606
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IAccountRepository,
|
|
3
|
+
IAddressRepository,
|
|
4
|
+
IMailboxSpecialUseRepository,
|
|
5
|
+
IMessageRepository,
|
|
6
|
+
MessageItem,
|
|
7
|
+
} from "@remit/data-ports";
|
|
8
|
+
import {
|
|
9
|
+
AddressRole,
|
|
10
|
+
MessageKeywordFlag,
|
|
11
|
+
MessageStatus,
|
|
12
|
+
} from "@remit/domain-enums";
|
|
13
|
+
import type { FlagPushService } from "./flag-push.js";
|
|
14
|
+
import type { MessageMoveService } from "./message-move.js";
|
|
15
|
+
|
|
16
|
+
export interface SpamReportLogger {
|
|
17
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
18
|
+
error(obj: Record<string, unknown>, msg: string): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The one designed, user-facing outcome of `notSpam`'s R2 wait timing out —
|
|
23
|
+
* distinct from any other failure so a caller surfacing failures to the user
|
|
24
|
+
* (the bulk handlers in packages/backend) can allowlist this specific,
|
|
25
|
+
* pre-written message instead of relaying an arbitrary thrown error's text.
|
|
26
|
+
*/
|
|
27
|
+
export class MoveNotSettledError extends Error {
|
|
28
|
+
constructor(messageId: string) {
|
|
29
|
+
super(
|
|
30
|
+
`Message ${messageId}'s move to Junk has not settled yet; try again in a moment.`,
|
|
31
|
+
);
|
|
32
|
+
this.name = "MoveNotSettledError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const noopLogger: SpamReportLogger = {
|
|
37
|
+
info: () => {},
|
|
38
|
+
error: () => {},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const DEFAULT_MOVE_SETTLE_TIMEOUT_MS = 5_000;
|
|
42
|
+
const DEFAULT_MOVE_SETTLE_POLL_MS = 250;
|
|
43
|
+
|
|
44
|
+
export interface SpamReportConfig {
|
|
45
|
+
messageService: IMessageRepository;
|
|
46
|
+
addressService: IAddressRepository;
|
|
47
|
+
accountService: IAccountRepository;
|
|
48
|
+
mailboxSpecialUseService: IMailboxSpecialUseRepository;
|
|
49
|
+
messageMoveService: MessageMoveService;
|
|
50
|
+
flagPushService: FlagPushService;
|
|
51
|
+
logger?: SpamReportLogger;
|
|
52
|
+
/** How long `notSpam` waits for a still-in-flight move before giving up. */
|
|
53
|
+
moveSettleTimeoutMs?: number;
|
|
54
|
+
moveSettlePollMs?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SpamReportParams {
|
|
58
|
+
accountConfigId: string;
|
|
59
|
+
accountId: string;
|
|
60
|
+
messageId: string;
|
|
61
|
+
setBy?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class SpamReportService {
|
|
65
|
+
private messageService: IMessageRepository;
|
|
66
|
+
private addressService: IAddressRepository;
|
|
67
|
+
private accountService: IAccountRepository;
|
|
68
|
+
private mailboxSpecialUseService: IMailboxSpecialUseRepository;
|
|
69
|
+
private messageMoveService: MessageMoveService;
|
|
70
|
+
private flagPushService: FlagPushService;
|
|
71
|
+
private log: SpamReportLogger;
|
|
72
|
+
private moveSettleTimeoutMs: number;
|
|
73
|
+
private moveSettlePollMs: number;
|
|
74
|
+
|
|
75
|
+
constructor(config: SpamReportConfig) {
|
|
76
|
+
this.messageService = config.messageService;
|
|
77
|
+
this.addressService = config.addressService;
|
|
78
|
+
this.accountService = config.accountService;
|
|
79
|
+
this.mailboxSpecialUseService = config.mailboxSpecialUseService;
|
|
80
|
+
this.messageMoveService = config.messageMoveService;
|
|
81
|
+
this.flagPushService = config.flagPushService;
|
|
82
|
+
this.log = config.logger ?? noopLogger;
|
|
83
|
+
this.moveSettleTimeoutMs =
|
|
84
|
+
config.moveSettleTimeoutMs ?? DEFAULT_MOVE_SETTLE_TIMEOUT_MS;
|
|
85
|
+
this.moveSettlePollMs =
|
|
86
|
+
config.moveSettlePollMs ?? DEFAULT_MOVE_SETTLE_POLL_MS;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private resolveFromAddress = async (
|
|
90
|
+
messageId: string,
|
|
91
|
+
): Promise<{ addressId: string; normalizedEmail: string }> => {
|
|
92
|
+
const description = await this.messageService.describe(messageId);
|
|
93
|
+
const from = description.envelopeAddress.find(
|
|
94
|
+
(a) => a.addressRole === AddressRole.From,
|
|
95
|
+
);
|
|
96
|
+
if (!from) {
|
|
97
|
+
throw new Error(`Message ${messageId} has no From address to act on`);
|
|
98
|
+
}
|
|
99
|
+
return { addressId: from.addressId, normalizedEmail: from.normalizedEmail };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* R2 wait (docs/architecture/imap-mutations.md): `notSpam`'s restore is a
|
|
104
|
+
* dependent write against report-spam's own move — enqueuing it while that
|
|
105
|
+
* move is still in flight (`status === moving`) would carry the message's
|
|
106
|
+
* pre-move `uid` (only a CONFIRMED move updates it, via `updateUid`) and
|
|
107
|
+
* risk acting on the wrong server-side message once both moves are
|
|
108
|
+
* in-flight at once. Cheap to block per the doc's default guidance: a move
|
|
109
|
+
* ordinarily settles in well under a second. On timeout the dependent
|
|
110
|
+
* write is not made — the caller is told to retry, and retrying is safe
|
|
111
|
+
* (this whole flow is idempotent).
|
|
112
|
+
*/
|
|
113
|
+
private waitForMoveToSettle = async (
|
|
114
|
+
messageId: string,
|
|
115
|
+
): Promise<MessageItem> => {
|
|
116
|
+
const deadline = Date.now() + this.moveSettleTimeoutMs;
|
|
117
|
+
let message = await this.messageService.get(messageId);
|
|
118
|
+
while (message.status === MessageStatus.moving && Date.now() < deadline) {
|
|
119
|
+
await new Promise((resolve) =>
|
|
120
|
+
setTimeout(resolve, this.moveSettlePollMs),
|
|
121
|
+
);
|
|
122
|
+
message = await this.messageService.get(messageId);
|
|
123
|
+
}
|
|
124
|
+
return message;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
reportSpam = async (params: SpamReportParams): Promise<void> => {
|
|
128
|
+
const { accountConfigId, accountId, messageId, setBy } = params;
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
|
|
131
|
+
const from = await this.resolveFromAddress(messageId);
|
|
132
|
+
const account = await this.accountService.get(accountId);
|
|
133
|
+
const isOwnAddress =
|
|
134
|
+
from.normalizedEmail.toLowerCase() === account.email.toLowerCase();
|
|
135
|
+
|
|
136
|
+
if (!isOwnAddress) {
|
|
137
|
+
await this.addressService.mergeFlags(accountConfigId, from.addressId, {
|
|
138
|
+
blocked: { value: true, setAt: now, setBy },
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Captured before this call writes anything: `before.spamReport` tells
|
|
143
|
+
// apart the two reasons the message can already be in Junk. If it is
|
|
144
|
+
// already set, a PREVIOUS report-spam press put it there and owns
|
|
145
|
+
// `originalMailboxId` — that value is exactly what Undo needs and must
|
|
146
|
+
// survive a repeated press. Only when it is absent is the message in
|
|
147
|
+
// Junk for some unrelated reason (a provider filter, a manual move),
|
|
148
|
+
// making any `originalMailboxId` on the row stale.
|
|
149
|
+
const before = await this.messageService.get(messageId);
|
|
150
|
+
const hadSpamReportAlready = Boolean(before.spamReport);
|
|
151
|
+
|
|
152
|
+
await this.messageService.update(messageId, {
|
|
153
|
+
spamReport: { reportedAt: now },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const junkMailbox =
|
|
157
|
+
await this.mailboxSpecialUseService.findJunkMailbox(accountId);
|
|
158
|
+
if (!junkMailbox) {
|
|
159
|
+
throw new Error(`No Junk mailbox found for account ${accountId}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const alreadyInJunk = before.mailboxId === junkMailbox.mailboxId;
|
|
163
|
+
|
|
164
|
+
await this.messageMoveService.moveMessage(
|
|
165
|
+
accountConfigId,
|
|
166
|
+
messageId,
|
|
167
|
+
junkMailbox.mailboxId,
|
|
168
|
+
accountId,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
if (alreadyInJunk && before.originalMailboxId && !hadSpamReportAlready) {
|
|
172
|
+
// moveMessage's same-mailbox guard no-opped — it never touches
|
|
173
|
+
// originalMailboxId, so a stale value left by some earlier, unrelated
|
|
174
|
+
// move would otherwise survive and send a later notSpam to the wrong
|
|
175
|
+
// folder. This report-spam action established no move of its own.
|
|
176
|
+
await this.messageService.clearOriginalMailboxId(messageId);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await this.flagPushService.flip({
|
|
180
|
+
accountId,
|
|
181
|
+
accountConfigId,
|
|
182
|
+
messageId,
|
|
183
|
+
mailboxId: junkMailbox.mailboxId,
|
|
184
|
+
flagName: MessageKeywordFlag.Junk,
|
|
185
|
+
operation: "add",
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
this.log.info(
|
|
189
|
+
{
|
|
190
|
+
accountId,
|
|
191
|
+
messageId,
|
|
192
|
+
addressId: from.addressId,
|
|
193
|
+
junkMailboxId: junkMailbox.mailboxId,
|
|
194
|
+
skippedBlock: isOwnAddress,
|
|
195
|
+
},
|
|
196
|
+
"Reported message as spam",
|
|
197
|
+
);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
notSpam = async (params: SpamReportParams): Promise<void> => {
|
|
201
|
+
const { accountConfigId, accountId, messageId } = params;
|
|
202
|
+
|
|
203
|
+
const from = await this.resolveFromAddress(messageId);
|
|
204
|
+
await this.addressService.mergeFlags(accountConfigId, from.addressId, {
|
|
205
|
+
blocked: null,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const message = await this.messageService.get(messageId);
|
|
209
|
+
|
|
210
|
+
if (message.originalMailboxId) {
|
|
211
|
+
// Only wait on the move's settlement when there is actually a
|
|
212
|
+
// dependent write to make (the restore below) — a move in flight for
|
|
213
|
+
// an unrelated reason is not this operation's concern.
|
|
214
|
+
const settled = await this.waitForMoveToSettle(messageId);
|
|
215
|
+
if (settled.status === MessageStatus.moving) {
|
|
216
|
+
throw new MoveNotSettledError(messageId);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await this.messageMoveService.restoreMessage(
|
|
220
|
+
accountConfigId,
|
|
221
|
+
messageId,
|
|
222
|
+
accountId,
|
|
223
|
+
);
|
|
224
|
+
await this.messageService.clearOriginalMailboxId(messageId);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const current = await this.messageService.get(messageId);
|
|
228
|
+
|
|
229
|
+
await this.flagPushService.flip({
|
|
230
|
+
accountId,
|
|
231
|
+
accountConfigId,
|
|
232
|
+
messageId,
|
|
233
|
+
mailboxId: current.mailboxId,
|
|
234
|
+
flagName: MessageKeywordFlag.Junk,
|
|
235
|
+
operation: "remove",
|
|
236
|
+
});
|
|
237
|
+
await this.flagPushService.flip({
|
|
238
|
+
accountId,
|
|
239
|
+
accountConfigId,
|
|
240
|
+
messageId,
|
|
241
|
+
mailboxId: current.mailboxId,
|
|
242
|
+
flagName: MessageKeywordFlag.NotJunk,
|
|
243
|
+
operation: "add",
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await this.messageService.clearSpamReport(messageId);
|
|
247
|
+
|
|
248
|
+
this.log.info(
|
|
249
|
+
{ accountId, messageId, addressId: from.addressId },
|
|
250
|
+
"Undid spam report",
|
|
251
|
+
);
|
|
252
|
+
};
|
|
253
|
+
}
|