@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.64",
3
+ "version": "0.0.65",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -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 mailbox = (mailboxId: string, specialUse: string | null): void => {
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 (?, 'acc', '', '/', ?, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, ?, 0, 0)`,
81
+ ) VALUES (?, ?, '', ?, ?, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, ?, 0, 0)`,
36
82
  )
37
- .run(mailboxId, mailboxId, specialUse);
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
358
+ await sweep();
286
359
  sighting("junk-then-bin", "bin-1");
287
- await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
360
+ await sweep();
288
361
 
289
362
  sighting("bin-then-junk", "bin-1");
290
- await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
363
+ await sweep();
291
364
  sighting("bin-then-junk", "spam-1");
292
- await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
375
+ await sweep();
303
376
 
304
377
  sqlite
305
378
  .prepare("UPDATE address SET flags = '{}' WHERE address_id = ?")
306
379
  .run("spammer");
307
- await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
416
+ await sweep();
344
417
 
345
418
  sqlite.prepare("DELETE FROM message WHERE message_id = ?").run("spam-1");
346
- const report = await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
504
+ await sweep();
441
505
 
442
506
  assert.equal(withheld("under-spam"), false);
443
507
  });
444
508
 
445
- test("reads the designation from either place it is stored", async () => {
446
- mailbox("column-only", '["Junk"]');
447
- mailbox("entry-only", null);
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
648
+ await sweep();
500
649
  const first = read("spammer");
501
650
 
502
- const report = await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "check");
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 sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
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 quoted = (names: readonly string[]): string =>
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 IN_JUNK = mailboxCarriesRole(MailboxSpecialUse.Junk, JUNK_FOLDER_NAMES);
60
- const IN_TRASH = mailboxCarriesRole(
61
- MailboxSpecialUse.Trash,
62
- TRASH_FOLDER_NAMES,
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 SIGHTING_IN_JUNK = sightingWhere(` AND ${IN_JUNK}`);
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
- export const WITHHOLDABLE = `NOT ${flagIsSet(JUNK_ONLY_FLAG)}
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 ${SIGHTING_IN_JUNK}
86
- AND NOT ${SIGHTING_IN_LIVE_MAIL}`;
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
- export const RESTORABLE = `${flagIsSet(JUNK_ONLY_FLAG)}
89
- AND (${ACCOUNT_HAS_CORRESPONDED} OR ${SIGHTING_IN_LIVE_MAIL})`;
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 = (scope = ""): string =>
92
- `UPDATE address
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 ${WITHHOLDABLE}${scope}`;
116
+ WHERE ${predicate.sql}${scope.sql}`,
117
+ params: [now, setBy, now, ...predicate.params, ...scope.params],
118
+ };
119
+ };
97
120
 
98
- export const restoreSql = (scope = ""): string =>
99
- `UPDATE address
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 ${RESTORABLE}${scope}`;
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: string,
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 withholdable = await countWhere(client, WITHHOLDABLE);
122
- const restorable = await countWhere(client, RESTORABLE);
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 { mode, withholdable, withheld: 0, restorable, restored: 0 };
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
- withholdable === 0
169
+ withholdableCount === 0
130
170
  ? 0
131
- : await client.run(withholdSql(), [now, REPAIR_SET_BY, now]);
132
-
133
- const restored = restorable === 0 ? 0 : await client.run(restoreSql(), [now]);
134
-
135
- return { mode, withholdable, withheld, restorable, restored };
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 mailbox = (mailboxId: string, specialUse: string | null): void => {
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 (?, 'acc', '', '/', ?, 1, 1, '0', 0, 0, 0, 0, 0, 0, 0, ?, 0, 0)`,
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, mailboxId, specialUse);
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 ["address", "envelope_address", "message", "mailbox"]) {
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", '["Junk"]');
99
- mailbox("trash", '["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 repo.reconcileJunkOnlyForMessage("msg");
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 repo.reconcileJunkOnlyForMessage("msg");
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 repo.reconcileJunkOnlyForMessage("spam");
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 repo.reconcileJunkOnlyForMessage("msg");
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 repo.reconcileJunkOnlyForMessage("msg");
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 repo.reconcileJunkOnlyForMessage("msg");
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 repo.reconcileJunkOnlyForMessage("msg");
300
+ await reconcile("msg");
197
301
  const first = await repo.getAddress(CONFIG, "spammer");
198
302
 
199
- await repo.reconcileJunkOnlyForMessage("msg");
303
+ await reconcile("msg");
200
304
 
201
305
  assert.deepEqual(await repo.getAddress(CONFIG, "spammer"), first);
202
306
  });
@@ -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(messageId: string): Promise<void> {
335
- const scope = ` AND address.address_id IN (
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
- await this.db.run(
340
- boundToDrizzle(withholdSql(scope), [now, JUNK_MOVE, now, messageId]),
341
- );
342
- await this.db.run(boundToDrizzle(restoreSql(scope), [now, messageId]));
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
- composeFolderRoleAppointmentName(accountId, role),
374
+ appointmentSettingName(accountId, role),
215
375
  );
216
376
  if (!setting || setting.value.kind !== "String") return undefined;
217
377
  return setting.value.value;