@remit/mailbox-service 0.0.110 → 0.0.112
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 +21 -6
- package/src/body-part-mapper.ts +51 -1
- package/src/body-sync-queue.test.ts +39 -99
- package/src/body-sync-terminal.test.ts +115 -277
- package/src/body-sync-terminal.ts +10 -12
- package/src/body-sync.test.ts +925 -0
- package/src/body-sync.ts +226 -200
- package/src/classification-backfill.test.ts +197 -527
- package/src/classify-message.test.ts +11 -74
- package/src/external-move.test.ts +102 -209
- package/src/external-move.ts +13 -0
- package/src/filter-operations.test.ts +319 -0
- package/src/filter-operations.ts +377 -0
- package/src/filter-update.test.ts +313 -0
- package/src/filters/filter-config.test.ts +55 -197
- package/src/filters/filter-config.ts +4 -39
- package/src/filters/match.test.ts +1 -38
- package/src/filters/match.ts +3 -52
- package/src/filters/pipeline.test.ts +158 -289
- package/src/filters/pipeline.ts +56 -170
- package/src/flag-push-terminal.ts +9 -11
- package/src/flag-push.test.ts +76 -372
- package/src/flag-queue.test.ts +197 -0
- package/src/folder-operations.test.ts +273 -0
- package/src/folder-operations.ts +217 -0
- package/src/forwarded-message.test.ts +122 -0
- package/src/imapflow-connect-retry.test.ts +76 -113
- package/src/imapflow-connection.test.ts +34 -0
- package/src/imapflow-connection.ts +19 -15
- package/src/index.ts +59 -12
- package/src/list-id-backfill.test.ts +133 -499
- package/src/mailbox-cursor.test.ts +81 -189
- package/src/mailbox-delete-intent.test.ts +78 -205
- package/src/mailbox-intent.ts +5 -3
- package/src/mailbox-management.test.ts +107 -220
- package/src/mailbox-management.ts +1 -2
- package/src/mailbox-presence.test.ts +36 -34
- package/src/mailbox-queue.ts +2 -20
- package/src/mailbox-rename-intent.test.ts +156 -325
- package/src/mailbox-rename-subtree.test.ts +205 -582
- package/src/mailbox-sync.test.ts +182 -744
- package/src/message-move.test.ts +726 -0
- package/src/message-move.ts +227 -57
- package/src/message-read.ts +87 -0
- package/src/message-save-retry.test.ts +90 -0
- package/src/message-sync-flags.test.ts +10 -1
- package/src/message-sync.test.ts +762 -0
- package/src/message-sync.ts +409 -239
- package/src/mime-walker.ts +16 -15
- package/src/organize-job.ts +93 -0
- package/src/outbox-attachment.test.ts +97 -209
- package/src/outbox-failed-recovery.test.ts +198 -269
- package/src/outbox-queue.test.ts +24 -0
- package/src/outbox-queue.ts +25 -8
- package/src/ownership.ts +140 -0
- package/src/placement-move-terminal.ts +9 -12
- package/src/placement-move.ts +33 -6
- package/src/placement-settled.ts +28 -4
- package/src/placement.test.ts +404 -0
- package/src/quarantine.test.ts +90 -148
- package/src/quarantine.ts +3 -2
- package/src/refusal.ts +29 -0
- package/src/restore-source-placement.ts +0 -4
- package/src/spam-report.test.ts +238 -710
- package/src/sync-warning.test.ts +213 -0
- package/src/sync-warning.ts +56 -0
- package/src/test-helpers/fake-imap.ts +690 -0
- package/src/test-helpers/test-queue.ts +56 -0
- package/src/test-helpers/test-store.ts +299 -0
- package/src/thread-root.test.ts +29 -0
- package/src/thread-root.ts +51 -0
- package/src/types/mailparser-augment.d.ts +8 -0
- package/src/types.ts +1 -1
- package/src/adversarial-mime.e2e.test.ts +0 -307
- package/src/body-sync-auto-read-writeonce.test.ts +0 -219
- package/src/body-sync-calendar-suggestion.test.ts +0 -433
- package/src/body-sync-category-denormalize.test.ts +0 -352
- package/src/body-sync-category-override.test.ts +0 -501
- package/src/body-sync-category-writeonce.test.ts +0 -234
- package/src/body-sync-filter.test.ts +0 -237
- package/src/body-sync-placement-flags.test.ts +0 -347
- package/src/body-sync-placement-writeonce.test.ts +0 -337
- package/src/body-sync-quarantine.test.ts +0 -276
- package/src/body-sync-resync.test.ts +0 -605
- package/src/body-sync-unclassifiable-once.test.ts +0 -260
- package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
- package/src/filters/anchor-drift.ts +0 -55
- package/src/filters/message-embedder.test.ts +0 -39
- package/src/filters/message-embedder.ts +0 -33
- package/src/flag-push-terminal.test.ts +0 -209
- package/src/flag-queue-stars.test.ts +0 -171
- package/src/imapflow-connection.e2e.test.ts +0 -335
- package/src/imapflow-connection.integ.test.ts +0 -316
- package/src/message-delete-junk-reconcile.test.ts +0 -117
- package/src/message-delete-no-trash.test.ts +0 -245
- package/src/message-delete-unsettled-placement.test.ts +0 -396
- package/src/message-move-copy.test.ts +0 -562
- package/src/message-move-empty-trash.test.ts +0 -326
- package/src/message-move-same-mailbox.test.ts +0 -139
- package/src/message-move-unsettled-placement.test.ts +0 -378
- package/src/message-sync-address-spoof.test.ts +0 -293
- package/src/message-sync-category.test.ts +0 -142
- package/src/message-sync-changedsince.test.ts +0 -840
- package/src/message-sync-junk-harvest.test.ts +0 -244
- package/src/message-sync-quarantine.test.ts +0 -416
- package/src/message-sync-repoint.test.ts +0 -419
- package/src/outbox-queue-settled-row.test.ts +0 -140
- package/src/pass-through-unit-of-work.ts +0 -17
- package/src/placement-move-terminal.test.ts +0 -300
- package/src/placement-move-unsettled.test.ts +0 -275
- package/src/placement-transition-race.test.ts +0 -482
- package/src/stale-message-reconcile.test.ts +0 -82
- package/src/stale-message-reconcile.ts +0 -55
- package/src/test-helpers/isolated-mailbox.ts +0 -44
- package/src/test-helpers/mailfuzz-connection.ts +0 -27
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
package/src/spam-report.test.ts
CHANGED
|
@@ -1,18 +1,9 @@
|
|
|
1
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";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import type { AccountItem, MailboxItem } from "@remit/data-ports";
|
|
13
4
|
import { MessagePlacementUnsettledError } from "@remit/data-ports/errors";
|
|
14
5
|
import { deriveAddressId } from "@remit/data-ports/id";
|
|
15
|
-
import { AddressRole } from "@remit/domain-enums";
|
|
6
|
+
import { AddressRole, MessageKeywordFlag } from "@remit/domain-enums";
|
|
16
7
|
import { FlagPushService } from "./flag-push.js";
|
|
17
8
|
import { MessageMoveService } from "./message-move.js";
|
|
18
9
|
import {
|
|
@@ -20,782 +11,319 @@ import {
|
|
|
20
11
|
NoJunkMailboxError,
|
|
21
12
|
SpamReportService,
|
|
22
13
|
} from "./spam-report.js";
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
m.syncStatus = "synced";
|
|
63
|
-
};
|
|
14
|
+
import { eml, FakeImapServer } from "./test-helpers/fake-imap.js";
|
|
15
|
+
import {
|
|
16
|
+
arriveAt,
|
|
17
|
+
captureQueue,
|
|
18
|
+
openStore,
|
|
19
|
+
type QueueCapture,
|
|
20
|
+
type QueuedEvent,
|
|
21
|
+
type Store,
|
|
22
|
+
seedAccount,
|
|
23
|
+
seedMailbox,
|
|
24
|
+
seedMessage,
|
|
25
|
+
} from "./test-helpers/test-store.js";
|
|
26
|
+
|
|
27
|
+
const SENDER = "sender@example.com";
|
|
28
|
+
|
|
29
|
+
let store: Store;
|
|
30
|
+
let capture: QueueCapture;
|
|
31
|
+
let queue: QueuedEvent[];
|
|
32
|
+
let imap: FakeImapServer;
|
|
33
|
+
let account: AccountItem;
|
|
34
|
+
let inbox: MailboxItem;
|
|
35
|
+
let junk: MailboxItem | undefined;
|
|
36
|
+
let other: MailboxItem;
|
|
37
|
+
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
capture = captureQueue();
|
|
40
|
+
queue = capture.sent;
|
|
41
|
+
store = await openStore();
|
|
42
|
+
imap = new FakeImapServer();
|
|
43
|
+
account = await seedAccount(store);
|
|
44
|
+
inbox = await seedMailbox(store, account, "INBOX");
|
|
45
|
+
other = await seedMailbox(store, account, "Other");
|
|
46
|
+
junk = await seedMailbox(store, account, "Junk", { specialUse: "Junk" });
|
|
47
|
+
for (const mailbox of await store.mailbox.listAllByAccount(
|
|
48
|
+
account.accountId,
|
|
49
|
+
)) {
|
|
50
|
+
imap.addFolder(mailbox.fullPath);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
64
53
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
originalMailboxId?: string;
|
|
70
|
-
/** Whether the sender was ever harvested into an `address` row. */
|
|
71
|
-
harvestedSender?: boolean;
|
|
72
|
-
/** Flags already standing on the harvested sender's row. */
|
|
73
|
-
senderFlags?: Record<string, unknown>;
|
|
74
|
-
junkMailbox?: { mailboxId: string; fullPath: string } | null;
|
|
75
|
-
} = {},
|
|
76
|
-
): World => {
|
|
77
|
-
const startMailbox = opts.startMailbox ?? INBOX_MAILBOX;
|
|
78
|
-
const fromEmail = opts.fromEmail ?? SENDER_EMAIL;
|
|
79
|
-
const fromAddressId = deriveAddressId(ACCOUNT_CONFIG, fromEmail);
|
|
80
|
-
const harvestedSender = opts.harvestedSender ?? true;
|
|
81
|
-
const junkMailbox =
|
|
82
|
-
opts.junkMailbox === undefined
|
|
83
|
-
? { mailboxId: JUNK_MAILBOX, fullPath: "Junk" }
|
|
84
|
-
: opts.junkMailbox;
|
|
85
|
-
|
|
86
|
-
const messages = new Map<string, Record<string, unknown>>([
|
|
87
|
-
[
|
|
88
|
-
MESSAGE_ID,
|
|
89
|
-
{
|
|
90
|
-
messageId: MESSAGE_ID,
|
|
91
|
-
mailboxId: startMailbox,
|
|
92
|
-
uid: 42,
|
|
93
|
-
rfc822Size: 100,
|
|
94
|
-
internalDate: 1_700_000_000_000,
|
|
95
|
-
envelopeId: "env-1",
|
|
96
|
-
rootBodyPartId: "body-1",
|
|
97
|
-
category: "primary",
|
|
98
|
-
hasListUnsubscribe: false,
|
|
99
|
-
syncStatus: "synced",
|
|
100
|
-
...(opts.originalMailboxId
|
|
101
|
-
? { originalMailboxId: opts.originalMailboxId }
|
|
102
|
-
: {}),
|
|
103
|
-
},
|
|
104
|
-
],
|
|
105
|
-
]);
|
|
106
|
-
|
|
107
|
-
const addresses = new Map<string, { flags: AddressFlags }>(
|
|
108
|
-
harvestedSender
|
|
109
|
-
? [[fromAddressId, { flags: (opts.senderFlags ?? {}) as AddressFlags }]]
|
|
110
|
-
: [],
|
|
111
|
-
);
|
|
54
|
+
afterEach(async () => {
|
|
55
|
+
capture.restore();
|
|
56
|
+
await store.close();
|
|
57
|
+
});
|
|
112
58
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
isRead: false,
|
|
122
|
-
hasStars: false,
|
|
123
|
-
},
|
|
124
|
-
];
|
|
125
|
-
|
|
126
|
-
const mailboxes = new Map<string, Record<string, unknown>>([
|
|
127
|
-
[
|
|
128
|
-
INBOX_MAILBOX,
|
|
129
|
-
{ mailboxId: INBOX_MAILBOX, fullPath: "INBOX", accountId: ACCOUNT },
|
|
130
|
-
],
|
|
131
|
-
[
|
|
132
|
-
JUNK_MAILBOX,
|
|
133
|
-
{ mailboxId: JUNK_MAILBOX, fullPath: "Junk", accountId: ACCOUNT },
|
|
134
|
-
],
|
|
135
|
-
]);
|
|
136
|
-
|
|
137
|
-
const messageService = {
|
|
138
|
-
get: async (id: string | string[]) => {
|
|
139
|
-
if (Array.isArray(id)) {
|
|
140
|
-
return id.map((i) => messages.get(i)).filter(Boolean);
|
|
141
|
-
}
|
|
142
|
-
const m = messages.get(id);
|
|
143
|
-
if (!m) throw new Error(`no message ${id}`);
|
|
144
|
-
return m;
|
|
145
|
-
},
|
|
146
|
-
describe: async (id: string) => {
|
|
147
|
-
const m = messages.get(id);
|
|
148
|
-
if (!m) throw new Error(`no message ${id}`);
|
|
149
|
-
return {
|
|
150
|
-
message: [m],
|
|
151
|
-
messageFlag: [],
|
|
152
|
-
envelope: [],
|
|
153
|
-
messageReference: [],
|
|
154
|
-
envelopeAddress: [
|
|
155
|
-
{
|
|
156
|
-
envelopeAddressId: "ea-1",
|
|
157
|
-
messageId: id,
|
|
158
|
-
addressId: fromAddressId,
|
|
159
|
-
displayName: "Spammy Sender",
|
|
160
|
-
normalizedEmail: fromEmail,
|
|
161
|
-
addressRole: AddressRole.From,
|
|
162
|
-
addressOrder: 0,
|
|
163
|
-
},
|
|
164
|
-
],
|
|
165
|
-
bodyPart: [],
|
|
166
|
-
bodyPartParameter: [],
|
|
167
|
-
rawMessageStorage: [],
|
|
168
|
-
bodyPartStorage: [],
|
|
169
|
-
bodyPartContent: [],
|
|
170
|
-
};
|
|
171
|
-
},
|
|
172
|
-
update: async (id: string, patch: Record<string, unknown>) => {
|
|
173
|
-
const m = messages.get(id);
|
|
174
|
-
if (m) Object.assign(m, patch);
|
|
175
|
-
return m;
|
|
176
|
-
},
|
|
177
|
-
transitionPlacement: async (
|
|
178
|
-
id: string,
|
|
179
|
-
_expected: Record<string, unknown>,
|
|
180
|
-
patch: Record<string, unknown>,
|
|
181
|
-
) => {
|
|
182
|
-
const m = messages.get(id);
|
|
183
|
-
if (!m) return undefined;
|
|
184
|
-
for (const [field, value] of Object.entries(patch)) {
|
|
185
|
-
if (value === null) {
|
|
186
|
-
delete m[field as keyof typeof m];
|
|
187
|
-
continue;
|
|
188
|
-
}
|
|
189
|
-
Object.assign(m, { [field]: value });
|
|
190
|
-
}
|
|
191
|
-
return m;
|
|
192
|
-
},
|
|
193
|
-
clearSpamReport: async (id: string) => {
|
|
194
|
-
const m = messages.get(id);
|
|
195
|
-
if (m) delete m.spamReport;
|
|
196
|
-
return m;
|
|
197
|
-
},
|
|
198
|
-
} as unknown as IMessageRepository;
|
|
199
|
-
|
|
200
|
-
const addressService = {
|
|
201
|
-
getAddress: async (_accountConfigId: string, addressIds: string[]) =>
|
|
202
|
-
addressIds
|
|
203
|
-
.filter((id) => addresses.has(id))
|
|
204
|
-
.map((id) => ({ addressId: id, ...addresses.get(id) })),
|
|
205
|
-
// Deliberately destructive on conflict, mirroring the real repo's
|
|
206
|
-
// `onConflictDoUpdate`: a caller that upserts over a row it did not
|
|
207
|
-
// create loses what stood on it.
|
|
208
|
-
upsertAddress: async (input: { addressId: string }) => {
|
|
209
|
-
addresses.set(input.addressId, { flags: {} });
|
|
210
|
-
return { ...input, flags: {} };
|
|
211
|
-
},
|
|
212
|
-
reconcileJunkOnlyForMessage: async () => {},
|
|
213
|
-
mergeFlags: async (
|
|
214
|
-
_accountConfigId: string,
|
|
215
|
-
addressId: string,
|
|
216
|
-
patch: Record<string, unknown>,
|
|
217
|
-
) => {
|
|
218
|
-
const entry = addresses.get(addressId);
|
|
219
|
-
if (!entry) throw new Error(`no address ${addressId}`);
|
|
220
|
-
const next = { ...entry.flags } as Record<string, unknown>;
|
|
221
|
-
for (const [key, value] of Object.entries(patch)) {
|
|
222
|
-
if (value === undefined) continue;
|
|
223
|
-
if (value === null) {
|
|
224
|
-
delete next[key];
|
|
225
|
-
continue;
|
|
226
|
-
}
|
|
227
|
-
next[key] = value;
|
|
228
|
-
}
|
|
229
|
-
entry.flags = next as AddressFlags;
|
|
230
|
-
return { addressId, flags: entry.flags };
|
|
231
|
-
},
|
|
232
|
-
} as unknown as IAddressRepository;
|
|
233
|
-
|
|
234
|
-
const accountService = {
|
|
235
|
-
get: async () => ({ accountId: ACCOUNT, email: ACCOUNT_EMAIL }),
|
|
236
|
-
} as unknown as IAccountRepository;
|
|
237
|
-
|
|
238
|
-
const mailboxSpecialUseService = {
|
|
239
|
-
findJunkMailbox: async () => junkMailbox,
|
|
240
|
-
findTrashMailbox: async () => null,
|
|
241
|
-
resolveTrashRole: async () => trashRole(null),
|
|
242
|
-
resolveJunkRolesForConfig: async () => NO_JUNK_ROLES,
|
|
243
|
-
} as unknown as IMailboxSpecialUseRepository;
|
|
244
|
-
|
|
245
|
-
const mailboxService = {
|
|
246
|
-
get: async (_acc: string, id: string | string[]) => {
|
|
247
|
-
if (Array.isArray(id)) {
|
|
248
|
-
return id.map((i) => mailboxes.get(i)).filter(Boolean);
|
|
249
|
-
}
|
|
250
|
-
return mailboxes.get(id);
|
|
251
|
-
},
|
|
252
|
-
} as unknown as IMailboxRepository;
|
|
253
|
-
|
|
254
|
-
const threadMessageService = {
|
|
255
|
-
getByMessageId: async (_cfg: string, messageId: string) => {
|
|
256
|
-
const row = threadRows.find((r) => r.messageId === messageId);
|
|
257
|
-
if (!row) throw new Error(`no thread message ${messageId}`);
|
|
258
|
-
return row;
|
|
259
|
-
},
|
|
260
|
-
update: async (
|
|
261
|
-
_cfg: string,
|
|
262
|
-
threadMessageId: string,
|
|
263
|
-
patch: Record<string, unknown>,
|
|
264
|
-
) => {
|
|
265
|
-
const row = threadRows.find((r) => r.threadMessageId === threadMessageId);
|
|
266
|
-
if (row) Object.assign(row, patch);
|
|
267
|
-
return row;
|
|
268
|
-
},
|
|
269
|
-
} as unknown as IThreadMessageRepository;
|
|
270
|
-
|
|
271
|
-
const sent: unknown[] = [];
|
|
272
|
-
const sqsImpl = {
|
|
273
|
-
send: async (command: unknown) => {
|
|
274
|
-
sent.push(command);
|
|
275
|
-
return {};
|
|
276
|
-
},
|
|
277
|
-
};
|
|
278
|
-
|
|
279
|
-
const messageMoveService = new MessageMoveService({
|
|
280
|
-
messageService,
|
|
281
|
-
mailboxService,
|
|
282
|
-
mailboxSpecialUseService,
|
|
283
|
-
threadMessageService,
|
|
284
|
-
addressService,
|
|
285
|
-
sqsQueueUrl: "http://localhost:9324/000000000000/message-mgmt",
|
|
286
|
-
moveSettleTimeoutMs: 30,
|
|
287
|
-
moveSettlePollMs: 5,
|
|
288
|
-
});
|
|
289
|
-
(
|
|
290
|
-
messageMoveService as unknown as {
|
|
291
|
-
sqs: { send: (c: unknown) => Promise<unknown> };
|
|
292
|
-
}
|
|
293
|
-
).sqs = sqsImpl;
|
|
294
|
-
|
|
295
|
-
const markerPuts: Array<Record<string, unknown>> = [];
|
|
296
|
-
const markerService: IMessageFlagPushRepository = {
|
|
297
|
-
put: async (input: Record<string, unknown>) => {
|
|
298
|
-
markerPuts.push(input);
|
|
299
|
-
return {
|
|
300
|
-
...input,
|
|
301
|
-
state: "pending",
|
|
302
|
-
createdAt: 1,
|
|
303
|
-
updatedAt: 1,
|
|
304
|
-
} as never;
|
|
305
|
-
},
|
|
306
|
-
find: async () => null,
|
|
307
|
-
updateState: async () => ({}) as never,
|
|
308
|
-
delete: async () => {},
|
|
309
|
-
listByAccountId: async () => [],
|
|
310
|
-
listByMailboxId: async () => [],
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
const flagPushService = new FlagPushService({
|
|
314
|
-
markerService,
|
|
315
|
-
sqsQueueUrl: "http://localhost:9324/000000000000/flag-push",
|
|
59
|
+
const userMoves = () =>
|
|
60
|
+
new MessageMoveService({
|
|
61
|
+
messageService: store.message,
|
|
62
|
+
mailboxService: store.mailbox,
|
|
63
|
+
mailboxSpecialUseService: store.mailboxSpecialUse,
|
|
64
|
+
threadMessageService: store.threadMessage,
|
|
65
|
+
addressService: store.address,
|
|
66
|
+
sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
|
|
316
67
|
});
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
68
|
+
|
|
69
|
+
const spam = () =>
|
|
70
|
+
new SpamReportService({
|
|
71
|
+
messageService: store.message,
|
|
72
|
+
addressService: store.address,
|
|
73
|
+
accountService: store.account,
|
|
74
|
+
mailboxSpecialUseService: store.mailboxSpecialUse,
|
|
75
|
+
messageMoveService: new MessageMoveService({
|
|
76
|
+
messageService: store.message,
|
|
77
|
+
mailboxService: store.mailbox,
|
|
78
|
+
mailboxSpecialUseService: store.mailboxSpecialUse,
|
|
79
|
+
threadMessageService: store.threadMessage,
|
|
80
|
+
addressService: store.address,
|
|
81
|
+
sqsQueueUrl: "http://localhost:9324/000000000000/remit-messages.fifo",
|
|
82
|
+
moveSettleTimeoutMs: 30,
|
|
83
|
+
moveSettlePollMs: 5,
|
|
84
|
+
}),
|
|
85
|
+
flagPushService: new FlagPushService({
|
|
86
|
+
markerService: store.flagPush,
|
|
87
|
+
sqsQueueUrl: "https://sqs.test/flags",
|
|
88
|
+
}),
|
|
332
89
|
moveSettleTimeoutMs: 30,
|
|
333
90
|
moveSettlePollMs: 5,
|
|
334
91
|
});
|
|
335
92
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
messages,
|
|
339
|
-
addresses,
|
|
340
|
-
threadRows,
|
|
341
|
-
sent,
|
|
342
|
-
sqsImpl,
|
|
343
|
-
markerPuts,
|
|
344
|
-
};
|
|
345
|
-
};
|
|
93
|
+
const arrive = (mailbox: MailboxItem = inbox, from = `Sender <${SENDER}>`) =>
|
|
94
|
+
arriveAt(store, imap, account, mailbox, eml({ From: from }));
|
|
346
95
|
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
96
|
+
const params = (messageId: string) => ({
|
|
97
|
+
accountConfigId: account.accountConfigId,
|
|
98
|
+
accountId: account.accountId,
|
|
99
|
+
messageId,
|
|
100
|
+
setBy: "user-1",
|
|
101
|
+
});
|
|
353
102
|
|
|
354
|
-
|
|
355
|
-
it("sets the blocked flag and enqueues the move to Junk", async () => {
|
|
356
|
-
const { service, messages, addresses, sent, markerPuts } = buildWorld();
|
|
357
|
-
|
|
358
|
-
await service.reportSpam({
|
|
359
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
360
|
-
accountId: ACCOUNT,
|
|
361
|
-
messageId: MESSAGE_ID,
|
|
362
|
-
setBy: "user-1",
|
|
363
|
-
});
|
|
103
|
+
const report = (messageId: string) => spam().reportSpam(params(messageId));
|
|
364
104
|
|
|
365
|
-
|
|
366
|
-
assert.equal(address?.flags.blocked?.value, true);
|
|
367
|
-
assert.equal(address?.flags.blocked?.setBy, "user-1");
|
|
105
|
+
const undo = (messageId: string) => spam().notSpam(params(messageId));
|
|
368
106
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
107
|
+
const sender = (email = SENDER) =>
|
|
108
|
+
store.address.getAddress(
|
|
109
|
+
account.accountConfigId,
|
|
110
|
+
deriveAddressId(account.accountConfigId, email),
|
|
111
|
+
);
|
|
372
112
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
113
|
+
const settle = async (messageId: string) => {
|
|
114
|
+
const row = await store.message.get(messageId);
|
|
115
|
+
await store.message.updateUid(messageId, row.uid + 100, row.mailboxId);
|
|
116
|
+
};
|
|
376
117
|
|
|
377
|
-
|
|
378
|
-
const spamReport = message.spamReport as { reportedAt: number };
|
|
379
|
-
assert.ok(spamReport.reportedAt > 0);
|
|
380
|
-
});
|
|
118
|
+
const moves = () => queue.filter((event) => event.body.type === "MESSAGE_MOVE");
|
|
381
119
|
|
|
382
|
-
|
|
383
|
-
const world = buildWorld();
|
|
384
|
-
world.sqsImpl.send = async () => {
|
|
385
|
-
throw new Error("SQS unavailable");
|
|
386
|
-
};
|
|
120
|
+
const row = (messageId: string) => store.message.get(messageId);
|
|
387
121
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
122
|
+
describe("SpamReportService.reportSpam", () => {
|
|
123
|
+
it("blocks the sender, moves the message to Junk and owes the server $Junk", async () => {
|
|
124
|
+
const messageId = await arrive();
|
|
125
|
+
|
|
126
|
+
await report(messageId);
|
|
127
|
+
|
|
128
|
+
const blocked = (await sender()).flags.blocked;
|
|
129
|
+
assert.equal(blocked?.value, true);
|
|
130
|
+
assert.equal(blocked?.setBy, "user-1");
|
|
131
|
+
const moved = await row(messageId);
|
|
132
|
+
assert.equal(moved.mailboxId, junk?.mailboxId);
|
|
133
|
+
assert.ok((moved.spamReport?.reportedAt ?? 0) > 0);
|
|
134
|
+
assert.equal(moves().length, 1);
|
|
135
|
+
assert.equal(
|
|
136
|
+
(await store.flagPush.find(messageId, MessageKeywordFlag.Junk))
|
|
137
|
+
?.operation,
|
|
138
|
+
"add",
|
|
396
139
|
);
|
|
397
|
-
|
|
398
|
-
const address = world.addresses.get(ADDRESS_ID);
|
|
399
|
-
assert.equal(address?.flags.blocked?.value, true);
|
|
400
140
|
});
|
|
401
141
|
|
|
402
|
-
it("
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
407
|
-
accountId: ACCOUNT,
|
|
408
|
-
messageId: MESSAGE_ID,
|
|
409
|
-
});
|
|
410
|
-
settleMove(messages);
|
|
411
|
-
await service.reportSpam({
|
|
412
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
413
|
-
accountId: ACCOUNT,
|
|
414
|
-
messageId: MESSAGE_ID,
|
|
142
|
+
it("keeps the block when the move fails", async () => {
|
|
143
|
+
const messageId = await arrive();
|
|
144
|
+
capture.answer(async () => {
|
|
145
|
+
throw new Error("SQS unavailable");
|
|
415
146
|
});
|
|
416
147
|
|
|
417
|
-
|
|
418
|
-
// the local mailboxId already equals Junk and skips without enqueueing.
|
|
419
|
-
// The skip is read off the settled row — a press inside the first move's
|
|
420
|
-
// flight waits for it rather than answering from the folder that move
|
|
421
|
-
// wrote optimistically (#665).
|
|
422
|
-
assert.equal(moveEvents(sent).length, 1);
|
|
423
|
-
});
|
|
148
|
+
await assert.rejects(report(messageId), /SQS unavailable/);
|
|
424
149
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
// carrying the inbox's uid, so acting on it would address whatever Junk
|
|
428
|
-
// holds at that uid — somebody else's message (#665).
|
|
429
|
-
const { service, sent } = buildWorld();
|
|
150
|
+
assert.equal((await sender()).flags.blocked?.value, true);
|
|
151
|
+
});
|
|
430
152
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
messageId: MESSAGE_ID,
|
|
435
|
-
});
|
|
153
|
+
it("moves once under a double press, and refuses a press inside the first move's flight", async () => {
|
|
154
|
+
const messageId = await arrive();
|
|
155
|
+
await report(messageId);
|
|
436
156
|
|
|
437
157
|
await assert.rejects(
|
|
438
|
-
()
|
|
439
|
-
service.reportSpam({
|
|
440
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
441
|
-
accountId: ACCOUNT,
|
|
442
|
-
messageId: MESSAGE_ID,
|
|
443
|
-
}),
|
|
158
|
+
report(messageId),
|
|
444
159
|
(error: unknown) =>
|
|
445
160
|
error instanceof MessagePlacementUnsettledError &&
|
|
446
|
-
error.statusCode === 409 &&
|
|
447
161
|
error.publicApiError?.details?.reason === "in_flight",
|
|
448
162
|
);
|
|
163
|
+
await settle(messageId);
|
|
164
|
+
await report(messageId);
|
|
449
165
|
|
|
450
|
-
assert.equal(
|
|
166
|
+
assert.equal(moves().length, 1);
|
|
451
167
|
});
|
|
452
168
|
|
|
453
|
-
it("moves
|
|
454
|
-
const
|
|
455
|
-
fromEmail: ACCOUNT_EMAIL,
|
|
456
|
-
});
|
|
169
|
+
it("moves mail forged from the account's own address without blocking the account", async () => {
|
|
170
|
+
const messageId = await arrive(inbox, `Me <${account.email}>`);
|
|
457
171
|
|
|
458
|
-
await
|
|
459
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
460
|
-
accountId: ACCOUNT,
|
|
461
|
-
messageId: MESSAGE_ID,
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
const address = addresses.get(ADDRESS_ID);
|
|
465
|
-
assert.equal(address?.flags.blocked, undefined);
|
|
172
|
+
await report(messageId);
|
|
466
173
|
|
|
467
|
-
|
|
468
|
-
assert.equal(
|
|
469
|
-
assert.equal(moveEvents(sent).length, 1);
|
|
470
|
-
assert.ok(message !== undefined);
|
|
471
|
-
assert.ok((message.spamReport as { reportedAt: number }).reportedAt > 0);
|
|
174
|
+
assert.equal((await sender(account.email)).flags.blocked, undefined);
|
|
175
|
+
assert.equal((await row(messageId)).mailboxId, junk?.mailboxId);
|
|
472
176
|
});
|
|
473
177
|
|
|
474
|
-
it("
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const { service, addresses } = buildWorld({
|
|
480
|
-
harvestedSender: false,
|
|
481
|
-
fromEmail: "no-at-sign",
|
|
482
|
-
});
|
|
483
|
-
|
|
484
|
-
await assert.rejects(
|
|
485
|
-
() =>
|
|
486
|
-
service.reportSpam({
|
|
487
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
488
|
-
accountId: ACCOUNT,
|
|
489
|
-
messageId: MESSAGE_ID,
|
|
490
|
-
}),
|
|
491
|
-
/not a usable email address/,
|
|
178
|
+
it("blocks a sender that was never harvested into the address book", async () => {
|
|
179
|
+
const messageId = await arrive();
|
|
180
|
+
await store.address.deleteAddress(
|
|
181
|
+
account.accountConfigId,
|
|
182
|
+
deriveAddressId(account.accountConfigId, SENDER),
|
|
492
183
|
);
|
|
493
184
|
|
|
494
|
-
|
|
495
|
-
});
|
|
185
|
+
await report(messageId);
|
|
496
186
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
// throw NotFoundError and take the whole report down with it — the
|
|
501
|
-
// message never reached Junk and the user got a retry that could not
|
|
502
|
-
// work (test.remit.email, 18 Aug 2026).
|
|
503
|
-
const { service, messages, addresses, sent } = buildWorld({
|
|
504
|
-
harvestedSender: false,
|
|
505
|
-
});
|
|
187
|
+
assert.equal((await sender()).flags.blocked?.value, true);
|
|
188
|
+
assert.equal((await row(messageId)).mailboxId, junk?.mailboxId);
|
|
189
|
+
});
|
|
506
190
|
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
messageId:
|
|
511
|
-
|
|
191
|
+
it("refuses to mint an address row for a sender with no @", async () => {
|
|
192
|
+
const { message } = await seedMessage(store, account, inbox, 9);
|
|
193
|
+
await store.address.upsertEnvelopeAddress({
|
|
194
|
+
messageId: message.messageId,
|
|
195
|
+
addressId: deriveAddressId(account.accountConfigId, "no-at-sign"),
|
|
196
|
+
displayName: "",
|
|
197
|
+
normalizedEmail: "no-at-sign",
|
|
198
|
+
addressRole: AddressRole.From,
|
|
199
|
+
addressOrder: 0,
|
|
512
200
|
});
|
|
513
201
|
|
|
514
|
-
|
|
515
|
-
|
|
202
|
+
await assert.rejects(
|
|
203
|
+
report(message.messageId),
|
|
204
|
+
/not a usable email address/,
|
|
205
|
+
);
|
|
516
206
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
207
|
+
assert.deepEqual(
|
|
208
|
+
await store.address.getAddress(account.accountConfigId, [
|
|
209
|
+
deriveAddressId(account.accountConfigId, "no-at-sign"),
|
|
210
|
+
]),
|
|
211
|
+
[],
|
|
212
|
+
);
|
|
520
213
|
});
|
|
521
214
|
|
|
522
215
|
it("keeps the junkOnly mark that withholds the sender from autocomplete", async () => {
|
|
523
|
-
|
|
524
|
-
// must not carry an upsert's on-conflict behaviour onto a row it did
|
|
525
|
-
// not create. The mark that withholds a spammer from autocomplete (#822)
|
|
526
|
-
// lives in these same flags, and clearing it here would put the spammer
|
|
527
|
-
// back in the compose picker — the opposite of what the button means.
|
|
528
|
-
const { service, addresses } = buildWorld({
|
|
529
|
-
senderFlags: {
|
|
530
|
-
junkOnly: { value: true, setAt: 1, setBy: "junk-harvest" },
|
|
531
|
-
},
|
|
532
|
-
});
|
|
216
|
+
const messageId = await arrive(junk);
|
|
533
217
|
|
|
534
|
-
await
|
|
535
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
536
|
-
accountId: ACCOUNT,
|
|
537
|
-
messageId: MESSAGE_ID,
|
|
538
|
-
setBy: "user-1",
|
|
539
|
-
});
|
|
218
|
+
await report(messageId);
|
|
540
219
|
|
|
541
|
-
const flags =
|
|
542
|
-
assert.equal(
|
|
543
|
-
assert.
|
|
544
|
-
value: true,
|
|
545
|
-
setAt: 1,
|
|
546
|
-
setBy: "junk-harvest",
|
|
547
|
-
});
|
|
220
|
+
const flags = (await sender()).flags;
|
|
221
|
+
assert.equal(flags.blocked?.value, true);
|
|
222
|
+
assert.equal(flags.junkOnly?.value, true);
|
|
548
223
|
});
|
|
549
224
|
|
|
550
|
-
it("names the missing Junk folder and changes nothing
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
225
|
+
it("names the missing Junk folder and changes nothing", async () => {
|
|
226
|
+
await store.mailboxSpecialUse.deleteByMailboxId(junk?.mailboxId as string);
|
|
227
|
+
await store.mailbox.delete(account.accountId, junk?.mailboxId as string);
|
|
228
|
+
junk = undefined;
|
|
229
|
+
const messageId = await arrive();
|
|
554
230
|
|
|
555
231
|
await assert.rejects(
|
|
556
|
-
()
|
|
557
|
-
service.reportSpam({
|
|
558
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
559
|
-
accountId: ACCOUNT,
|
|
560
|
-
messageId: MESSAGE_ID,
|
|
561
|
-
}),
|
|
232
|
+
report(messageId),
|
|
562
233
|
(error: unknown) =>
|
|
563
234
|
error instanceof NoJunkMailboxError &&
|
|
564
235
|
/no Junk folder/.test(error.message) &&
|
|
565
236
|
/Create one/.test(error.message),
|
|
566
237
|
);
|
|
567
238
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
assert.equal(
|
|
571
|
-
|
|
572
|
-
assert.equal(
|
|
573
|
-
assert.equal(message?.mailboxId, INBOX_MAILBOX);
|
|
574
|
-
assert.equal(moveEvents(sent).length, 0);
|
|
575
|
-
assert.equal(markerPuts.length, 0);
|
|
239
|
+
assert.equal((await sender()).flags.blocked, undefined);
|
|
240
|
+
const untouched = await row(messageId);
|
|
241
|
+
assert.equal(untouched.mailboxId, inbox.mailboxId);
|
|
242
|
+
assert.equal(untouched.spamReport, undefined);
|
|
243
|
+
assert.equal(queue.length, 0);
|
|
576
244
|
});
|
|
577
245
|
});
|
|
578
246
|
|
|
579
247
|
describe("SpamReportService.notSpam", () => {
|
|
580
|
-
it("
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
await
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
});
|
|
595
|
-
|
|
596
|
-
assert.equal(addresses.size, 0);
|
|
597
|
-
assert.equal(messages.get(MESSAGE_ID)?.spamReport, undefined);
|
|
598
|
-
});
|
|
599
|
-
|
|
600
|
-
it("restores the original mailbox and clears the flag without setting trust", async () => {
|
|
601
|
-
const { service, messages, addresses } = buildWorld();
|
|
602
|
-
|
|
603
|
-
await service.reportSpam({
|
|
604
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
605
|
-
accountId: ACCOUNT,
|
|
606
|
-
messageId: MESSAGE_ID,
|
|
607
|
-
});
|
|
608
|
-
settleMove(messages);
|
|
609
|
-
await service.notSpam({
|
|
610
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
611
|
-
accountId: ACCOUNT,
|
|
612
|
-
messageId: MESSAGE_ID,
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
const message = messages.get(MESSAGE_ID);
|
|
616
|
-
assert.equal(message?.mailboxId, INBOX_MAILBOX);
|
|
617
|
-
assert.equal(message?.spamReport, undefined);
|
|
618
|
-
|
|
619
|
-
const address = addresses.get(ADDRESS_ID);
|
|
620
|
-
assert.equal(address?.flags.blocked, undefined);
|
|
621
|
-
assert.equal(address?.flags.wellknown, undefined);
|
|
622
|
-
assert.equal(address?.flags.trusted, undefined);
|
|
623
|
-
assert.equal(address?.flags.vip, undefined);
|
|
248
|
+
it("restores the original folder and lifts the block without granting trust", async () => {
|
|
249
|
+
const messageId = await arrive();
|
|
250
|
+
await report(messageId);
|
|
251
|
+
await settle(messageId);
|
|
252
|
+
|
|
253
|
+
await undo(messageId);
|
|
254
|
+
|
|
255
|
+
const restored = await row(messageId);
|
|
256
|
+
assert.equal(restored.mailboxId, inbox.mailboxId);
|
|
257
|
+
assert.equal(restored.spamReport, undefined);
|
|
258
|
+
const flags = (await sender()).flags;
|
|
259
|
+
assert.equal(flags.blocked, undefined);
|
|
260
|
+
assert.equal(flags.wellknown, undefined);
|
|
261
|
+
assert.equal(flags.vip, undefined);
|
|
624
262
|
});
|
|
625
263
|
|
|
626
|
-
it("
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
startMailbox: JUNK_MAILBOX,
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
await service.reportSpam({
|
|
636
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
637
|
-
accountId: ACCOUNT,
|
|
638
|
-
messageId: MESSAGE_ID,
|
|
639
|
-
});
|
|
640
|
-
await service.notSpam({
|
|
641
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
642
|
-
accountId: ACCOUNT,
|
|
643
|
-
messageId: MESSAGE_ID,
|
|
644
|
-
});
|
|
645
|
-
|
|
646
|
-
const message = messages.get(MESSAGE_ID);
|
|
647
|
-
assert.equal(message?.mailboxId, JUNK_MAILBOX, "left where it is");
|
|
648
|
-
assert.equal(message?.spamReport, undefined);
|
|
649
|
-
|
|
650
|
-
const address = addresses.get(ADDRESS_ID);
|
|
651
|
-
assert.equal(address?.flags.blocked, undefined);
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
it("clears a stale originalMailboxId left by an earlier, unrelated move instead of restoring to it", async () => {
|
|
655
|
-
// The message is already in Junk (an earlier, unrelated move put it
|
|
656
|
-
// there) and still carries that move's originalMailboxId. report-spam's
|
|
657
|
-
// own move is a same-mailbox no-op — moveMessage never touches
|
|
658
|
-
// originalMailboxId — so without an explicit clear, notSpam would
|
|
659
|
-
// restore to a folder this report-spam action never moved it out of.
|
|
660
|
-
const OTHER_MAILBOX = "mbx-other";
|
|
661
|
-
const { service, messages, addresses } = buildWorld({
|
|
662
|
-
startMailbox: JUNK_MAILBOX,
|
|
663
|
-
originalMailboxId: OTHER_MAILBOX,
|
|
664
|
-
});
|
|
665
|
-
|
|
666
|
-
await service.reportSpam({
|
|
667
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
668
|
-
accountId: ACCOUNT,
|
|
669
|
-
messageId: MESSAGE_ID,
|
|
670
|
-
});
|
|
671
|
-
|
|
672
|
-
assert.equal(messages.get(MESSAGE_ID)?.originalMailboxId, undefined);
|
|
673
|
-
|
|
674
|
-
await service.notSpam({
|
|
675
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
676
|
-
accountId: ACCOUNT,
|
|
677
|
-
messageId: MESSAGE_ID,
|
|
678
|
-
});
|
|
264
|
+
it("mints no address row for a sender that was never harvested", async () => {
|
|
265
|
+
const messageId = await arrive(junk);
|
|
266
|
+
await store.address.deleteAddress(
|
|
267
|
+
account.accountConfigId,
|
|
268
|
+
deriveAddressId(account.accountConfigId, SENDER),
|
|
269
|
+
);
|
|
679
270
|
|
|
680
|
-
|
|
681
|
-
assert.equal(message?.mailboxId, JUNK_MAILBOX, "left where it is");
|
|
682
|
-
assert.notEqual(message?.mailboxId, OTHER_MAILBOX);
|
|
271
|
+
await undo(messageId);
|
|
683
272
|
|
|
684
|
-
|
|
685
|
-
|
|
273
|
+
assert.deepEqual(
|
|
274
|
+
await store.address.getAddress(account.accountConfigId, [
|
|
275
|
+
deriveAddressId(account.accountConfigId, SENDER),
|
|
276
|
+
]),
|
|
277
|
+
[],
|
|
278
|
+
);
|
|
686
279
|
});
|
|
687
280
|
|
|
688
|
-
it("
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
await service.reportSpam({
|
|
696
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
697
|
-
accountId: ACCOUNT,
|
|
698
|
-
messageId: MESSAGE_ID,
|
|
699
|
-
});
|
|
700
|
-
settleMove(messages);
|
|
701
|
-
|
|
702
|
-
await service.reportSpam({
|
|
703
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
704
|
-
accountId: ACCOUNT,
|
|
705
|
-
messageId: MESSAGE_ID,
|
|
706
|
-
});
|
|
707
|
-
|
|
708
|
-
assert.equal(
|
|
709
|
-
messages.get(MESSAGE_ID)?.originalMailboxId,
|
|
710
|
-
INBOX_MAILBOX,
|
|
711
|
-
"the second press must not destroy the first press's originalMailboxId",
|
|
281
|
+
it("leaves mail an earlier, unrelated move filed in Junk where it is", async () => {
|
|
282
|
+
const messageId = await arrive(other);
|
|
283
|
+
await userMoves().moveMessage(
|
|
284
|
+
account.accountConfigId,
|
|
285
|
+
messageId,
|
|
286
|
+
junk?.mailboxId as string,
|
|
287
|
+
account.accountId,
|
|
712
288
|
);
|
|
289
|
+
await settle(messageId);
|
|
290
|
+
queue.length = 0;
|
|
713
291
|
|
|
714
|
-
await
|
|
715
|
-
|
|
716
|
-
accountId: ACCOUNT,
|
|
717
|
-
messageId: MESSAGE_ID,
|
|
718
|
-
});
|
|
292
|
+
await report(messageId);
|
|
293
|
+
await undo(messageId);
|
|
719
294
|
|
|
720
|
-
|
|
721
|
-
assert.equal(
|
|
722
|
-
message?.mailboxId,
|
|
723
|
-
INBOX_MAILBOX,
|
|
724
|
-
"undo must actually restore the message, not leave it stuck in Junk",
|
|
725
|
-
);
|
|
726
|
-
// INBOX->Junk (press 1), Junk->INBOX (undo) — press 2 was a same-mailbox
|
|
727
|
-
// no-op and must not have enqueued a move of its own.
|
|
728
|
-
assert.equal(moveEvents(sent).length, 2);
|
|
295
|
+
assert.equal((await row(messageId)).mailboxId, junk?.mailboxId);
|
|
296
|
+
assert.equal((await sender()).flags.blocked, undefined);
|
|
729
297
|
});
|
|
730
298
|
|
|
731
|
-
it("
|
|
732
|
-
const
|
|
299
|
+
it("still restores after a second report, and a second undo moves nothing", async () => {
|
|
300
|
+
const messageId = await arrive();
|
|
301
|
+
await report(messageId);
|
|
302
|
+
await settle(messageId);
|
|
303
|
+
await report(messageId);
|
|
733
304
|
|
|
734
|
-
await
|
|
735
|
-
|
|
736
|
-
accountId: ACCOUNT,
|
|
737
|
-
messageId: MESSAGE_ID,
|
|
738
|
-
});
|
|
739
|
-
settleMove(messages);
|
|
740
|
-
await service.notSpam({
|
|
741
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
742
|
-
accountId: ACCOUNT,
|
|
743
|
-
messageId: MESSAGE_ID,
|
|
744
|
-
});
|
|
745
|
-
// originalMailboxId is already cleared, so the second call has nothing
|
|
746
|
-
// to wait on or restore — no need to settle again.
|
|
747
|
-
await service.notSpam({
|
|
748
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
749
|
-
accountId: ACCOUNT,
|
|
750
|
-
messageId: MESSAGE_ID,
|
|
751
|
-
});
|
|
305
|
+
await undo(messageId);
|
|
306
|
+
await undo(messageId);
|
|
752
307
|
|
|
753
|
-
const
|
|
754
|
-
assert.equal(
|
|
755
|
-
assert.equal(
|
|
756
|
-
|
|
757
|
-
// third INBOX->Junk move.
|
|
758
|
-
assert.equal(moveEvents(sent).length, 2);
|
|
308
|
+
const restored = await row(messageId);
|
|
309
|
+
assert.equal(restored.mailboxId, inbox.mailboxId);
|
|
310
|
+
assert.equal(restored.originalMailboxId, undefined);
|
|
311
|
+
assert.equal(moves().length, 2);
|
|
759
312
|
});
|
|
760
313
|
|
|
761
|
-
it("
|
|
762
|
-
const
|
|
314
|
+
it("refuses to restore before the move settled, but still lifts the block", async () => {
|
|
315
|
+
const messageId = await arrive();
|
|
316
|
+
await report(messageId);
|
|
763
317
|
|
|
764
|
-
await
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
318
|
+
await assert.rejects(undo(messageId), (error: unknown) => {
|
|
319
|
+
assert.ok(error instanceof MoveNotSettledError);
|
|
320
|
+
assert.match(error.message, /has not settled yet/);
|
|
321
|
+
return true;
|
|
768
322
|
});
|
|
769
|
-
// Deliberately NOT settled: status stays "moving", as it would while
|
|
770
|
-
// the original MESSAGE_MOVE is still genuinely in flight or retrying.
|
|
771
|
-
|
|
772
|
-
await assert.rejects(
|
|
773
|
-
() =>
|
|
774
|
-
service.notSpam({
|
|
775
|
-
accountConfigId: ACCOUNT_CONFIG,
|
|
776
|
-
accountId: ACCOUNT,
|
|
777
|
-
messageId: MESSAGE_ID,
|
|
778
|
-
}),
|
|
779
|
-
(error: unknown) => {
|
|
780
|
-
// Must be the dedicated type, not a plain Error — callers that
|
|
781
|
-
// surface failures to the user (packages/backend) allowlist this
|
|
782
|
-
// specific type rather than relaying an arbitrary message.
|
|
783
|
-
assert.ok(error instanceof MoveNotSettledError);
|
|
784
|
-
assert.match(error.message, /has not settled yet/);
|
|
785
|
-
return true;
|
|
786
|
-
},
|
|
787
|
-
);
|
|
788
323
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
assert.
|
|
793
|
-
|
|
794
|
-
// But nothing move-related was touched — a real move #2 must not be
|
|
795
|
-
// enqueued against an unsettled move #1.
|
|
796
|
-
const message = messages.get(MESSAGE_ID);
|
|
797
|
-
assert.equal(message?.mailboxId, JUNK_MAILBOX);
|
|
798
|
-
assert.ok(message !== undefined);
|
|
799
|
-
assert.ok((message.spamReport as { reportedAt: number }).reportedAt > 0);
|
|
324
|
+
assert.equal((await sender()).flags.blocked, undefined);
|
|
325
|
+
const unmoved = await row(messageId);
|
|
326
|
+
assert.equal(unmoved.mailboxId, junk?.mailboxId);
|
|
327
|
+
assert.ok((unmoved.spamReport?.reportedAt ?? 0) > 0);
|
|
800
328
|
});
|
|
801
329
|
});
|