@remit/drizzle-service 0.0.64 → 0.0.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/repair/junk-only-address.sqlite.test.ts +221 -72
- package/src/repair/junk-only-address.ts +116 -68
- package/src/repos/i4-account-setting.ts +23 -1
- package/src/repos/i4-address-junk-move.sqlite.test.ts +119 -15
- package/src/repos/i4-address.ts +15 -7
- package/src/repos/i4-mailbox-special-use.ts +161 -1
package/package.json
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { after, before, beforeEach, describe, test } from "node:test";
|
|
4
|
+
import { composeFolderRoleAppointmentName } from "@remit/data-ports/folder-role";
|
|
5
|
+
import { CanonicalMailboxRole } from "@remit/domain-enums";
|
|
4
6
|
import Database from "better-sqlite3";
|
|
7
|
+
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
8
|
+
import { AccountSettingRepo } from "../repos/i4-account-setting.js";
|
|
9
|
+
import { MailboxSpecialUseRepo } from "../repos/i4-mailbox-special-use.js";
|
|
5
10
|
import { shippedTableDdl } from "../test-shipped-sqlite-schema.js";
|
|
6
11
|
import {
|
|
7
12
|
type JunkOnlyRepairClient,
|
|
13
|
+
type JunkOnlyRepairMode,
|
|
14
|
+
type JunkOnlyReport,
|
|
8
15
|
sweepJunkOnlyAddresses,
|
|
9
16
|
} from "./junk-only-address.js";
|
|
10
17
|
|
|
@@ -22,8 +29,47 @@ interface AddressRow {
|
|
|
22
29
|
|
|
23
30
|
describe("addresses standing only on mail in Junk", () => {
|
|
24
31
|
let sqlite: Database.Database;
|
|
32
|
+
let specialUse: MailboxSpecialUseRepo;
|
|
33
|
+
let accountSetting: AccountSettingRepo;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Which folder holds Junk is resolved through the repository seam every
|
|
37
|
+
* other special-folder lookup reads, so the sweep honours an appointment and
|
|
38
|
+
* cannot disagree with the sync that harvested the address.
|
|
39
|
+
*/
|
|
40
|
+
const sweep = async (
|
|
41
|
+
mode: JunkOnlyRepairMode = "repair",
|
|
42
|
+
): Promise<JunkOnlyReport> =>
|
|
43
|
+
sweepJunkOnlyAddresses(
|
|
44
|
+
clientOver(sqlite),
|
|
45
|
+
mode,
|
|
46
|
+
await specialUse.resolveJunkRolesForInstance(),
|
|
47
|
+
);
|
|
25
48
|
|
|
26
|
-
const
|
|
49
|
+
const account = (accountId: string): void => {
|
|
50
|
+
sqlite
|
|
51
|
+
.prepare(
|
|
52
|
+
`INSERT OR IGNORE INTO account (
|
|
53
|
+
account_id, account_config_id, username, email, imap_host,
|
|
54
|
+
imap_port, imap_tls, imap_start_tls, smtp_port, is_active,
|
|
55
|
+
connection_state, created_at, updated_at
|
|
56
|
+
) VALUES (?, 'cfg-1', 'user', 'user@example.com', 'imap.example.com',
|
|
57
|
+
993, 1, 0, 465, 1, 'disconnected', 0, 0)`,
|
|
58
|
+
)
|
|
59
|
+
.run(accountId);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const mailbox = (
|
|
63
|
+
mailboxId: string,
|
|
64
|
+
designation: string | null,
|
|
65
|
+
folder: {
|
|
66
|
+
accountId?: string;
|
|
67
|
+
fullPath?: string;
|
|
68
|
+
hierarchyDelimiter?: string;
|
|
69
|
+
} = {},
|
|
70
|
+
): void => {
|
|
71
|
+
const accountId = folder.accountId ?? "acc";
|
|
72
|
+
account(accountId);
|
|
27
73
|
sqlite
|
|
28
74
|
.prepare(
|
|
29
75
|
`INSERT INTO mailbox (
|
|
@@ -32,9 +78,29 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
32
78
|
unseen_count, deleted_count, total_size, last_sync_uid,
|
|
33
79
|
high_water_mark_uid, last_message_sync_at, special_use,
|
|
34
80
|
created_at, updated_at
|
|
35
|
-
) VALUES (?,
|
|
81
|
+
) VALUES (?, ?, '', ?, ?, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, ?, 0, 0)`,
|
|
36
82
|
)
|
|
37
|
-
.run(
|
|
83
|
+
.run(
|
|
84
|
+
mailboxId,
|
|
85
|
+
accountId,
|
|
86
|
+
folder.hierarchyDelimiter ?? "/",
|
|
87
|
+
folder.fullPath ?? mailboxId,
|
|
88
|
+
designation,
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const appointJunk = async (
|
|
93
|
+
accountId: string,
|
|
94
|
+
mailboxId: string,
|
|
95
|
+
): Promise<void> => {
|
|
96
|
+
await accountSetting.upsert({
|
|
97
|
+
accountConfigId: "cfg-1",
|
|
98
|
+
name: composeFolderRoleAppointmentName(
|
|
99
|
+
accountId,
|
|
100
|
+
CanonicalMailboxRole.Junk,
|
|
101
|
+
),
|
|
102
|
+
value: { kind: "String", value: mailboxId },
|
|
103
|
+
});
|
|
38
104
|
};
|
|
39
105
|
|
|
40
106
|
const specialUseEntry = (mailboxId: string, specialUse: string): void => {
|
|
@@ -117,12 +183,17 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
117
183
|
|
|
118
184
|
before(() => {
|
|
119
185
|
sqlite = new Database(":memory:");
|
|
186
|
+
const db = drizzle(sqlite);
|
|
187
|
+
specialUse = new MailboxSpecialUseRepo(db as never);
|
|
188
|
+
accountSetting = new AccountSettingRepo(db as never);
|
|
120
189
|
for (const table of [
|
|
121
190
|
"address",
|
|
122
191
|
"envelope_address",
|
|
123
192
|
"message",
|
|
124
193
|
"mailbox",
|
|
125
194
|
"mailbox_special_use_entry",
|
|
195
|
+
"account_setting",
|
|
196
|
+
"account",
|
|
126
197
|
]) {
|
|
127
198
|
sqlite.exec(shippedTableDdl(DDL_TAG, table));
|
|
128
199
|
}
|
|
@@ -148,6 +219,8 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
148
219
|
"message",
|
|
149
220
|
"mailbox",
|
|
150
221
|
"mailbox_special_use_entry",
|
|
222
|
+
"account_setting",
|
|
223
|
+
"account",
|
|
151
224
|
]) {
|
|
152
225
|
sqlite.exec(`DELETE FROM ${table}`);
|
|
153
226
|
}
|
|
@@ -167,7 +240,7 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
167
240
|
sighting("spammer", "spam-1");
|
|
168
241
|
sighting("spammer", "spam-2");
|
|
169
242
|
|
|
170
|
-
const report = await
|
|
243
|
+
const report = await sweep();
|
|
171
244
|
|
|
172
245
|
assert.equal(report.withholdable, 1);
|
|
173
246
|
assert.equal(report.withheld, 1);
|
|
@@ -181,7 +254,7 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
181
254
|
sighting("colleague", "spam-1");
|
|
182
255
|
sighting("colleague", "mail-1");
|
|
183
256
|
|
|
184
|
-
const report = await
|
|
257
|
+
const report = await sweep();
|
|
185
258
|
|
|
186
259
|
assert.equal(report.withholdable, 0);
|
|
187
260
|
assert.equal(withheld("colleague"), false);
|
|
@@ -194,7 +267,7 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
194
267
|
.prepare("UPDATE message SET mailbox_id = 'junk' WHERE message_id = ?")
|
|
195
268
|
.run("mail-1");
|
|
196
269
|
|
|
197
|
-
await
|
|
270
|
+
await sweep();
|
|
198
271
|
|
|
199
272
|
assert.equal(withheld("newsletter"), true);
|
|
200
273
|
});
|
|
@@ -202,13 +275,13 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
202
275
|
test("restores an address whose message moved out of Junk", async () => {
|
|
203
276
|
address("misfiled");
|
|
204
277
|
sighting("misfiled", "spam-1");
|
|
205
|
-
await
|
|
278
|
+
await sweep();
|
|
206
279
|
assert.equal(withheld("misfiled"), true);
|
|
207
280
|
|
|
208
281
|
sqlite
|
|
209
282
|
.prepare("UPDATE message SET mailbox_id = 'inbox' WHERE message_id = ?")
|
|
210
283
|
.run("spam-1");
|
|
211
|
-
const report = await
|
|
284
|
+
const report = await sweep();
|
|
212
285
|
|
|
213
286
|
assert.equal(report.restorable, 1);
|
|
214
287
|
assert.equal(report.restored, 1);
|
|
@@ -219,7 +292,7 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
219
292
|
address("client", { outbound: 1 });
|
|
220
293
|
sighting("client", "spam-1");
|
|
221
294
|
|
|
222
|
-
const report = await
|
|
295
|
+
const report = await sweep();
|
|
223
296
|
|
|
224
297
|
assert.equal(report.withholdable, 0);
|
|
225
298
|
assert.equal(withheld("client"), false);
|
|
@@ -229,7 +302,7 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
229
302
|
address("friend", { reply: 2 });
|
|
230
303
|
sighting("friend", "spam-1");
|
|
231
304
|
|
|
232
|
-
await
|
|
305
|
+
await sweep();
|
|
233
306
|
|
|
234
307
|
assert.equal(withheld("friend"), false);
|
|
235
308
|
});
|
|
@@ -238,7 +311,7 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
238
311
|
address("boss", {}, '{"vip":{"value":true,"setAt":1}}');
|
|
239
312
|
sighting("boss", "spam-1");
|
|
240
313
|
|
|
241
|
-
await
|
|
314
|
+
await sweep();
|
|
242
315
|
|
|
243
316
|
assert.equal(withheld("boss"), false);
|
|
244
317
|
});
|
|
@@ -247,13 +320,13 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
247
320
|
address("reformed", {}, '{"junkOnly":{"value":true,"setAt":1}}');
|
|
248
321
|
sighting("reformed", "spam-1");
|
|
249
322
|
|
|
250
|
-
await
|
|
323
|
+
await sweep();
|
|
251
324
|
assert.equal(withheld("reformed"), true);
|
|
252
325
|
|
|
253
326
|
sqlite
|
|
254
327
|
.prepare("UPDATE address SET outbound_count = 1 WHERE address_id = ?")
|
|
255
328
|
.run("reformed");
|
|
256
|
-
await
|
|
329
|
+
await sweep();
|
|
257
330
|
|
|
258
331
|
assert.equal(withheld("reformed"), false);
|
|
259
332
|
});
|
|
@@ -262,7 +335,7 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
262
335
|
address("ex-colleague");
|
|
263
336
|
sighting("ex-colleague", "bin-1");
|
|
264
337
|
|
|
265
|
-
const report = await
|
|
338
|
+
const report = await sweep();
|
|
266
339
|
|
|
267
340
|
assert.equal(report.withholdable, 0);
|
|
268
341
|
assert.equal(withheld("ex-colleague"), false);
|
|
@@ -273,7 +346,7 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
273
346
|
sighting("spammer", "spam-1");
|
|
274
347
|
sighting("spammer", "bin-1");
|
|
275
348
|
|
|
276
|
-
await
|
|
349
|
+
await sweep();
|
|
277
350
|
|
|
278
351
|
assert.equal(withheld("spammer"), true);
|
|
279
352
|
});
|
|
@@ -282,14 +355,14 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
282
355
|
address("junk-then-bin");
|
|
283
356
|
address("bin-then-junk");
|
|
284
357
|
sighting("junk-then-bin", "spam-1");
|
|
285
|
-
await
|
|
358
|
+
await sweep();
|
|
286
359
|
sighting("junk-then-bin", "bin-1");
|
|
287
|
-
await
|
|
360
|
+
await sweep();
|
|
288
361
|
|
|
289
362
|
sighting("bin-then-junk", "bin-1");
|
|
290
|
-
await
|
|
363
|
+
await sweep();
|
|
291
364
|
sighting("bin-then-junk", "spam-1");
|
|
292
|
-
await
|
|
365
|
+
await sweep();
|
|
293
366
|
|
|
294
367
|
assert.equal(withheld("junk-then-bin"), true);
|
|
295
368
|
assert.equal(withheld("bin-then-junk"), true);
|
|
@@ -299,12 +372,12 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
299
372
|
address("spammer");
|
|
300
373
|
sighting("spammer", "spam-1");
|
|
301
374
|
sighting("spammer", "bin-1");
|
|
302
|
-
await
|
|
375
|
+
await sweep();
|
|
303
376
|
|
|
304
377
|
sqlite
|
|
305
378
|
.prepare("UPDATE address SET flags = '{}' WHERE address_id = ?")
|
|
306
379
|
.run("spammer");
|
|
307
|
-
await
|
|
380
|
+
await sweep();
|
|
308
381
|
|
|
309
382
|
assert.equal(withheld("spammer"), true);
|
|
310
383
|
});
|
|
@@ -312,12 +385,12 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
312
385
|
test("deleting every spam message leaves the mark standing", async () => {
|
|
313
386
|
address("spammer");
|
|
314
387
|
sighting("spammer", "spam-1");
|
|
315
|
-
await
|
|
388
|
+
await sweep();
|
|
316
389
|
|
|
317
390
|
sqlite
|
|
318
391
|
.prepare("UPDATE message SET mailbox_id = 'trash' WHERE message_id = ?")
|
|
319
392
|
.run("spam-1");
|
|
320
|
-
const report = await
|
|
393
|
+
const report = await sweep();
|
|
321
394
|
|
|
322
395
|
assert.equal(report.restorable, 0);
|
|
323
396
|
assert.equal(withheld("spammer"), true);
|
|
@@ -326,12 +399,12 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
326
399
|
test("deleting the spam does not put its sender back", async () => {
|
|
327
400
|
address("spammer");
|
|
328
401
|
sighting("spammer", "spam-1");
|
|
329
|
-
await
|
|
402
|
+
await sweep();
|
|
330
403
|
|
|
331
404
|
sqlite
|
|
332
405
|
.prepare("UPDATE message SET mailbox_id = 'trash' WHERE message_id = ?")
|
|
333
406
|
.run("spam-1");
|
|
334
|
-
const report = await
|
|
407
|
+
const report = await sweep();
|
|
335
408
|
|
|
336
409
|
assert.equal(report.restorable, 0);
|
|
337
410
|
assert.equal(withheld("spammer"), true);
|
|
@@ -340,10 +413,10 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
340
413
|
test("purging the spam does not put its sender back", async () => {
|
|
341
414
|
address("spammer");
|
|
342
415
|
sighting("spammer", "spam-1");
|
|
343
|
-
await
|
|
416
|
+
await sweep();
|
|
344
417
|
|
|
345
418
|
sqlite.prepare("DELETE FROM message WHERE message_id = ?").run("spam-1");
|
|
346
|
-
const report = await
|
|
419
|
+
const report = await sweep();
|
|
347
420
|
|
|
348
421
|
assert.equal(report.restorable, 0);
|
|
349
422
|
assert.equal(withheld("spammer"), true);
|
|
@@ -355,7 +428,7 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
355
428
|
sighting("reported", "spam-1");
|
|
356
429
|
sighting("hushed", "spam-1");
|
|
357
430
|
|
|
358
|
-
await
|
|
431
|
+
await sweep();
|
|
359
432
|
|
|
360
433
|
assert.equal(withheld("reported"), true);
|
|
361
434
|
assert.equal(withheld("hushed"), true);
|
|
@@ -364,7 +437,7 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
364
437
|
test("blocking a withheld sender never lifts the mark", async () => {
|
|
365
438
|
address("spammer");
|
|
366
439
|
sighting("spammer", "spam-1");
|
|
367
|
-
await
|
|
440
|
+
await sweep();
|
|
368
441
|
|
|
369
442
|
sqlite
|
|
370
443
|
.prepare(
|
|
@@ -373,96 +446,172 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
373
446
|
WHERE address_id = ?`,
|
|
374
447
|
)
|
|
375
448
|
.run("spammer");
|
|
376
|
-
const report = await
|
|
449
|
+
const report = await sweep();
|
|
377
450
|
|
|
378
451
|
assert.equal(report.restorable, 0);
|
|
379
452
|
assert.equal(withheld("spammer"), true);
|
|
380
453
|
});
|
|
381
454
|
|
|
382
455
|
test("reads a Junk folder a server does not designate", async () => {
|
|
383
|
-
mailbox("named-spam", null);
|
|
456
|
+
mailbox("named-spam", null, { accountId: "acc-named", fullPath: "Spam" });
|
|
384
457
|
message("spam-5", "named-spam");
|
|
385
458
|
address("by-name");
|
|
386
459
|
sighting("by-name", "spam-5");
|
|
387
|
-
sqlite
|
|
388
|
-
.prepare("UPDATE mailbox SET full_path = 'Spam' WHERE mailbox_id = ?")
|
|
389
|
-
.run("named-spam");
|
|
390
460
|
|
|
391
|
-
await
|
|
461
|
+
await sweep();
|
|
392
462
|
|
|
393
463
|
assert.equal(withheld("by-name"), true);
|
|
394
464
|
});
|
|
395
465
|
|
|
396
466
|
test("reads a Junk folder nested under any prefix", async () => {
|
|
397
|
-
mailbox("nested-spam", null
|
|
467
|
+
mailbox("nested-spam", null, {
|
|
468
|
+
accountId: "acc-nested",
|
|
469
|
+
fullPath: "INBOX/Spam",
|
|
470
|
+
});
|
|
398
471
|
message("spam-6", "nested-spam");
|
|
399
472
|
address("nested");
|
|
400
473
|
sighting("nested", "spam-6");
|
|
401
|
-
sqlite
|
|
402
|
-
.prepare(
|
|
403
|
-
"UPDATE mailbox SET full_path = 'INBOX/Spam' WHERE mailbox_id = ?",
|
|
404
|
-
)
|
|
405
|
-
.run("nested-spam");
|
|
406
474
|
|
|
407
|
-
await
|
|
475
|
+
await sweep();
|
|
408
476
|
|
|
409
477
|
assert.equal(withheld("nested"), true);
|
|
410
478
|
});
|
|
411
479
|
|
|
412
480
|
test("reads a Junk folder under a delimiter that is not a slash", async () => {
|
|
413
|
-
mailbox("dotted-spam", null
|
|
481
|
+
mailbox("dotted-spam", null, {
|
|
482
|
+
accountId: "acc-dotted",
|
|
483
|
+
fullPath: "Mail.Junk E-mail",
|
|
484
|
+
hierarchyDelimiter: ".",
|
|
485
|
+
});
|
|
414
486
|
message("spam-7", "dotted-spam");
|
|
415
487
|
address("dotted");
|
|
416
488
|
sighting("dotted", "spam-7");
|
|
417
|
-
sqlite
|
|
418
|
-
.prepare(
|
|
419
|
-
`UPDATE mailbox SET full_path = 'Mail.Junk E-mail',
|
|
420
|
-
hierarchy_delimiter = '.' WHERE mailbox_id = ?`,
|
|
421
|
-
)
|
|
422
|
-
.run("dotted-spam");
|
|
423
489
|
|
|
424
|
-
await
|
|
490
|
+
await sweep();
|
|
425
491
|
|
|
426
492
|
assert.equal(withheld("dotted"), true);
|
|
427
493
|
});
|
|
428
494
|
|
|
429
495
|
test("never reads a prefix as the folder it names", async () => {
|
|
430
|
-
mailbox("spam-parent", null
|
|
496
|
+
mailbox("spam-parent", null, {
|
|
497
|
+
accountId: "acc-parent",
|
|
498
|
+
fullPath: "Spam/Receipts",
|
|
499
|
+
});
|
|
431
500
|
message("mail-2", "spam-parent");
|
|
432
501
|
address("under-spam");
|
|
433
502
|
sighting("under-spam", "mail-2");
|
|
434
|
-
sqlite
|
|
435
|
-
.prepare(
|
|
436
|
-
"UPDATE mailbox SET full_path = 'Spam/Receipts' WHERE mailbox_id = ?",
|
|
437
|
-
)
|
|
438
|
-
.run("spam-parent");
|
|
439
503
|
|
|
440
|
-
await
|
|
504
|
+
await sweep();
|
|
441
505
|
|
|
442
506
|
assert.equal(withheld("under-spam"), false);
|
|
443
507
|
});
|
|
444
508
|
|
|
445
|
-
test("reads the designation
|
|
446
|
-
mailbox("
|
|
447
|
-
|
|
509
|
+
test("reads the designation the mailbox sync stored", async () => {
|
|
510
|
+
mailbox("entry-only", null, {
|
|
511
|
+
accountId: "acc-entry",
|
|
512
|
+
fullPath: "Bulk",
|
|
513
|
+
});
|
|
448
514
|
specialUseEntry("entry-only", "Junk");
|
|
449
|
-
message("spam-3", "column-only");
|
|
450
515
|
message("spam-4", "entry-only");
|
|
451
|
-
address("by-column");
|
|
452
516
|
address("by-entry");
|
|
453
|
-
sighting("by-column", "spam-3");
|
|
454
517
|
sighting("by-entry", "spam-4");
|
|
455
518
|
|
|
456
|
-
await
|
|
519
|
+
await sweep();
|
|
457
520
|
|
|
458
|
-
assert.equal(withheld("by-column"), true);
|
|
459
521
|
assert.equal(withheld("by-entry"), true);
|
|
460
522
|
});
|
|
461
523
|
|
|
524
|
+
test("a second folder named Spam never claims the role", async () => {
|
|
525
|
+
mailbox("second-spam", null, { fullPath: "Archive/Spam" });
|
|
526
|
+
message("spam-3", "second-spam");
|
|
527
|
+
address("filed-away");
|
|
528
|
+
sighting("filed-away", "spam-3");
|
|
529
|
+
|
|
530
|
+
await sweep();
|
|
531
|
+
|
|
532
|
+
assert.equal(withheld("filed-away"), false);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("withholds on the Junk folder the account appointed", async () => {
|
|
536
|
+
mailbox("rubbish", null, {
|
|
537
|
+
accountId: "acc-appointed",
|
|
538
|
+
fullPath: "INBOX/Rubbish",
|
|
539
|
+
});
|
|
540
|
+
await appointJunk("acc-appointed", "rubbish");
|
|
541
|
+
message("spam-8", "rubbish");
|
|
542
|
+
address("appointed-spammer");
|
|
543
|
+
sighting("appointed-spammer", "spam-8");
|
|
544
|
+
|
|
545
|
+
await sweep();
|
|
546
|
+
|
|
547
|
+
assert.equal(withheld("appointed-spammer"), true);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
test("leaves the same folder alone when nobody appointed it", async () => {
|
|
551
|
+
mailbox("unclaimed-rubbish", null, {
|
|
552
|
+
accountId: "acc-unappointed",
|
|
553
|
+
fullPath: "INBOX/Rubbish",
|
|
554
|
+
});
|
|
555
|
+
message("spam-9", "unclaimed-rubbish");
|
|
556
|
+
address("stranger");
|
|
557
|
+
sighting("stranger", "spam-9");
|
|
558
|
+
|
|
559
|
+
await sweep();
|
|
560
|
+
|
|
561
|
+
assert.equal(withheld("stranger"), false);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* No folder holds Junk anywhere, so nothing is known about any sighting. The
|
|
566
|
+
* predicate must be silent in both directions: an empty id list read as "no
|
|
567
|
+
* message is in Junk" would make every sighting live mail and hand back
|
|
568
|
+
* every mark the instance had earned.
|
|
569
|
+
*/
|
|
570
|
+
test("an instance with no Junk folder neither withholds nor restores", async () => {
|
|
571
|
+
for (const table of [
|
|
572
|
+
"envelope_address",
|
|
573
|
+
"message",
|
|
574
|
+
"mailbox",
|
|
575
|
+
"mailbox_special_use_entry",
|
|
576
|
+
]) {
|
|
577
|
+
sqlite.exec(`DELETE FROM ${table}`);
|
|
578
|
+
}
|
|
579
|
+
mailbox("plain-inbox", null, { fullPath: "INBOX" });
|
|
580
|
+
message("mail-9", "plain-inbox");
|
|
581
|
+
address("colleague");
|
|
582
|
+
address("misfiled", {}, '{"junkOnly":{"value":true,"setAt":1}}');
|
|
583
|
+
sighting("colleague", "mail-9");
|
|
584
|
+
sighting("misfiled", "mail-9");
|
|
585
|
+
|
|
586
|
+
const report = await sweep();
|
|
587
|
+
|
|
588
|
+
assert.equal(report.withholdable, 0);
|
|
589
|
+
assert.equal(report.restorable, 0);
|
|
590
|
+
assert.equal(withheld("colleague"), false);
|
|
591
|
+
assert.equal(withheld("misfiled"), true);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("standing the account gave an address lifts its mark regardless", async () => {
|
|
595
|
+
for (const table of ["mailbox", "mailbox_special_use_entry"]) {
|
|
596
|
+
sqlite.exec(`DELETE FROM ${table}`);
|
|
597
|
+
}
|
|
598
|
+
mailbox("plain-inbox", null, { fullPath: "INBOX" });
|
|
599
|
+
address(
|
|
600
|
+
"reformed",
|
|
601
|
+
{ outbound: 1 },
|
|
602
|
+
'{"junkOnly":{"value":true,"setAt":1}}',
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
const report = await sweep();
|
|
606
|
+
|
|
607
|
+
assert.equal(report.restorable, 1);
|
|
608
|
+
assert.equal(withheld("reformed"), false);
|
|
609
|
+
});
|
|
610
|
+
|
|
462
611
|
test("leaves an address no message has ever carried alone", async () => {
|
|
463
612
|
address("orphan");
|
|
464
613
|
|
|
465
|
-
const report = await
|
|
614
|
+
const report = await sweep();
|
|
466
615
|
|
|
467
616
|
assert.equal(report.withholdable, 0);
|
|
468
617
|
assert.equal(withheld("orphan"), false);
|
|
@@ -476,7 +625,7 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
476
625
|
sighting("colleague", "mail-1");
|
|
477
626
|
sighting("client", "spam-2");
|
|
478
627
|
|
|
479
|
-
await
|
|
628
|
+
await sweep();
|
|
480
629
|
|
|
481
630
|
assert.equal(addressCount(), 3);
|
|
482
631
|
});
|
|
@@ -485,7 +634,7 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
485
634
|
address("noisy", {}, '{"muted":{"value":true,"setAt":7}}');
|
|
486
635
|
sighting("noisy", "spam-1");
|
|
487
636
|
|
|
488
|
-
await
|
|
637
|
+
await sweep();
|
|
489
638
|
|
|
490
639
|
assert.deepEqual(JSON.parse(read("noisy").flags).muted, {
|
|
491
640
|
value: true,
|
|
@@ -496,10 +645,10 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
496
645
|
test("a second run writes nothing", async () => {
|
|
497
646
|
address("spammer");
|
|
498
647
|
sighting("spammer", "spam-1");
|
|
499
|
-
await
|
|
648
|
+
await sweep();
|
|
500
649
|
const first = read("spammer");
|
|
501
650
|
|
|
502
|
-
const report = await
|
|
651
|
+
const report = await sweep();
|
|
503
652
|
|
|
504
653
|
assert.equal(report.withholdable, 0);
|
|
505
654
|
assert.equal(report.restorable, 0);
|
|
@@ -514,7 +663,7 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
514
663
|
address("misfiled", {}, '{"junkOnly":{"value":true,"setAt":1}}');
|
|
515
664
|
sighting("misfiled", "mail-1");
|
|
516
665
|
|
|
517
|
-
const report = await
|
|
666
|
+
const report = await sweep("check");
|
|
518
667
|
|
|
519
668
|
assert.equal(report.withholdable, 1);
|
|
520
669
|
assert.equal(report.restorable, 1);
|
|
@@ -528,7 +677,7 @@ describe("addresses standing only on mail in Junk", () => {
|
|
|
528
677
|
address("legacy", {}, "");
|
|
529
678
|
sighting("legacy", "spam-1");
|
|
530
679
|
|
|
531
|
-
await
|
|
680
|
+
await sweep();
|
|
532
681
|
|
|
533
682
|
assert.equal(withheld("legacy"), true);
|
|
534
683
|
});
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
JUNK_FOLDER_NAMES,
|
|
3
|
-
TRASH_FOLDER_NAMES,
|
|
4
|
-
} from "@remit/data-ports/mailbox-role";
|
|
5
|
-
import { MailboxSpecialUse } from "@remit/domain-enums";
|
|
1
|
+
import type { JunkRoleMailboxes } from "@remit/data-ports/folder-role";
|
|
6
2
|
|
|
7
3
|
export interface JunkOnlyRepairClient {
|
|
8
4
|
all(sql: string, params: readonly unknown[]): Promise<unknown[]>;
|
|
@@ -19,6 +15,12 @@ export interface JunkOnlyReport {
|
|
|
19
15
|
readonly restored: number;
|
|
20
16
|
}
|
|
21
17
|
|
|
18
|
+
/** A statement or fragment with the values its `?` placeholders take, in order. */
|
|
19
|
+
export interface BoundSql {
|
|
20
|
+
readonly sql: string;
|
|
21
|
+
readonly params: readonly unknown[];
|
|
22
|
+
}
|
|
23
|
+
|
|
22
24
|
export const JUNK_ONLY_FLAG = "junkOnly";
|
|
23
25
|
|
|
24
26
|
const STORED_FLAGS = "coalesce(nullif(address.flags, ''), '{}')";
|
|
@@ -26,41 +28,23 @@ const STORED_FLAGS = "coalesce(nullif(address.flags, ''), '{}')";
|
|
|
26
28
|
const flagIsSet = (name: string): string =>
|
|
27
29
|
`coalesce(json_extract(${STORED_FLAGS}, '$.${name}.value'), 0) = 1`;
|
|
28
30
|
|
|
29
|
-
const
|
|
30
|
-
names.map((name) => `'${name}'`).join(", ");
|
|
31
|
-
|
|
32
|
-
const MAILBOX_LEAF = `lower(substr(
|
|
33
|
-
mailbox.full_path,
|
|
34
|
-
length(rtrim(
|
|
35
|
-
mailbox.full_path,
|
|
36
|
-
replace(mailbox.full_path, mailbox.hierarchy_delimiter, '')
|
|
37
|
-
)) + 1
|
|
38
|
-
))`;
|
|
39
|
-
|
|
40
|
-
const mailboxCarriesRole = (
|
|
41
|
-
specialUse: string,
|
|
42
|
-
names: readonly string[],
|
|
43
|
-
): string => `(
|
|
44
|
-
exists (
|
|
45
|
-
SELECT 1 FROM mailbox_special_use_entry entry
|
|
46
|
-
WHERE entry.mailbox_id = message.mailbox_id
|
|
47
|
-
AND entry.special_use = '${specialUse}'
|
|
48
|
-
)
|
|
49
|
-
OR exists (
|
|
50
|
-
SELECT 1 FROM mailbox
|
|
51
|
-
WHERE mailbox.mailbox_id = message.mailbox_id
|
|
52
|
-
AND (
|
|
53
|
-
mailbox.special_use LIKE '%"${specialUse}"%'
|
|
54
|
-
OR ${MAILBOX_LEAF} IN (${quoted(names)})
|
|
55
|
-
)
|
|
56
|
-
)
|
|
57
|
-
)`;
|
|
31
|
+
const EMPTY: BoundSql = { sql: "", params: [] };
|
|
58
32
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
33
|
+
const MATCHES_NOTHING: BoundSql = { sql: "0 = 1", params: [] };
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether the message sits in one of these mailboxes. An empty list is
|
|
37
|
+
* false — no mailbox holds the role, so no message is in one. That reading is
|
|
38
|
+
* right for Trash and wrong for Junk, which is why an unresolvable Junk folder
|
|
39
|
+
* is handled before this is ever reached.
|
|
40
|
+
*/
|
|
41
|
+
const inMailboxes = (mailboxIds: readonly string[]): BoundSql =>
|
|
42
|
+
mailboxIds.length === 0
|
|
43
|
+
? { sql: "(0 = 1)", params: [] }
|
|
44
|
+
: {
|
|
45
|
+
sql: `(message.mailbox_id IN (${mailboxIds.map(() => "?").join(", ")}))`,
|
|
46
|
+
params: mailboxIds,
|
|
47
|
+
};
|
|
64
48
|
|
|
65
49
|
const sightingWhere = (extra: string): string => `exists (
|
|
66
50
|
SELECT 1 FROM envelope_address
|
|
@@ -68,47 +52,95 @@ const sightingWhere = (extra: string): string => `exists (
|
|
|
68
52
|
WHERE envelope_address.address_id = address.address_id${extra}
|
|
69
53
|
)`;
|
|
70
54
|
|
|
71
|
-
const
|
|
72
|
-
const SIGHTING_IN_LIVE_MAIL = sightingWhere(
|
|
73
|
-
` AND NOT ${IN_JUNK} AND NOT ${IN_TRASH}`,
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
export const ACCOUNT_HAS_CORRESPONDED = `(
|
|
55
|
+
const ACCOUNT_HAS_CORRESPONDED = `(
|
|
77
56
|
address.outbound_count > 0
|
|
78
57
|
OR address.reply_count > 0
|
|
79
58
|
OR ${flagIsSet("vip")}
|
|
80
59
|
OR ${flagIsSet("trusted")}
|
|
81
60
|
)`;
|
|
82
61
|
|
|
83
|
-
|
|
62
|
+
/**
|
|
63
|
+
* No account in scope has a Junk folder, so nothing is known about any
|
|
64
|
+
* sighting: the mark cannot be earned. Silence is not the same as "every
|
|
65
|
+
* message is live mail" — read that way, one move would restore every address
|
|
66
|
+
* the sweep had withheld.
|
|
67
|
+
*/
|
|
68
|
+
const withholdable = (roles: JunkRoleMailboxes): BoundSql => {
|
|
69
|
+
if (roles.junkMailboxIds.length === 0) return MATCHES_NOTHING;
|
|
70
|
+
const inJunk = inMailboxes(roles.junkMailboxIds);
|
|
71
|
+
const inTrash = inMailboxes(roles.trashMailboxIds);
|
|
72
|
+
return {
|
|
73
|
+
sql: `NOT ${flagIsSet(JUNK_ONLY_FLAG)}
|
|
84
74
|
AND NOT ${ACCOUNT_HAS_CORRESPONDED}
|
|
85
|
-
AND ${
|
|
86
|
-
AND NOT ${
|
|
75
|
+
AND ${sightingWhere(` AND ${inJunk.sql}`)}
|
|
76
|
+
AND NOT ${sightingWhere(` AND NOT ${inJunk.sql} AND NOT ${inTrash.sql}`)}`,
|
|
77
|
+
params: [...inJunk.params, ...inJunk.params, ...inTrash.params],
|
|
78
|
+
};
|
|
79
|
+
};
|
|
87
80
|
|
|
88
|
-
|
|
89
|
-
|
|
81
|
+
/**
|
|
82
|
+
* The same silence lifts no mark either — "stands on live mail" is
|
|
83
|
+
* unanswerable without knowing which folder is Junk. Standing the account
|
|
84
|
+
* gave the address itself still lifts it: that evidence needs no folder.
|
|
85
|
+
*/
|
|
86
|
+
const restorable = (roles: JunkRoleMailboxes): BoundSql => {
|
|
87
|
+
if (roles.junkMailboxIds.length === 0) {
|
|
88
|
+
return {
|
|
89
|
+
sql: `${flagIsSet(JUNK_ONLY_FLAG)}
|
|
90
|
+
AND ${ACCOUNT_HAS_CORRESPONDED}`,
|
|
91
|
+
params: [],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const inJunk = inMailboxes(roles.junkMailboxIds);
|
|
95
|
+
const inTrash = inMailboxes(roles.trashMailboxIds);
|
|
96
|
+
return {
|
|
97
|
+
sql: `${flagIsSet(JUNK_ONLY_FLAG)}
|
|
98
|
+
AND (${ACCOUNT_HAS_CORRESPONDED}
|
|
99
|
+
OR ${sightingWhere(` AND NOT ${inJunk.sql} AND NOT ${inTrash.sql}`)})`,
|
|
100
|
+
params: [...inJunk.params, ...inTrash.params],
|
|
101
|
+
};
|
|
102
|
+
};
|
|
90
103
|
|
|
91
|
-
export const withholdSql = (
|
|
92
|
-
|
|
104
|
+
export const withholdSql = (
|
|
105
|
+
roles: JunkRoleMailboxes,
|
|
106
|
+
now: number,
|
|
107
|
+
setBy: string,
|
|
108
|
+
scope: BoundSql = EMPTY,
|
|
109
|
+
): BoundSql => {
|
|
110
|
+
const predicate = withholdable(roles);
|
|
111
|
+
return {
|
|
112
|
+
sql: `UPDATE address
|
|
93
113
|
SET flags = json_set(${STORED_FLAGS}, '$.${JUNK_ONLY_FLAG}',
|
|
94
114
|
json_object('value', json('true'), 'setAt', CAST(? AS INTEGER), 'setBy', ?)),
|
|
95
115
|
updated_at = ?
|
|
96
|
-
WHERE ${
|
|
116
|
+
WHERE ${predicate.sql}${scope.sql}`,
|
|
117
|
+
params: [now, setBy, now, ...predicate.params, ...scope.params],
|
|
118
|
+
};
|
|
119
|
+
};
|
|
97
120
|
|
|
98
|
-
export const restoreSql = (
|
|
99
|
-
|
|
121
|
+
export const restoreSql = (
|
|
122
|
+
roles: JunkRoleMailboxes,
|
|
123
|
+
now: number,
|
|
124
|
+
scope: BoundSql = EMPTY,
|
|
125
|
+
): BoundSql => {
|
|
126
|
+
const predicate = restorable(roles);
|
|
127
|
+
return {
|
|
128
|
+
sql: `UPDATE address
|
|
100
129
|
SET flags = json_remove(${STORED_FLAGS}, '$.${JUNK_ONLY_FLAG}'), updated_at = ?
|
|
101
|
-
WHERE ${
|
|
130
|
+
WHERE ${predicate.sql}${scope.sql}`,
|
|
131
|
+
params: [now, ...predicate.params, ...scope.params],
|
|
132
|
+
};
|
|
133
|
+
};
|
|
102
134
|
|
|
103
135
|
const REPAIR_SET_BY = "junk-only-repair";
|
|
104
136
|
|
|
105
137
|
const countWhere = async (
|
|
106
138
|
client: JunkOnlyRepairClient,
|
|
107
|
-
predicate:
|
|
139
|
+
predicate: BoundSql,
|
|
108
140
|
): Promise<number> => {
|
|
109
141
|
const [row] = (await client.all(
|
|
110
|
-
`SELECT count(*) AS row_count FROM address WHERE ${predicate}`,
|
|
111
|
-
|
|
142
|
+
`SELECT count(*) AS row_count FROM address WHERE ${predicate.sql}`,
|
|
143
|
+
predicate.params,
|
|
112
144
|
)) as { row_count: number }[];
|
|
113
145
|
return row?.row_count ?? 0;
|
|
114
146
|
};
|
|
@@ -116,23 +148,39 @@ const countWhere = async (
|
|
|
116
148
|
export const sweepJunkOnlyAddresses = async (
|
|
117
149
|
client: JunkOnlyRepairClient,
|
|
118
150
|
mode: JunkOnlyRepairMode,
|
|
151
|
+
roles: JunkRoleMailboxes,
|
|
119
152
|
now: number = Date.now(),
|
|
120
153
|
): Promise<JunkOnlyReport> => {
|
|
121
|
-
const
|
|
122
|
-
const
|
|
154
|
+
const withholdableCount = await countWhere(client, withholdable(roles));
|
|
155
|
+
const restorableCount = await countWhere(client, restorable(roles));
|
|
123
156
|
|
|
124
157
|
if (mode === "check") {
|
|
125
|
-
return {
|
|
158
|
+
return {
|
|
159
|
+
mode,
|
|
160
|
+
withholdable: withholdableCount,
|
|
161
|
+
withheld: 0,
|
|
162
|
+
restorable: restorableCount,
|
|
163
|
+
restored: 0,
|
|
164
|
+
};
|
|
126
165
|
}
|
|
127
166
|
|
|
167
|
+
const withhold = withholdSql(roles, now, REPAIR_SET_BY);
|
|
128
168
|
const withheld =
|
|
129
|
-
|
|
169
|
+
withholdableCount === 0
|
|
130
170
|
? 0
|
|
131
|
-
: await client.run(
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
171
|
+
: await client.run(withhold.sql, withhold.params);
|
|
172
|
+
|
|
173
|
+
const restore = restoreSql(roles, now);
|
|
174
|
+
const restored =
|
|
175
|
+
restorableCount === 0 ? 0 : await client.run(restore.sql, restore.params);
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
mode,
|
|
179
|
+
withholdable: withholdableCount,
|
|
180
|
+
withheld,
|
|
181
|
+
restorable: restorableCount,
|
|
182
|
+
restored,
|
|
183
|
+
};
|
|
136
184
|
};
|
|
137
185
|
|
|
138
186
|
export const formatJunkOnlyReport = (report: JunkOnlyReport): string[] => {
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
IAccountSettingRepository,
|
|
6
6
|
UpsertAccountSettingInput,
|
|
7
7
|
} from "@remit/data-ports";
|
|
8
|
-
import { eq } from "drizzle-orm";
|
|
8
|
+
import { and, eq, inArray } from "drizzle-orm";
|
|
9
9
|
import type { Db } from "../db.js";
|
|
10
10
|
import { accountSettingTable } from "../schema/i4-account-setting.js";
|
|
11
11
|
|
|
@@ -48,6 +48,28 @@ export class AccountSettingRepo implements IAccountSettingRepository {
|
|
|
48
48
|
return row ? rowToAccountSetting(row) : null;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The named settings of several configs in one read, for callers that would
|
|
53
|
+
* otherwise `get` once per account. Missing rows are simply absent from the
|
|
54
|
+
* result rather than an error.
|
|
55
|
+
*/
|
|
56
|
+
async getMany(
|
|
57
|
+
accountConfigIds: readonly string[],
|
|
58
|
+
names: readonly string[],
|
|
59
|
+
): Promise<AccountSettingItem[]> {
|
|
60
|
+
if (accountConfigIds.length === 0 || names.length === 0) return [];
|
|
61
|
+
const rows = await this.db
|
|
62
|
+
.select()
|
|
63
|
+
.from(accountSettingTable)
|
|
64
|
+
.where(
|
|
65
|
+
and(
|
|
66
|
+
inArray(accountSettingTable.accountConfigId, [...accountConfigIds]),
|
|
67
|
+
inArray(accountSettingTable.name, [...names]),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
return rows.map(rowToAccountSetting);
|
|
71
|
+
}
|
|
72
|
+
|
|
51
73
|
async listByAccountConfig(
|
|
52
74
|
accountConfigId: string,
|
|
53
75
|
): Promise<AccountSettingItem[]> {
|
|
@@ -3,6 +3,7 @@ import { after, before, beforeEach, describe, test } from "node:test";
|
|
|
3
3
|
import type Database from "better-sqlite3";
|
|
4
4
|
import { createTestDb, type TestDb } from "../test-db.js";
|
|
5
5
|
import { AddressRepo } from "./i4-address.js";
|
|
6
|
+
import { MailboxSpecialUseRepo } from "./i4-mailbox-special-use.js";
|
|
6
7
|
|
|
7
8
|
const CONFIG = "cfg-1";
|
|
8
9
|
|
|
@@ -11,8 +12,34 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
11
12
|
let sqlite: Database.Database;
|
|
12
13
|
let close: () => Promise<void>;
|
|
13
14
|
let repo: AddressRepo;
|
|
15
|
+
let specialUse: MailboxSpecialUseRepo;
|
|
14
16
|
|
|
15
|
-
const
|
|
17
|
+
const account = (accountId: string): void => {
|
|
18
|
+
sqlite
|
|
19
|
+
.prepare(
|
|
20
|
+
`INSERT OR IGNORE INTO account (
|
|
21
|
+
account_id, account_config_id, username, email, imap_host,
|
|
22
|
+
imap_port, imap_tls, imap_start_tls, smtp_port, is_active,
|
|
23
|
+
connection_state, created_at, updated_at
|
|
24
|
+
) VALUES (?, ?, 'user', 'user@example.com', 'imap.example.com',
|
|
25
|
+
993, 1, 0, 465, 1, 'disconnected', 0, 0)`,
|
|
26
|
+
)
|
|
27
|
+
.run(accountId, CONFIG);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A folder the server designated, written the way mailbox sync writes one:
|
|
32
|
+
* the denormalized column AND the entry row. The paths are deliberately not
|
|
33
|
+
* English — the role has to be read off the designation, not guessed from
|
|
34
|
+
* the name.
|
|
35
|
+
*/
|
|
36
|
+
const mailbox = (
|
|
37
|
+
mailboxId: string,
|
|
38
|
+
designation: string | null,
|
|
39
|
+
folder: { accountId?: string; fullPath?: string } = {},
|
|
40
|
+
): void => {
|
|
41
|
+
const accountId = folder.accountId ?? "acc";
|
|
42
|
+
account(accountId);
|
|
16
43
|
sqlite
|
|
17
44
|
.prepare(
|
|
18
45
|
`INSERT INTO mailbox (
|
|
@@ -21,9 +48,22 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
21
48
|
unseen_count, deleted_count, total_size, last_sync_uid,
|
|
22
49
|
high_water_mark_uid, last_message_sync_at, special_use,
|
|
23
50
|
created_at, updated_at
|
|
24
|
-
) VALUES (?,
|
|
51
|
+
) VALUES (?, ?, '', '/', ?, 1, 1, '0', 0, 0, 0, 0, 0, 0, 0, ?, 0, 0)`,
|
|
52
|
+
)
|
|
53
|
+
.run(
|
|
54
|
+
mailboxId,
|
|
55
|
+
accountId,
|
|
56
|
+
folder.fullPath ?? mailboxId,
|
|
57
|
+
designation ? JSON.stringify([designation]) : null,
|
|
58
|
+
);
|
|
59
|
+
if (!designation) return;
|
|
60
|
+
sqlite
|
|
61
|
+
.prepare(
|
|
62
|
+
`INSERT INTO mailbox_special_use_entry (
|
|
63
|
+
mailbox_special_use_id, mailbox_id, special_use
|
|
64
|
+
) VALUES (?, ?, ?)`,
|
|
25
65
|
)
|
|
26
|
-
.run(mailboxId
|
|
66
|
+
.run(`${mailboxId}-${designation}`, mailboxId, designation);
|
|
27
67
|
};
|
|
28
68
|
|
|
29
69
|
const message = (messageId: string, mailboxId: string): void => {
|
|
@@ -84,19 +124,38 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
84
124
|
before(async () => {
|
|
85
125
|
({ db, sqlite, close } = await createTestDb());
|
|
86
126
|
repo = new AddressRepo(db as never);
|
|
127
|
+
specialUse = new MailboxSpecialUseRepo(db as never);
|
|
87
128
|
});
|
|
88
129
|
|
|
130
|
+
/**
|
|
131
|
+
* The roles the caller resolves once and hands down, covering every account
|
|
132
|
+
* under the config — which is the scope the predicate reads, because the
|
|
133
|
+
* address book is keyed by config.
|
|
134
|
+
*/
|
|
135
|
+
const reconcile = async (messageId: string): Promise<void> =>
|
|
136
|
+
repo.reconcileJunkOnlyForMessage(
|
|
137
|
+
messageId,
|
|
138
|
+
await specialUse.resolveJunkRolesForConfig(CONFIG),
|
|
139
|
+
);
|
|
140
|
+
|
|
89
141
|
after(async () => {
|
|
90
142
|
await close();
|
|
91
143
|
});
|
|
92
144
|
|
|
93
145
|
beforeEach(() => {
|
|
94
|
-
for (const table of [
|
|
146
|
+
for (const table of [
|
|
147
|
+
"address",
|
|
148
|
+
"envelope_address",
|
|
149
|
+
"message",
|
|
150
|
+
"mailbox",
|
|
151
|
+
"mailbox_special_use_entry",
|
|
152
|
+
"account",
|
|
153
|
+
]) {
|
|
95
154
|
sqlite.exec(`DELETE FROM ${table}`);
|
|
96
155
|
}
|
|
97
|
-
mailbox("inbox", null);
|
|
98
|
-
mailbox("junk",
|
|
99
|
-
mailbox("trash",
|
|
156
|
+
mailbox("inbox", null, { fullPath: "INBOX" });
|
|
157
|
+
mailbox("junk", "Junk", { fullPath: "Ongewenst" });
|
|
158
|
+
mailbox("trash", "Trash", { fullPath: "Prullenbak" });
|
|
100
159
|
});
|
|
101
160
|
|
|
102
161
|
test("a message moved into Junk stops the sender being suggested", async () => {
|
|
@@ -106,7 +165,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
106
165
|
assert.deepEqual(await suggested("spammer"), ["spammer"]);
|
|
107
166
|
|
|
108
167
|
moveTo("msg", "junk");
|
|
109
|
-
await
|
|
168
|
+
await reconcile("msg");
|
|
110
169
|
|
|
111
170
|
assert.equal(await withheld("spammer"), true);
|
|
112
171
|
assert.deepEqual(await suggested("spammer"), []);
|
|
@@ -119,7 +178,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
119
178
|
await repo.incrementOutboundCount(CONFIG, "client", Date.now());
|
|
120
179
|
|
|
121
180
|
moveTo("msg", "junk");
|
|
122
|
-
await
|
|
181
|
+
await reconcile("msg");
|
|
123
182
|
|
|
124
183
|
assert.equal(await withheld("client"), false);
|
|
125
184
|
});
|
|
@@ -132,7 +191,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
132
191
|
sighting("colleague", "real");
|
|
133
192
|
|
|
134
193
|
moveTo("spam", "junk");
|
|
135
|
-
await
|
|
194
|
+
await reconcile("spam");
|
|
136
195
|
|
|
137
196
|
assert.equal(await withheld("colleague"), false);
|
|
138
197
|
});
|
|
@@ -152,7 +211,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
152
211
|
assert.deepEqual(await suggested("misfiled"), []);
|
|
153
212
|
|
|
154
213
|
moveTo("msg", "inbox");
|
|
155
|
-
await
|
|
214
|
+
await reconcile("msg");
|
|
156
215
|
|
|
157
216
|
assert.deepEqual(await suggested("misfiled"), ["misfiled"]);
|
|
158
217
|
});
|
|
@@ -171,7 +230,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
171
230
|
sighting("spammer", "msg");
|
|
172
231
|
|
|
173
232
|
moveTo("msg", "trash");
|
|
174
|
-
await
|
|
233
|
+
await reconcile("msg");
|
|
175
234
|
|
|
176
235
|
assert.equal(await withheld("spammer"), true);
|
|
177
236
|
});
|
|
@@ -183,20 +242,65 @@ describe("reconciling one message's addresses at the moment it moves", () => {
|
|
|
183
242
|
sighting("bystander", "other");
|
|
184
243
|
|
|
185
244
|
moveTo("msg", "junk");
|
|
186
|
-
await
|
|
245
|
+
await reconcile("msg");
|
|
187
246
|
|
|
188
247
|
assert.equal(await withheld("bystander"), false);
|
|
189
248
|
});
|
|
190
249
|
|
|
250
|
+
test("a sender met only in Junk on two accounts of one config stays withheld", async () => {
|
|
251
|
+
mailbox("junk-b", "Junk", {
|
|
252
|
+
accountId: "acc-b",
|
|
253
|
+
fullPath: "Indésirables",
|
|
254
|
+
});
|
|
255
|
+
message("ma", "junk");
|
|
256
|
+
message("mb", "junk-b");
|
|
257
|
+
await harvest("spammer");
|
|
258
|
+
sighting("spammer", "ma");
|
|
259
|
+
sighting("spammer", "mb");
|
|
260
|
+
|
|
261
|
+
await reconcile("ma");
|
|
262
|
+
assert.equal(await withheld("spammer"), true);
|
|
263
|
+
|
|
264
|
+
await reconcile("mb");
|
|
265
|
+
|
|
266
|
+
assert.equal(await withheld("spammer"), true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The name hints are English only, so an account whose server flags nothing
|
|
271
|
+
* and whose Junk folder is called `Ongewenst` resolves no Junk at all. That
|
|
272
|
+
* silence must lift no mark: reading it as "every sighting is live mail"
|
|
273
|
+
* would hand the spammer back on the next move.
|
|
274
|
+
*/
|
|
275
|
+
test("a move never lifts a mark when no folder holds Junk", async () => {
|
|
276
|
+
sqlite.exec("DELETE FROM mailbox_special_use_entry");
|
|
277
|
+
sqlite.exec("UPDATE mailbox SET special_use = NULL");
|
|
278
|
+
message("msg", "inbox");
|
|
279
|
+
await repo.upsertJunkAddress({
|
|
280
|
+
addressId: "spammer",
|
|
281
|
+
accountConfigId: CONFIG,
|
|
282
|
+
displayName: "Name",
|
|
283
|
+
localPart: "spammer",
|
|
284
|
+
domain: "example.com",
|
|
285
|
+
normalizedEmail: "spammer@example.com",
|
|
286
|
+
normalizedCompound: "name spammer@example.com",
|
|
287
|
+
});
|
|
288
|
+
sighting("spammer", "msg");
|
|
289
|
+
|
|
290
|
+
await reconcile("msg");
|
|
291
|
+
|
|
292
|
+
assert.equal(await withheld("spammer"), true);
|
|
293
|
+
});
|
|
294
|
+
|
|
191
295
|
test("a second reconcile of the same move writes nothing new", async () => {
|
|
192
296
|
message("msg", "inbox");
|
|
193
297
|
await harvest("spammer");
|
|
194
298
|
sighting("spammer", "msg");
|
|
195
299
|
moveTo("msg", "junk");
|
|
196
|
-
await
|
|
300
|
+
await reconcile("msg");
|
|
197
301
|
const first = await repo.getAddress(CONFIG, "spammer");
|
|
198
302
|
|
|
199
|
-
await
|
|
303
|
+
await reconcile("msg");
|
|
200
304
|
|
|
201
305
|
assert.deepEqual(await repo.getAddress(CONFIG, "spammer"), first);
|
|
202
306
|
});
|
package/src/repos/i4-address.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
UpdateAddressInput,
|
|
11
11
|
} from "@remit/data-ports";
|
|
12
12
|
import { BadRequestError } from "@remit/data-ports/errors";
|
|
13
|
+
import type { JunkRoleMailboxes } from "@remit/data-ports/folder-role";
|
|
13
14
|
import { shouldPromoteWellknown } from "@remit/data-ports/wellknown";
|
|
14
15
|
import {
|
|
15
16
|
and,
|
|
@@ -26,6 +27,7 @@ import { NotFoundError } from "../error.js";
|
|
|
26
27
|
import { envelopeAddressId } from "../id.js";
|
|
27
28
|
import { decodeToken, resultList } from "../pagination.js";
|
|
28
29
|
import {
|
|
30
|
+
type BoundSql,
|
|
29
31
|
JUNK_ONLY_FLAG,
|
|
30
32
|
restoreSql,
|
|
31
33
|
withholdSql,
|
|
@@ -331,15 +333,21 @@ export class AddressRepo implements IAddressRepository {
|
|
|
331
333
|
return rowToAddress(row);
|
|
332
334
|
}
|
|
333
335
|
|
|
334
|
-
async reconcileJunkOnlyForMessage(
|
|
335
|
-
|
|
336
|
+
async reconcileJunkOnlyForMessage(
|
|
337
|
+
messageId: string,
|
|
338
|
+
roles: JunkRoleMailboxes,
|
|
339
|
+
): Promise<void> {
|
|
340
|
+
const scope: BoundSql = {
|
|
341
|
+
sql: ` AND address.address_id IN (
|
|
336
342
|
SELECT address_id FROM envelope_address WHERE message_id = ?
|
|
337
|
-
)
|
|
343
|
+
)`,
|
|
344
|
+
params: [messageId],
|
|
345
|
+
};
|
|
338
346
|
const now = Date.now();
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
);
|
|
342
|
-
await this.db.run(boundToDrizzle(
|
|
347
|
+
const withhold = withholdSql(roles, now, JUNK_MOVE, scope);
|
|
348
|
+
await this.db.run(boundToDrizzle(withhold.sql, withhold.params));
|
|
349
|
+
const restore = restoreSql(roles, now, scope);
|
|
350
|
+
await this.db.run(boundToDrizzle(restore.sql, restore.params));
|
|
343
351
|
}
|
|
344
352
|
|
|
345
353
|
async getAddress(
|
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
import {
|
|
7
7
|
type CanonicalMailboxRoleValue,
|
|
8
8
|
composeFolderRoleAppointmentName,
|
|
9
|
+
type JunkRoleMailboxes,
|
|
9
10
|
type RoleMailboxCandidate,
|
|
10
11
|
type RoleResolution,
|
|
11
12
|
resolveMailboxForRole,
|
|
@@ -22,6 +23,23 @@ import { AccountSettingRepo } from "./i4-account-setting.js";
|
|
|
22
23
|
|
|
23
24
|
type DB = Db<Record<string, unknown>>;
|
|
24
25
|
|
|
26
|
+
const JUNK_ROLES: readonly CanonicalMailboxRoleValue[] = [
|
|
27
|
+
CanonicalMailboxRole.Junk,
|
|
28
|
+
CanonicalMailboxRole.Trash,
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const appointmentKey = (
|
|
32
|
+
accountId: string,
|
|
33
|
+
role: CanonicalMailboxRoleValue,
|
|
34
|
+
): string => `${accountId}\u0000${role}`;
|
|
35
|
+
|
|
36
|
+
// The only place this repository names an appointment setting, so that no read
|
|
37
|
+
// here can reach the display-only label row sitting beside it (#887).
|
|
38
|
+
const appointmentSettingName = (
|
|
39
|
+
accountId: string,
|
|
40
|
+
role: CanonicalMailboxRoleValue,
|
|
41
|
+
): string => composeFolderRoleAppointmentName(accountId, role);
|
|
42
|
+
|
|
25
43
|
interface RoleCandidate extends RoleMailboxCandidate {
|
|
26
44
|
fullPath: string;
|
|
27
45
|
}
|
|
@@ -173,6 +191,148 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
|
|
|
173
191
|
return this.findMailboxForRole(accountId, CanonicalMailboxRole.Junk);
|
|
174
192
|
}
|
|
175
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Junk and Trash for every account under one config, in a fixed number of
|
|
196
|
+
* reads however many accounts the config holds — this feeds a predicate the
|
|
197
|
+
* per-message reconcile runs inside the sync loop.
|
|
198
|
+
*/
|
|
199
|
+
async resolveJunkRolesForConfig(
|
|
200
|
+
accountConfigId: string,
|
|
201
|
+
): Promise<JunkRoleMailboxes> {
|
|
202
|
+
const accounts = await this.db
|
|
203
|
+
.select({ accountId: accountTable.accountId })
|
|
204
|
+
.from(accountTable)
|
|
205
|
+
.where(eq(accountTable.accountConfigId, accountConfigId));
|
|
206
|
+
return this.resolveJunkRoles(accounts.map((row) => row.accountId));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The same answer for every account the instance holds. A mailbox id belongs
|
|
211
|
+
* to exactly one account, so a union across accounts is no less selective
|
|
212
|
+
* than asking each of them separately — which is what lets the boot sweep
|
|
213
|
+
* run one pass over the address table instead of one per config.
|
|
214
|
+
*/
|
|
215
|
+
async resolveJunkRolesForInstance(): Promise<JunkRoleMailboxes> {
|
|
216
|
+
const accounts = await this.db
|
|
217
|
+
.select({ accountId: accountTable.accountId })
|
|
218
|
+
.from(accountTable);
|
|
219
|
+
return this.resolveJunkRoles(accounts.map((row) => row.accountId));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private async resolveJunkRoles(
|
|
223
|
+
accountIds: readonly string[],
|
|
224
|
+
): Promise<JunkRoleMailboxes> {
|
|
225
|
+
if (accountIds.length === 0) {
|
|
226
|
+
return { junkMailboxIds: [], trashMailboxIds: [] };
|
|
227
|
+
}
|
|
228
|
+
const [candidates, appointments] = await Promise.all([
|
|
229
|
+
this.roleCandidatesFor(accountIds),
|
|
230
|
+
this.appointedMailboxIds(accountIds, JUNK_ROLES),
|
|
231
|
+
]);
|
|
232
|
+
|
|
233
|
+
const junkMailboxIds: string[] = [];
|
|
234
|
+
const trashMailboxIds: string[] = [];
|
|
235
|
+
for (const accountId of accountIds) {
|
|
236
|
+
const mailboxes = candidates.get(accountId) ?? [];
|
|
237
|
+
const junk = resolveMailboxForRole(
|
|
238
|
+
CanonicalMailboxRole.Junk,
|
|
239
|
+
mailboxes,
|
|
240
|
+
appointments.get(appointmentKey(accountId, CanonicalMailboxRole.Junk)),
|
|
241
|
+
);
|
|
242
|
+
if (junk) junkMailboxIds.push(junk.mailboxId);
|
|
243
|
+
const trash = resolveMailboxForRole(
|
|
244
|
+
CanonicalMailboxRole.Trash,
|
|
245
|
+
mailboxes,
|
|
246
|
+
appointments.get(appointmentKey(accountId, CanonicalMailboxRole.Trash)),
|
|
247
|
+
);
|
|
248
|
+
if (trash) trashMailboxIds.push(trash.mailboxId);
|
|
249
|
+
}
|
|
250
|
+
return { junkMailboxIds, trashMailboxIds };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Each account's appointment for each role, in two reads. Same precedence
|
|
255
|
+
* input as `appointedMailboxId`, batched: an account row missing is a caller
|
|
256
|
+
* racing a delete, and leaves the account with no appointment rather than
|
|
257
|
+
* failing the lookup.
|
|
258
|
+
*/
|
|
259
|
+
private async appointedMailboxIds(
|
|
260
|
+
accountIds: readonly string[],
|
|
261
|
+
roles: readonly CanonicalMailboxRoleValue[],
|
|
262
|
+
): Promise<Map<string, string>> {
|
|
263
|
+
const accounts = await this.db
|
|
264
|
+
.select({
|
|
265
|
+
accountId: accountTable.accountId,
|
|
266
|
+
accountConfigId: accountTable.accountConfigId,
|
|
267
|
+
})
|
|
268
|
+
.from(accountTable)
|
|
269
|
+
.where(inArray(accountTable.accountId, [...accountIds]));
|
|
270
|
+
if (accounts.length === 0) return new Map();
|
|
271
|
+
|
|
272
|
+
const wanted = new Map<string, string>();
|
|
273
|
+
for (const account of accounts) {
|
|
274
|
+
for (const role of roles) {
|
|
275
|
+
wanted.set(
|
|
276
|
+
appointmentSettingName(account.accountId, role),
|
|
277
|
+
appointmentKey(account.accountId, role),
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const settings = await this.accountSetting.getMany(
|
|
283
|
+
accounts.map((account) => account.accountConfigId),
|
|
284
|
+
[...wanted.keys()],
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const appointed = new Map<string, string>();
|
|
288
|
+
for (const setting of settings) {
|
|
289
|
+
const key = wanted.get(setting.name);
|
|
290
|
+
if (!key) continue;
|
|
291
|
+
if (setting.value.kind !== "String") continue;
|
|
292
|
+
appointed.set(key, setting.value.value);
|
|
293
|
+
}
|
|
294
|
+
return appointed;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private async roleCandidatesFor(
|
|
298
|
+
accountIds: readonly string[],
|
|
299
|
+
): Promise<Map<string, RoleCandidate[]>> {
|
|
300
|
+
const rows = await this.db
|
|
301
|
+
.select()
|
|
302
|
+
.from(mailboxTable)
|
|
303
|
+
.where(inArray(mailboxTable.accountId, [...accountIds]));
|
|
304
|
+
if (rows.length === 0) return new Map();
|
|
305
|
+
|
|
306
|
+
const entries = await this.db
|
|
307
|
+
.select()
|
|
308
|
+
.from(mailboxSpecialUseTable)
|
|
309
|
+
.where(
|
|
310
|
+
inArray(
|
|
311
|
+
mailboxSpecialUseTable.mailboxId,
|
|
312
|
+
rows.map((row) => row.mailboxId),
|
|
313
|
+
),
|
|
314
|
+
);
|
|
315
|
+
const byMailbox = new Map<string, string[]>();
|
|
316
|
+
for (const entry of entries) {
|
|
317
|
+
const designations = byMailbox.get(entry.mailboxId) ?? [];
|
|
318
|
+
designations.push(entry.specialUse);
|
|
319
|
+
byMailbox.set(entry.mailboxId, designations);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const byAccount = new Map<string, RoleCandidate[]>();
|
|
323
|
+
for (const row of rows) {
|
|
324
|
+
const candidates = byAccount.get(row.accountId) ?? [];
|
|
325
|
+
candidates.push({
|
|
326
|
+
mailboxId: row.mailboxId,
|
|
327
|
+
fullPath: row.fullPath,
|
|
328
|
+
hierarchyDelimiter: row.hierarchyDelimiter,
|
|
329
|
+
specialUse: byMailbox.get(row.mailboxId) ?? [],
|
|
330
|
+
});
|
|
331
|
+
byAccount.set(row.accountId, candidates);
|
|
332
|
+
}
|
|
333
|
+
return byAccount;
|
|
334
|
+
}
|
|
335
|
+
|
|
176
336
|
/**
|
|
177
337
|
* The one read behind every `find<Role>Mailbox`: the account's mailboxes and
|
|
178
338
|
* its appointment for this role, handed to the shared precedence rule. Read
|
|
@@ -211,7 +371,7 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
|
|
|
211
371
|
|
|
212
372
|
const setting = await this.accountSetting.get(
|
|
213
373
|
account.accountConfigId,
|
|
214
|
-
|
|
374
|
+
appointmentSettingName(accountId, role),
|
|
215
375
|
);
|
|
216
376
|
if (!setting || setting.value.kind !== "String") return undefined;
|
|
217
377
|
return setting.value.value;
|