@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
|
@@ -1,604 +1,277 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the `backfillClassifications` pass (issue #1197) — the resumable,
|
|
3
|
-
* checkpointed full-corpus pass that classifies rows still carrying the
|
|
4
|
-
* `NotExamined` sentinel on `Message.classificationState`, from their stored
|
|
5
|
-
* bodies, and marks them `Examined`.
|
|
6
|
-
*
|
|
7
|
-
* Mirrors `list-id-backfill.test.ts`: per-account pages, checkpoint after
|
|
8
|
-
* every page, resume by account-id order, and per-message failure
|
|
9
|
-
* containment via `.then(fulfilled, rejected)`.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
1
|
import assert from "node:assert/strict";
|
|
13
|
-
import { describe, it } from "node:test";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
14
3
|
import type {
|
|
15
|
-
AccountConfigItem,
|
|
16
4
|
AccountItem,
|
|
17
|
-
|
|
18
|
-
IAccountConfigRepository,
|
|
19
|
-
IAccountRepository,
|
|
20
|
-
IAddressRepository,
|
|
21
|
-
IMessageRepository,
|
|
22
|
-
IThreadMessageRepository,
|
|
23
|
-
MessageItem,
|
|
24
|
-
ResultList,
|
|
25
|
-
ThreadMessageItem,
|
|
5
|
+
MailboxItem,
|
|
26
6
|
UpdateMessageInput,
|
|
27
|
-
UpdateThreadMessageInput,
|
|
28
7
|
} from "@remit/data-ports";
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
8
|
+
import { deriveAddressId } from "@remit/data-ports/id";
|
|
9
|
+
import {
|
|
10
|
+
DisplayNameCorrespondence,
|
|
11
|
+
MessageCategory,
|
|
12
|
+
MessageClassificationState,
|
|
13
|
+
} from "@remit/domain-enums";
|
|
31
14
|
import type { StorageService } from "@remit/storage-service";
|
|
32
|
-
import {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
15
|
+
import {
|
|
16
|
+
backfillClassifications,
|
|
17
|
+
type ClassificationBackfillCheckpoint,
|
|
18
|
+
} from "./classification-backfill.js";
|
|
19
|
+
import { eml, FakeImapServer } from "./test-helpers/fake-imap.js";
|
|
20
|
+
import {
|
|
21
|
+
arriveAt,
|
|
22
|
+
openStore,
|
|
23
|
+
type Store,
|
|
24
|
+
seedAccount,
|
|
25
|
+
seedMailbox,
|
|
26
|
+
storeBody,
|
|
27
|
+
} from "./test-helpers/test-store.js";
|
|
28
|
+
|
|
29
|
+
const PLAIN = eml({ From: "Alice <alice@example.com>", Subject: "Hi" }, "hi");
|
|
30
|
+
|
|
31
|
+
const LIST = eml(
|
|
32
|
+
{
|
|
33
|
+
From: "Alice <alice@example.com>",
|
|
34
|
+
Subject: "Newsletter",
|
|
35
|
+
"List-Id": "<news.example.com>",
|
|
36
|
+
"List-Unsubscribe": "<https://example.com/unsub>",
|
|
37
|
+
},
|
|
38
|
+
"this week in news",
|
|
44
39
|
);
|
|
45
40
|
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
"Content-Type: text/plain",
|
|
54
|
-
"",
|
|
55
|
-
"this week in news",
|
|
56
|
-
].join("\r\n"),
|
|
41
|
+
const IMPERSONATION = eml(
|
|
42
|
+
{
|
|
43
|
+
From: "InfoMedics <billing@serviceupdatebank.atlassian.net>",
|
|
44
|
+
Subject: "Openstaande factuur",
|
|
45
|
+
"DKIM-Signature": "v=1; a=rsa-sha256; d=atlassian.net; s=sel; b=xxx",
|
|
46
|
+
},
|
|
47
|
+
"Betaal uw factuur",
|
|
57
48
|
);
|
|
58
49
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
);
|
|
50
|
+
let store: Store;
|
|
51
|
+
let storage: StorageService;
|
|
52
|
+
let imap: FakeImapServer;
|
|
53
|
+
let account: AccountItem;
|
|
54
|
+
let inbox: MailboxItem;
|
|
55
|
+
let junk: MailboxItem;
|
|
56
|
+
|
|
57
|
+
beforeEach(async () => {
|
|
58
|
+
store = await openStore();
|
|
59
|
+
storage = store.storage();
|
|
60
|
+
imap = new FakeImapServer();
|
|
61
|
+
imap.addFolder("INBOX");
|
|
62
|
+
imap.addFolder("Junk");
|
|
63
|
+
account = await seedAccount(store);
|
|
64
|
+
inbox = await seedMailbox(store, account, "INBOX");
|
|
65
|
+
junk = await seedMailbox(store, account, "Junk", { specialUse: "Junk" });
|
|
66
|
+
});
|
|
70
67
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
sentDate: 1,
|
|
84
|
-
isRead: false,
|
|
85
|
-
hasAttachment: false,
|
|
86
|
-
star: "none",
|
|
87
|
-
hasStars: false,
|
|
88
|
-
isDeleted: false,
|
|
89
|
-
category: "uncategorized",
|
|
90
|
-
createdAt: 1,
|
|
91
|
-
updatedAt: 1,
|
|
92
|
-
...overrides,
|
|
93
|
-
}) as unknown as ThreadMessageItem;
|
|
94
|
-
|
|
95
|
-
const message = (overrides: Partial<MessageItem>): MessageItem =>
|
|
96
|
-
({
|
|
97
|
-
messageId: "m-1",
|
|
98
|
-
mailboxId: "mb-1",
|
|
99
|
-
uid: 1,
|
|
100
|
-
status: "active",
|
|
101
|
-
syncStatus: "synced",
|
|
102
|
-
category: "uncategorized",
|
|
103
|
-
classificationState: "NotExamined",
|
|
104
|
-
hasListUnsubscribe: false,
|
|
105
|
-
movedByRemit: false,
|
|
106
|
-
bodyStorageKey: "s3://m-1",
|
|
107
|
-
authenticityVerdict: "NotEvaluated",
|
|
108
|
-
createdAt: 1,
|
|
109
|
-
updatedAt: 1,
|
|
110
|
-
...overrides,
|
|
111
|
-
}) as unknown as MessageItem;
|
|
112
|
-
|
|
113
|
-
const asAccount = (accountConfigId: string): AccountConfigItem =>
|
|
114
|
-
({ accountConfigId }) as unknown as AccountConfigItem;
|
|
115
|
-
|
|
116
|
-
interface Harness {
|
|
117
|
-
accountConfigService: Pick<IAccountConfigRepository, "listAll">;
|
|
118
|
-
accountService: Pick<IAccountRepository, "listAllByAccountConfig">;
|
|
119
|
-
mailboxSpecialUseService: FolderPlacementLookup;
|
|
120
|
-
addressService: Pick<IAddressRepository, "getAddress">;
|
|
121
|
-
threadMessageService: Pick<
|
|
122
|
-
IThreadMessageRepository,
|
|
123
|
-
"listByAccount" | "findAllByMessageId" | "update"
|
|
124
|
-
>;
|
|
125
|
-
messageService: Pick<IMessageRepository, "get" | "update">;
|
|
126
|
-
storageService: Pick<StorageService, "retrieve">;
|
|
127
|
-
messageUpdates: Array<{ messageId: string; input: UpdateMessageInput }>;
|
|
128
|
-
threadUpdates: Array<{
|
|
129
|
-
accountConfigId: string;
|
|
130
|
-
threadMessageId: string;
|
|
131
|
-
input: UpdateThreadMessageInput;
|
|
132
|
-
}>;
|
|
133
|
-
retrieved: string[];
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const buildHarness = (options: {
|
|
137
|
-
accounts?: AccountConfigItem[];
|
|
138
|
-
rows: ThreadMessageItem[];
|
|
139
|
-
messages: MessageItem[];
|
|
140
|
-
retrieve?: (key: string) => Promise<Buffer>;
|
|
141
|
-
pageSize?: number;
|
|
142
|
-
addressFlags?: AddressItem["flags"];
|
|
143
|
-
}): Harness => {
|
|
144
|
-
const accounts = options.accounts ?? [
|
|
145
|
-
{ accountConfigId: "acc-1" } as unknown as AccountConfigItem,
|
|
146
|
-
];
|
|
147
|
-
const messagesById = new Map(options.messages.map((m) => [m.messageId, m]));
|
|
148
|
-
const messageUpdates: Harness["messageUpdates"] = [];
|
|
149
|
-
const threadUpdates: Harness["threadUpdates"] = [];
|
|
150
|
-
const retrieved: string[] = [];
|
|
151
|
-
const pageSize = options.pageSize ?? 200;
|
|
152
|
-
|
|
153
|
-
const accountConfigService: Pick<IAccountConfigRepository, "listAll"> = {
|
|
154
|
-
listAll: async () => accounts,
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
const accountService: Pick<IAccountRepository, "listAllByAccountConfig"> = {
|
|
158
|
-
listAllByAccountConfig: async (accountConfigId: string) => [
|
|
159
|
-
{ accountId: `account-${accountConfigId}` } as unknown as AccountItem,
|
|
160
|
-
],
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
const mailboxSpecialUseService: FolderPlacementLookup = {
|
|
164
|
-
findJunkMailbox: async () => ({
|
|
165
|
-
mailboxId: JUNK_MAILBOX_ID,
|
|
166
|
-
fullPath: "Junk",
|
|
167
|
-
}),
|
|
168
|
-
findInboxMailbox: async () => ({ mailboxId: "mb-1", fullPath: "INBOX" }),
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
const addressService = {
|
|
172
|
-
getAddress: (async () => {
|
|
173
|
-
if (options.addressFlags === undefined)
|
|
174
|
-
throw new NotFoundError("no Address row for this sender");
|
|
175
|
-
return { flags: options.addressFlags } as AddressItem;
|
|
176
|
-
}) as unknown as IAddressRepository["getAddress"],
|
|
177
|
-
} as Pick<IAddressRepository, "getAddress">;
|
|
178
|
-
|
|
179
|
-
const threadMessageService: Pick<
|
|
180
|
-
IThreadMessageRepository,
|
|
181
|
-
"listByAccount" | "findAllByMessageId" | "update"
|
|
182
|
-
> = {
|
|
183
|
-
listByAccount: async (
|
|
184
|
-
accountConfigId: string,
|
|
185
|
-
opts?: { limit?: number; continuationToken?: string },
|
|
186
|
-
): Promise<ResultList<ThreadMessageItem>> => {
|
|
187
|
-
const scoped = options.rows.filter(
|
|
188
|
-
(r) => r.accountConfigId === accountConfigId,
|
|
189
|
-
);
|
|
190
|
-
const start = opts?.continuationToken
|
|
191
|
-
? Number(opts.continuationToken)
|
|
192
|
-
: 0;
|
|
193
|
-
const limit = opts?.limit ?? pageSize;
|
|
194
|
-
const page = scoped.slice(start, start + limit);
|
|
195
|
-
const nextStart = start + page.length;
|
|
196
|
-
return {
|
|
197
|
-
items: page,
|
|
198
|
-
continuationToken:
|
|
199
|
-
nextStart < scoped.length ? String(nextStart) : undefined,
|
|
200
|
-
};
|
|
201
|
-
},
|
|
202
|
-
findAllByMessageId: async (_accountConfigId, messageId) =>
|
|
203
|
-
options.rows.filter((r) => r.messageId === messageId),
|
|
204
|
-
update: async (accountConfigId, threadMessageId, input) => {
|
|
205
|
-
threadUpdates.push({ accountConfigId, threadMessageId, input });
|
|
206
|
-
const existing = options.rows.find(
|
|
207
|
-
(r) => r.threadMessageId === threadMessageId,
|
|
208
|
-
);
|
|
209
|
-
return { ...existing, ...input } as ThreadMessageItem;
|
|
210
|
-
},
|
|
211
|
-
};
|
|
212
|
-
|
|
213
|
-
const messageService: Pick<IMessageRepository, "get" | "update"> = {
|
|
214
|
-
get: (async (messageIds: string | string[]) => {
|
|
215
|
-
if (Array.isArray(messageIds)) {
|
|
216
|
-
return messageIds
|
|
217
|
-
.map((id) => messagesById.get(id))
|
|
218
|
-
.filter((m): m is MessageItem => m !== undefined);
|
|
219
|
-
}
|
|
220
|
-
const found = messagesById.get(messageIds);
|
|
221
|
-
if (!found) throw new Error(`no fixture for ${messageIds}`);
|
|
222
|
-
return found;
|
|
223
|
-
}) as IMessageRepository["get"],
|
|
224
|
-
update: async (messageId, input) => {
|
|
225
|
-
messageUpdates.push({ messageId, input });
|
|
226
|
-
const existing = messagesById.get(messageId);
|
|
227
|
-
return { ...existing, ...input } as MessageItem;
|
|
228
|
-
},
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
const storageService: Pick<StorageService, "retrieve"> = {
|
|
232
|
-
retrieve: async (key: string) => {
|
|
233
|
-
retrieved.push(key);
|
|
234
|
-
return options.retrieve ? options.retrieve(key) : PLAIN_EML;
|
|
235
|
-
},
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
return {
|
|
239
|
-
accountConfigService,
|
|
240
|
-
accountService,
|
|
241
|
-
mailboxSpecialUseService,
|
|
242
|
-
addressService,
|
|
243
|
-
threadMessageService,
|
|
244
|
-
messageService,
|
|
245
|
-
storageService,
|
|
246
|
-
messageUpdates,
|
|
247
|
-
threadUpdates,
|
|
248
|
-
retrieved,
|
|
249
|
-
};
|
|
68
|
+
afterEach(() => store.close());
|
|
69
|
+
|
|
70
|
+
const stored = async (
|
|
71
|
+
raw: Buffer,
|
|
72
|
+
mailbox: MailboxItem = inbox,
|
|
73
|
+
update: UpdateMessageInput = {},
|
|
74
|
+
owner: AccountItem = account,
|
|
75
|
+
): Promise<string> => {
|
|
76
|
+
const messageId = await arriveAt(store, imap, owner, mailbox, raw);
|
|
77
|
+
await storeBody(store, storage, owner, messageId, raw);
|
|
78
|
+
await store.message.update(messageId, update);
|
|
79
|
+
return messageId;
|
|
250
80
|
};
|
|
251
81
|
|
|
82
|
+
const deps = () => ({
|
|
83
|
+
accountConfigService: store.accountConfig,
|
|
84
|
+
accountService: store.account,
|
|
85
|
+
mailboxSpecialUseService: store.mailboxSpecialUse,
|
|
86
|
+
addressService: store.address,
|
|
87
|
+
threadMessageService: store.threadMessage,
|
|
88
|
+
messageService: store.message,
|
|
89
|
+
storageService: storage,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const threadRow = async (messageId: string) =>
|
|
93
|
+
(
|
|
94
|
+
await store.threadMessage.findAllByMessageId(
|
|
95
|
+
account.accountConfigId,
|
|
96
|
+
messageId,
|
|
97
|
+
)
|
|
98
|
+
)[0];
|
|
99
|
+
|
|
252
100
|
describe("backfillClassifications", () => {
|
|
253
|
-
it("classifies a
|
|
254
|
-
const
|
|
255
|
-
rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
|
|
256
|
-
messages: [message({ messageId: "m-1" })],
|
|
257
|
-
});
|
|
101
|
+
it("classifies a never-examined body and denormalizes the answer", async () => {
|
|
102
|
+
const messageId = await stored(LIST);
|
|
258
103
|
|
|
259
|
-
const result = await backfillClassifications(
|
|
104
|
+
const result = await backfillClassifications(deps());
|
|
260
105
|
|
|
261
106
|
assert.equal(result.backfilled, 1);
|
|
262
|
-
|
|
263
|
-
assert.equal(
|
|
264
|
-
assert.
|
|
265
|
-
assert.equal(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
assert.equal(harness.messageUpdates[0].input.category, "personal");
|
|
270
|
-
assert.equal(
|
|
271
|
-
harness.messageUpdates[0].input.classificationState,
|
|
272
|
-
"Examined",
|
|
273
|
-
);
|
|
107
|
+
const row = await store.message.get(messageId);
|
|
108
|
+
assert.equal(row.category, MessageCategory.newsletter);
|
|
109
|
+
assert.equal(row.hasListUnsubscribe, true);
|
|
110
|
+
assert.equal(row.classificationState, MessageClassificationState.Examined);
|
|
111
|
+
const thread = await threadRow(messageId);
|
|
112
|
+
assert.equal(thread.category, MessageCategory.newsletter);
|
|
113
|
+
assert.equal(thread.listId, "news.example.com");
|
|
274
114
|
});
|
|
275
115
|
|
|
276
|
-
it("compares the sender of a Junk row the provider rated clean,
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
row({
|
|
280
|
-
threadMessageId: "tm-junk",
|
|
281
|
-
messageId: "m-junk",
|
|
282
|
-
mailboxId: JUNK_MAILBOX_ID,
|
|
283
|
-
}),
|
|
284
|
-
row({ threadMessageId: "tm-inbox", messageId: "m-inbox" }),
|
|
285
|
-
],
|
|
286
|
-
messages: [
|
|
287
|
-
message({
|
|
288
|
-
messageId: "m-junk",
|
|
289
|
-
mailboxId: JUNK_MAILBOX_ID,
|
|
290
|
-
bodyStorageKey: "s3://m-junk",
|
|
291
|
-
}),
|
|
292
|
-
message({ messageId: "m-inbox", bodyStorageKey: "s3://m-inbox" }),
|
|
293
|
-
],
|
|
294
|
-
retrieve: async () => IMPERSONATION_EML,
|
|
295
|
-
});
|
|
116
|
+
it("compares the sender of a Junk row the provider rated clean, not of an inbox row", async () => {
|
|
117
|
+
const inJunk = await stored(IMPERSONATION, junk);
|
|
118
|
+
const inInbox = await stored(IMPERSONATION);
|
|
296
119
|
|
|
297
|
-
await backfillClassifications(
|
|
120
|
+
await backfillClassifications(deps());
|
|
298
121
|
|
|
299
|
-
const correspondence = new Map(
|
|
300
|
-
harness.messageUpdates.map((update) => [
|
|
301
|
-
update.messageId,
|
|
302
|
-
update.input.authenticity?.displayNameCorrespondence,
|
|
303
|
-
]),
|
|
304
|
-
);
|
|
305
122
|
assert.equal(
|
|
306
|
-
|
|
123
|
+
(await store.message.get(inJunk)).authenticity?.displayNameCorrespondence,
|
|
307
124
|
DisplayNameCorrespondence.Unrelated,
|
|
308
125
|
);
|
|
309
|
-
assert.equal(
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
|
|
315
|
-
messages: [message({ messageId: "m-1" })],
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
await backfillClassifications(harness);
|
|
319
|
-
|
|
320
|
-
// The thread row is written BEFORE the Message update, so a failure
|
|
321
|
-
// between the two leaves both undone for the rerun (issue #320).
|
|
322
|
-
assert.equal(harness.threadUpdates.length, 1);
|
|
323
|
-
assert.equal(harness.threadUpdates[0].threadMessageId, "tm-1");
|
|
324
|
-
assert.equal(harness.threadUpdates[0].input.category, "personal");
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
it("writes the derived bulk fields and the row's List-Id, from the same bytes", async () => {
|
|
328
|
-
const harness = buildHarness({
|
|
329
|
-
rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
|
|
330
|
-
messages: [message({ messageId: "m-1", bodyStorageKey: "s3://list" })],
|
|
331
|
-
retrieve: async () => LIST_EML,
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
const result = await backfillClassifications(harness);
|
|
335
|
-
|
|
336
|
-
assert.equal(result.backfilled, 1);
|
|
337
|
-
assert.equal(harness.messageUpdates[0].input.category, "newsletter");
|
|
338
|
-
assert.equal(harness.messageUpdates[0].input.hasListUnsubscribe, true);
|
|
339
|
-
assert.equal(harness.threadUpdates[0].input.listId, "news.example.com");
|
|
126
|
+
assert.equal(
|
|
127
|
+
(await store.message.get(inInbox)).authenticity
|
|
128
|
+
?.displayNameCorrespondence,
|
|
129
|
+
undefined,
|
|
130
|
+
);
|
|
340
131
|
});
|
|
341
132
|
|
|
342
|
-
it("applies the sender's
|
|
343
|
-
const
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
});
|
|
133
|
+
it("applies the sender's category override over the headers", async () => {
|
|
134
|
+
const messageId = await stored(PLAIN);
|
|
135
|
+
await store.address.updateAddress(
|
|
136
|
+
account.accountConfigId,
|
|
137
|
+
deriveAddressId(account.accountConfigId, "alice@example.com"),
|
|
138
|
+
{ flags: { category: { value: MessageCategory.marketing, setAt: 1 } } },
|
|
139
|
+
);
|
|
350
140
|
|
|
351
|
-
|
|
141
|
+
await backfillClassifications(deps());
|
|
352
142
|
|
|
353
|
-
assert.equal(
|
|
354
|
-
|
|
355
|
-
|
|
143
|
+
assert.equal(
|
|
144
|
+
(await store.message.get(messageId)).category,
|
|
145
|
+
MessageCategory.marketing,
|
|
146
|
+
);
|
|
356
147
|
});
|
|
357
148
|
|
|
358
|
-
it("marks an already-categorized
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
messages: [
|
|
362
|
-
message({
|
|
363
|
-
messageId: "m-1",
|
|
364
|
-
category: "newsletter",
|
|
365
|
-
classificationState: "NotExamined",
|
|
366
|
-
}),
|
|
367
|
-
],
|
|
149
|
+
it("marks an already-categorized row examined and keeps its category", async () => {
|
|
150
|
+
const messageId = await stored(PLAIN, inbox, {
|
|
151
|
+
category: MessageCategory.newsletter,
|
|
368
152
|
});
|
|
369
153
|
|
|
370
|
-
const result = await backfillClassifications(
|
|
154
|
+
const result = await backfillClassifications(deps());
|
|
371
155
|
|
|
372
|
-
// Those rows were classified by a pass that predates the field; the
|
|
373
|
-
// write-once category is carried forward, never recomputed (#355).
|
|
374
156
|
assert.equal(result.alreadyCategorized, 1);
|
|
375
|
-
|
|
376
|
-
assert.
|
|
377
|
-
assert.equal(
|
|
378
|
-
assert.deepEqual(harness.messageUpdates[0].input, {
|
|
379
|
-
classificationState: "Examined",
|
|
380
|
-
});
|
|
381
|
-
assert.deepEqual(harness.threadUpdates, []);
|
|
157
|
+
const row = await store.message.get(messageId);
|
|
158
|
+
assert.equal(row.category, MessageCategory.newsletter);
|
|
159
|
+
assert.equal(row.classificationState, MessageClassificationState.Examined);
|
|
382
160
|
});
|
|
383
161
|
|
|
384
|
-
it("
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
messages: [
|
|
388
|
-
message({ messageId: "m-1", classificationState: "Examined" }),
|
|
389
|
-
],
|
|
162
|
+
it("leaves examined rows and rows with no stored body alone", async () => {
|
|
163
|
+
await stored(PLAIN, inbox, {
|
|
164
|
+
classificationState: MessageClassificationState.Examined,
|
|
390
165
|
});
|
|
166
|
+
const unsynced = await arriveAt(store, imap, account, inbox, PLAIN);
|
|
391
167
|
|
|
392
|
-
const result = await backfillClassifications(
|
|
168
|
+
const result = await backfillClassifications(deps());
|
|
393
169
|
|
|
394
170
|
assert.equal(result.alreadyExamined, 1);
|
|
395
|
-
assert.equal(result.backfilled, 0);
|
|
396
|
-
assert.deepEqual(harness.retrieved, []);
|
|
397
|
-
assert.deepEqual(harness.messageUpdates, []);
|
|
398
|
-
});
|
|
399
|
-
|
|
400
|
-
it("skips a candidate whose body was never synced, without reading storage", async () => {
|
|
401
|
-
const harness = buildHarness({
|
|
402
|
-
rows: [row({ threadMessageId: "tm-1", messageId: "m-1" })],
|
|
403
|
-
messages: [message({ messageId: "m-1", bodyStorageKey: undefined })],
|
|
404
|
-
});
|
|
405
|
-
|
|
406
|
-
const result = await backfillClassifications(harness);
|
|
407
|
-
|
|
408
171
|
assert.equal(result.skippedNoBody, 1);
|
|
409
|
-
assert.
|
|
410
|
-
|
|
172
|
+
assert.equal(
|
|
173
|
+
(await store.message.get(unsynced)).classificationState,
|
|
174
|
+
MessageClassificationState.NotExamined,
|
|
175
|
+
);
|
|
411
176
|
});
|
|
412
177
|
|
|
413
|
-
it("examines a message once
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
threadMessageId: "tm-2",
|
|
419
|
-
messageId: "m-1",
|
|
420
|
-
threadId: "thread-2",
|
|
421
|
-
}),
|
|
422
|
-
],
|
|
423
|
-
messages: [message({ messageId: "m-1" })],
|
|
178
|
+
it("examines a message once however many thread rows index it", async () => {
|
|
179
|
+
const messageId = await stored(PLAIN);
|
|
180
|
+
await store.threadMessage.create({
|
|
181
|
+
...(await threadRow(messageId)),
|
|
182
|
+
threadId: "drifted-root",
|
|
424
183
|
});
|
|
425
184
|
|
|
426
|
-
const result = await backfillClassifications(
|
|
185
|
+
const result = await backfillClassifications(deps());
|
|
427
186
|
|
|
428
187
|
assert.equal(result.backfilled, 1);
|
|
429
188
|
assert.equal(result.alreadyExamined, 1);
|
|
430
|
-
assert.deepEqual(harness.retrieved, ["s3://m-1"]);
|
|
431
|
-
assert.equal(harness.messageUpdates.length, 1);
|
|
432
189
|
});
|
|
433
190
|
|
|
434
|
-
it("contains a
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
row({ threadMessageId: "tm-bad", messageId: "m-bad" }),
|
|
438
|
-
row({ threadMessageId: "tm-good", messageId: "m-good" }),
|
|
439
|
-
],
|
|
440
|
-
messages: [
|
|
441
|
-
message({ messageId: "m-bad", bodyStorageKey: "s3://m-bad" }),
|
|
442
|
-
message({ messageId: "m-good", bodyStorageKey: "s3://m-good" }),
|
|
443
|
-
],
|
|
444
|
-
retrieve: async (key) => {
|
|
445
|
-
if (key === "s3://m-bad") throw new Error("AccessDenied");
|
|
446
|
-
return PLAIN_EML;
|
|
447
|
-
},
|
|
191
|
+
it("contains a failure to the one message and keeps going", async () => {
|
|
192
|
+
const lost = await stored(PLAIN, inbox, {
|
|
193
|
+
bodyStorageKey: "file:///nonexistent/remit/body.eml",
|
|
448
194
|
});
|
|
195
|
+
const fine = await stored(PLAIN);
|
|
449
196
|
|
|
450
|
-
const result = await backfillClassifications(
|
|
197
|
+
const result = await backfillClassifications(deps());
|
|
451
198
|
|
|
452
199
|
assert.equal(result.failed, 1);
|
|
453
|
-
assert.deepEqual(result.failedThreadMessageIds, [
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
assert.equal(
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
it("reports progress as pages are processed", async () => {
|
|
460
|
-
const rows = Array.from({ length: 3 }, (_, i) =>
|
|
461
|
-
row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
|
|
462
|
-
);
|
|
463
|
-
const messages = rows.map((r) =>
|
|
464
|
-
message({
|
|
465
|
-
messageId: r.messageId,
|
|
466
|
-
bodyStorageKey: `s3://${r.messageId}`,
|
|
467
|
-
}),
|
|
200
|
+
assert.deepEqual(result.failedThreadMessageIds, [
|
|
201
|
+
(await threadRow(lost)).threadMessageId,
|
|
202
|
+
]);
|
|
203
|
+
assert.equal(
|
|
204
|
+
(await store.message.get(fine)).classificationState,
|
|
205
|
+
MessageClassificationState.Examined,
|
|
468
206
|
);
|
|
469
|
-
const harness = buildHarness({ rows, messages, pageSize: 2 });
|
|
470
|
-
const progress: Array<{ accountConfigId: string; scanned: number }> = [];
|
|
471
|
-
|
|
472
|
-
const result = await backfillClassifications(harness, {
|
|
473
|
-
batchSize: 2,
|
|
474
|
-
onProgress: (p) => progress.push({ ...p }),
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
assert.equal(result.backfilled, 3);
|
|
478
|
-
assert.equal(progress.length, 2);
|
|
479
|
-
assert.equal(progress[0].scanned, 2);
|
|
480
|
-
assert.equal(progress[1].scanned, 3);
|
|
481
207
|
});
|
|
482
208
|
|
|
483
|
-
it("scans
|
|
484
|
-
const
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
message({
|
|
497
|
-
messageId: r.messageId,
|
|
498
|
-
bodyStorageKey: `s3://${r.messageId}`,
|
|
499
|
-
}),
|
|
500
|
-
),
|
|
501
|
-
});
|
|
502
|
-
const progress: Array<{ accountConfigId: string; scanned: number }> = [];
|
|
209
|
+
it("scans accounts in account-config order whatever order they were created in", async () => {
|
|
210
|
+
const second = await seedAccount(store, { accountConfigId: "cfg-a" });
|
|
211
|
+
const third = await seedAccount(store, { accountConfigId: "cfg-b" });
|
|
212
|
+
for (const owner of [third, second]) {
|
|
213
|
+
imap.addFolder(`INBOX-${owner.accountConfigId}`);
|
|
214
|
+
await stored(
|
|
215
|
+
PLAIN,
|
|
216
|
+
await seedMailbox(store, owner, `INBOX-${owner.accountConfigId}`),
|
|
217
|
+
{},
|
|
218
|
+
owner,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const order: string[] = [];
|
|
503
222
|
|
|
504
|
-
|
|
505
|
-
onProgress: (
|
|
223
|
+
await backfillClassifications(deps(), {
|
|
224
|
+
onProgress: (progress) => order.push(progress.accountConfigId),
|
|
506
225
|
});
|
|
507
226
|
|
|
508
|
-
assert.equal(result.backfilled, 3);
|
|
509
227
|
assert.deepEqual(
|
|
510
|
-
|
|
511
|
-
|
|
228
|
+
order.filter((id) => id !== account.accountConfigId),
|
|
229
|
+
["cfg-a", "cfg-b"],
|
|
512
230
|
);
|
|
513
231
|
});
|
|
514
232
|
|
|
515
|
-
it("
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
);
|
|
519
|
-
const messages = rows.map((r) =>
|
|
520
|
-
message({
|
|
521
|
-
messageId: r.messageId,
|
|
522
|
-
bodyStorageKey: `s3://${r.messageId}`,
|
|
523
|
-
}),
|
|
524
|
-
);
|
|
525
|
-
const harness = buildHarness({ rows, messages, pageSize: 2 });
|
|
526
|
-
|
|
527
|
-
const saved: Array<{
|
|
528
|
-
accountConfigId: string;
|
|
529
|
-
continuationToken?: string;
|
|
530
|
-
}> = [];
|
|
233
|
+
it("resumes from its checkpoint after an interruption", async () => {
|
|
234
|
+
for (let i = 0; i < 3; i++) await stored(PLAIN);
|
|
235
|
+
let checkpoint: ClassificationBackfillCheckpoint | undefined;
|
|
531
236
|
let cleared = false;
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
load: async () => undefined,
|
|
537
|
-
save: async (checkpoint) => {
|
|
538
|
-
saved.push(checkpoint);
|
|
539
|
-
},
|
|
540
|
-
clear: async () => {
|
|
541
|
-
cleared = true;
|
|
542
|
-
},
|
|
237
|
+
const checkpointStore = {
|
|
238
|
+
load: async () => checkpoint,
|
|
239
|
+
save: async (next: ClassificationBackfillCheckpoint) => {
|
|
240
|
+
checkpoint = next;
|
|
543
241
|
},
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
assert.
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
const messages = rows.map((r) =>
|
|
558
|
-
message({
|
|
559
|
-
messageId: r.messageId,
|
|
560
|
-
bodyStorageKey: `s3://${r.messageId}`,
|
|
242
|
+
clear: async () => {
|
|
243
|
+
checkpoint = undefined;
|
|
244
|
+
cleared = true;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
await assert.rejects(
|
|
249
|
+
backfillClassifications(deps(), {
|
|
250
|
+
batchSize: 2,
|
|
251
|
+
checkpointStore,
|
|
252
|
+
onProgress: () => {
|
|
253
|
+
throw new Error("killed");
|
|
254
|
+
},
|
|
561
255
|
}),
|
|
562
256
|
);
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
const result = await backfillClassifications(harness, {
|
|
257
|
+
const resumed = await backfillClassifications(deps(), {
|
|
566
258
|
batchSize: 2,
|
|
567
|
-
checkpointStore
|
|
568
|
-
load: async () => ({
|
|
569
|
-
accountConfigId: "acc-1",
|
|
570
|
-
continuationToken: "2",
|
|
571
|
-
}),
|
|
572
|
-
save: async () => {},
|
|
573
|
-
clear: async () => {},
|
|
574
|
-
},
|
|
259
|
+
checkpointStore,
|
|
575
260
|
});
|
|
576
261
|
|
|
577
|
-
assert.equal(
|
|
578
|
-
assert.equal(
|
|
579
|
-
assert.
|
|
580
|
-
harness.messageUpdates.map((u) => u.messageId),
|
|
581
|
-
["m-2"],
|
|
582
|
-
);
|
|
262
|
+
assert.equal(resumed.scanned, 1);
|
|
263
|
+
assert.equal(resumed.backfilled, 1);
|
|
264
|
+
assert.equal(cleared, true);
|
|
583
265
|
});
|
|
584
266
|
|
|
585
267
|
it("restarts the pass when the checkpointed account no longer exists", async () => {
|
|
586
|
-
|
|
587
|
-
row({ threadMessageId: `tm-${i}`, messageId: `m-${i}` }),
|
|
588
|
-
);
|
|
589
|
-
const messages = rows.map((r) =>
|
|
590
|
-
message({
|
|
591
|
-
messageId: r.messageId,
|
|
592
|
-
bodyStorageKey: `s3://${r.messageId}`,
|
|
593
|
-
}),
|
|
594
|
-
);
|
|
595
|
-
const harness = buildHarness({ rows, messages, pageSize: 2 });
|
|
268
|
+
for (let i = 0; i < 3; i++) await stored(PLAIN);
|
|
596
269
|
|
|
597
|
-
const result = await backfillClassifications(
|
|
270
|
+
const result = await backfillClassifications(deps(), {
|
|
598
271
|
batchSize: 2,
|
|
599
272
|
checkpointStore: {
|
|
600
273
|
load: async () => ({
|
|
601
|
-
accountConfigId: "
|
|
274
|
+
accountConfigId: "cfg-removed",
|
|
602
275
|
continuationToken: "2",
|
|
603
276
|
}),
|
|
604
277
|
save: async () => {},
|
|
@@ -607,9 +280,6 @@ describe("backfillClassifications", () => {
|
|
|
607
280
|
});
|
|
608
281
|
|
|
609
282
|
assert.equal(result.scanned, 3);
|
|
610
|
-
assert.
|
|
611
|
-
harness.messageUpdates.map((u) => u.messageId),
|
|
612
|
-
["m-0", "m-1", "m-2"],
|
|
613
|
-
);
|
|
283
|
+
assert.equal(result.backfilled, 3);
|
|
614
284
|
});
|
|
615
285
|
});
|