@remit/drizzle-service 0.0.48 → 0.0.50

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.48",
3
+ "version": "0.0.50",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,535 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { after, before, beforeEach, describe, test } from "node:test";
4
+ import Database from "better-sqlite3";
5
+ import { shippedTableDdl } from "../test-shipped-sqlite-schema.js";
6
+ import {
7
+ type JunkOnlyRepairClient,
8
+ sweepJunkOnlyAddresses,
9
+ } from "./junk-only-address.js";
10
+
11
+ const DDL_TAG = "0000_happy_roland_deschain";
12
+
13
+ const clientOver = (sqlite: Database.Database): JunkOnlyRepairClient => ({
14
+ all: async (sql, params) => sqlite.prepare(sql).all(...params),
15
+ run: async (sql, params) => sqlite.prepare(sql).run(...params).changes,
16
+ });
17
+
18
+ interface AddressRow {
19
+ flags: string;
20
+ updated_at: number;
21
+ }
22
+
23
+ describe("addresses standing only on mail in Junk", () => {
24
+ let sqlite: Database.Database;
25
+
26
+ const mailbox = (mailboxId: string, specialUse: string | null): void => {
27
+ sqlite
28
+ .prepare(
29
+ `INSERT INTO mailbox (
30
+ mailbox_id, account_id, namespace_prefix, hierarchy_delimiter,
31
+ full_path, uid_validity, uid_next, highest_modseq, message_count,
32
+ unseen_count, deleted_count, total_size, last_sync_uid,
33
+ high_water_mark_uid, last_message_sync_at, special_use,
34
+ created_at, updated_at
35
+ ) VALUES (?, 'acc', '', '/', ?, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, ?, 0, 0)`,
36
+ )
37
+ .run(mailboxId, mailboxId, specialUse);
38
+ };
39
+
40
+ const specialUseEntry = (mailboxId: string, specialUse: string): void => {
41
+ sqlite
42
+ .prepare(
43
+ `INSERT INTO mailbox_special_use_entry (
44
+ mailbox_special_use_id, mailbox_id, special_use
45
+ ) VALUES (?, ?, ?)`,
46
+ )
47
+ .run(`${mailboxId}-${specialUse}`, mailboxId, specialUse);
48
+ };
49
+
50
+ const message = (messageId: string, mailboxId: string): void => {
51
+ sqlite
52
+ .prepare(
53
+ `INSERT INTO message (
54
+ message_id, mailbox_id, uid, sequence_number, rfc822_size,
55
+ internal_date, envelope_id, root_body_part_id, created_at, updated_at
56
+ ) VALUES (?, ?, 1, 1, 10, 0, 'env', 'body', 0, 0)`,
57
+ )
58
+ .run(messageId, mailboxId);
59
+ };
60
+
61
+ const address = (
62
+ addressId: string,
63
+ counters: { outbound?: number; reply?: number } = {},
64
+ flags = "{}",
65
+ ): void => {
66
+ sqlite
67
+ .prepare(
68
+ `INSERT INTO address (
69
+ address_id, account_config_id, display_name, local_part, domain,
70
+ normalized_email, normalized_compound, flags, inbound_count,
71
+ outbound_count, reply_count, last_inbound_at, last_outbound_at,
72
+ last_reply_at, created_at, updated_at
73
+ ) VALUES (?, 'cfg-1', 'Name', ?, 'example.com', ?, ?, ?, 0, ?, ?, 0,
74
+ NULL, 0, 0, 0)`,
75
+ )
76
+ .run(
77
+ addressId,
78
+ addressId,
79
+ `${addressId}@example.com`,
80
+ `name ${addressId}@example.com`,
81
+ flags,
82
+ counters.outbound ?? 0,
83
+ counters.reply ?? 0,
84
+ );
85
+ };
86
+
87
+ const sighting = (addressId: string, messageId: string): void => {
88
+ sqlite
89
+ .prepare(
90
+ `INSERT INTO envelope_address (
91
+ envelope_address_id, message_id, address_id, display_name,
92
+ normalized_email, address_role, address_order, created_at, updated_at
93
+ ) VALUES (?, ?, ?, 'Name', ?, 'From', 0, 0, 0)`,
94
+ )
95
+ .run(
96
+ `${addressId}-${messageId}`,
97
+ messageId,
98
+ addressId,
99
+ `${addressId}@example.com`,
100
+ );
101
+ };
102
+
103
+ const read = (addressId: string): AddressRow =>
104
+ sqlite
105
+ .prepare("SELECT flags, updated_at FROM address WHERE address_id = ?")
106
+ .get(addressId) as AddressRow;
107
+
108
+ const withheld = (addressId: string): boolean =>
109
+ JSON.parse(read(addressId).flags).junkOnly?.value === true;
110
+
111
+ const addressCount = (): number =>
112
+ (
113
+ sqlite.prepare("SELECT count(*) AS n FROM address").get() as {
114
+ n: number;
115
+ }
116
+ ).n;
117
+
118
+ before(() => {
119
+ sqlite = new Database(":memory:");
120
+ for (const table of [
121
+ "address",
122
+ "envelope_address",
123
+ "message",
124
+ "mailbox",
125
+ "mailbox_special_use_entry",
126
+ ]) {
127
+ sqlite.exec(shippedTableDdl(DDL_TAG, table));
128
+ }
129
+ sqlite.exec(
130
+ readFileSync(
131
+ new URL(
132
+ "../../../../npm-scripts/sqlite-address-sightings-index.sql",
133
+ import.meta.url,
134
+ ),
135
+ "utf8",
136
+ ),
137
+ );
138
+ });
139
+
140
+ after(() => {
141
+ sqlite.close();
142
+ });
143
+
144
+ beforeEach(() => {
145
+ for (const table of [
146
+ "address",
147
+ "envelope_address",
148
+ "message",
149
+ "mailbox",
150
+ "mailbox_special_use_entry",
151
+ ]) {
152
+ sqlite.exec(`DELETE FROM ${table}`);
153
+ }
154
+ mailbox("junk", '["Junk"]');
155
+ specialUseEntry("junk", "Junk");
156
+ mailbox("inbox", null);
157
+ mailbox("trash", '["Trash"]');
158
+ specialUseEntry("trash", "Trash");
159
+ message("spam-1", "junk");
160
+ message("spam-2", "junk");
161
+ message("mail-1", "inbox");
162
+ message("bin-1", "trash");
163
+ });
164
+
165
+ test("withholds an address seen only on mail in Junk", async () => {
166
+ address("spammer");
167
+ sighting("spammer", "spam-1");
168
+ sighting("spammer", "spam-2");
169
+
170
+ const report = await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
171
+
172
+ assert.equal(report.withholdable, 1);
173
+ assert.equal(report.withheld, 1);
174
+ assert.equal(withheld("spammer"), true);
175
+ // `int64` on the wire, so the timestamp must not land as a float.
176
+ assert.match(read("spammer").flags, /"setAt":\d+,/);
177
+ });
178
+
179
+ test("keeps an address with one sighting outside Junk", async () => {
180
+ address("colleague");
181
+ sighting("colleague", "spam-1");
182
+ sighting("colleague", "mail-1");
183
+
184
+ const report = await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
185
+
186
+ assert.equal(report.withholdable, 0);
187
+ assert.equal(withheld("colleague"), false);
188
+ });
189
+
190
+ test("withholds an address whose only message moved into Junk", async () => {
191
+ address("newsletter");
192
+ sighting("newsletter", "mail-1");
193
+ sqlite
194
+ .prepare("UPDATE message SET mailbox_id = 'junk' WHERE message_id = ?")
195
+ .run("mail-1");
196
+
197
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
198
+
199
+ assert.equal(withheld("newsletter"), true);
200
+ });
201
+
202
+ test("restores an address whose message moved out of Junk", async () => {
203
+ address("misfiled");
204
+ sighting("misfiled", "spam-1");
205
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
206
+ assert.equal(withheld("misfiled"), true);
207
+
208
+ sqlite
209
+ .prepare("UPDATE message SET mailbox_id = 'inbox' WHERE message_id = ?")
210
+ .run("spam-1");
211
+ const report = await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
212
+
213
+ assert.equal(report.restorable, 1);
214
+ assert.equal(report.restored, 1);
215
+ assert.equal(withheld("misfiled"), false);
216
+ });
217
+
218
+ test("never withholds an address the account has written to", async () => {
219
+ address("client", { outbound: 1 });
220
+ sighting("client", "spam-1");
221
+
222
+ const report = await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
223
+
224
+ assert.equal(report.withholdable, 0);
225
+ assert.equal(withheld("client"), false);
226
+ });
227
+
228
+ test("never withholds an address the account has replied to", async () => {
229
+ address("friend", { reply: 2 });
230
+ sighting("friend", "spam-1");
231
+
232
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
233
+
234
+ assert.equal(withheld("friend"), false);
235
+ });
236
+
237
+ test("never withholds a VIP", async () => {
238
+ address("boss", {}, '{"vip":{"value":true,"setAt":1}}');
239
+ sighting("boss", "spam-1");
240
+
241
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
242
+
243
+ assert.equal(withheld("boss"), false);
244
+ });
245
+
246
+ test("restores a withheld address once the account writes to it", async () => {
247
+ address("reformed", {}, '{"junkOnly":{"value":true,"setAt":1}}');
248
+ sighting("reformed", "spam-1");
249
+
250
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
251
+ assert.equal(withheld("reformed"), true);
252
+
253
+ sqlite
254
+ .prepare("UPDATE address SET outbound_count = 1 WHERE address_id = ?")
255
+ .run("reformed");
256
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
257
+
258
+ assert.equal(withheld("reformed"), false);
259
+ });
260
+
261
+ test("leaves mail in Trash feeding the address book", async () => {
262
+ address("ex-colleague");
263
+ sighting("ex-colleague", "bin-1");
264
+
265
+ const report = await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
266
+
267
+ assert.equal(report.withholdable, 0);
268
+ assert.equal(withheld("ex-colleague"), false);
269
+ });
270
+
271
+ test("one deleted message does not keep a spammer suggestible", async () => {
272
+ address("spammer");
273
+ sighting("spammer", "spam-1");
274
+ sighting("spammer", "bin-1");
275
+
276
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
277
+
278
+ assert.equal(withheld("spammer"), true);
279
+ });
280
+
281
+ test("Trash reads the same whichever move happened first", async () => {
282
+ address("junk-then-bin");
283
+ address("bin-then-junk");
284
+ sighting("junk-then-bin", "spam-1");
285
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
286
+ sighting("junk-then-bin", "bin-1");
287
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
288
+
289
+ sighting("bin-then-junk", "bin-1");
290
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
291
+ sighting("bin-then-junk", "spam-1");
292
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
293
+
294
+ assert.equal(withheld("junk-then-bin"), true);
295
+ assert.equal(withheld("bin-then-junk"), true);
296
+ });
297
+
298
+ test("a cold sweep agrees with the moves that built the state", async () => {
299
+ address("spammer");
300
+ sighting("spammer", "spam-1");
301
+ sighting("spammer", "bin-1");
302
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
303
+
304
+ sqlite
305
+ .prepare("UPDATE address SET flags = '{}' WHERE address_id = ?")
306
+ .run("spammer");
307
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
308
+
309
+ assert.equal(withheld("spammer"), true);
310
+ });
311
+
312
+ test("deleting every spam message leaves the mark standing", async () => {
313
+ address("spammer");
314
+ sighting("spammer", "spam-1");
315
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
316
+
317
+ sqlite
318
+ .prepare("UPDATE message SET mailbox_id = 'trash' WHERE message_id = ?")
319
+ .run("spam-1");
320
+ const report = await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
321
+
322
+ assert.equal(report.restorable, 0);
323
+ assert.equal(withheld("spammer"), true);
324
+ });
325
+
326
+ test("deleting the spam does not put its sender back", async () => {
327
+ address("spammer");
328
+ sighting("spammer", "spam-1");
329
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
330
+
331
+ sqlite
332
+ .prepare("UPDATE message SET mailbox_id = 'trash' WHERE message_id = ?")
333
+ .run("spam-1");
334
+ const report = await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
335
+
336
+ assert.equal(report.restorable, 0);
337
+ assert.equal(withheld("spammer"), true);
338
+ });
339
+
340
+ test("purging the spam does not put its sender back", async () => {
341
+ address("spammer");
342
+ sighting("spammer", "spam-1");
343
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
344
+
345
+ sqlite.prepare("DELETE FROM message WHERE message_id = ?").run("spam-1");
346
+ const report = await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
347
+
348
+ assert.equal(report.restorable, 0);
349
+ assert.equal(withheld("spammer"), true);
350
+ });
351
+
352
+ test("withholds a sender the account blocked or muted", async () => {
353
+ address("reported", {}, '{"blocked":{"value":true,"setAt":1}}');
354
+ address("hushed", {}, '{"muted":{"value":true,"setAt":1}}');
355
+ sighting("reported", "spam-1");
356
+ sighting("hushed", "spam-1");
357
+
358
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
359
+
360
+ assert.equal(withheld("reported"), true);
361
+ assert.equal(withheld("hushed"), true);
362
+ });
363
+
364
+ test("blocking a withheld sender never lifts the mark", async () => {
365
+ address("spammer");
366
+ sighting("spammer", "spam-1");
367
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
368
+
369
+ sqlite
370
+ .prepare(
371
+ `UPDATE address SET flags = json_set(flags, '$.blocked',
372
+ json_object('value', json('true'), 'setAt', 1))
373
+ WHERE address_id = ?`,
374
+ )
375
+ .run("spammer");
376
+ const report = await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
377
+
378
+ assert.equal(report.restorable, 0);
379
+ assert.equal(withheld("spammer"), true);
380
+ });
381
+
382
+ test("reads a Junk folder a server does not designate", async () => {
383
+ mailbox("named-spam", null);
384
+ message("spam-5", "named-spam");
385
+ address("by-name");
386
+ sighting("by-name", "spam-5");
387
+ sqlite
388
+ .prepare("UPDATE mailbox SET full_path = 'Spam' WHERE mailbox_id = ?")
389
+ .run("named-spam");
390
+
391
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
392
+
393
+ assert.equal(withheld("by-name"), true);
394
+ });
395
+
396
+ test("reads a Junk folder nested under any prefix", async () => {
397
+ mailbox("nested-spam", null);
398
+ message("spam-6", "nested-spam");
399
+ address("nested");
400
+ 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
+
407
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
408
+
409
+ assert.equal(withheld("nested"), true);
410
+ });
411
+
412
+ test("reads a Junk folder under a delimiter that is not a slash", async () => {
413
+ mailbox("dotted-spam", null);
414
+ message("spam-7", "dotted-spam");
415
+ address("dotted");
416
+ 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
+
424
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
425
+
426
+ assert.equal(withheld("dotted"), true);
427
+ });
428
+
429
+ test("never reads a prefix as the folder it names", async () => {
430
+ mailbox("spam-parent", null);
431
+ message("mail-2", "spam-parent");
432
+ address("under-spam");
433
+ 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
+
440
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
441
+
442
+ assert.equal(withheld("under-spam"), false);
443
+ });
444
+
445
+ test("reads the designation from either place it is stored", async () => {
446
+ mailbox("column-only", '["Junk"]');
447
+ mailbox("entry-only", null);
448
+ specialUseEntry("entry-only", "Junk");
449
+ message("spam-3", "column-only");
450
+ message("spam-4", "entry-only");
451
+ address("by-column");
452
+ address("by-entry");
453
+ sighting("by-column", "spam-3");
454
+ sighting("by-entry", "spam-4");
455
+
456
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
457
+
458
+ assert.equal(withheld("by-column"), true);
459
+ assert.equal(withheld("by-entry"), true);
460
+ });
461
+
462
+ test("leaves an address no message has ever carried alone", async () => {
463
+ address("orphan");
464
+
465
+ const report = await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
466
+
467
+ assert.equal(report.withholdable, 0);
468
+ assert.equal(withheld("orphan"), false);
469
+ });
470
+
471
+ test("removes no row", async () => {
472
+ address("spammer");
473
+ address("colleague");
474
+ address("client", { outbound: 3 });
475
+ sighting("spammer", "spam-1");
476
+ sighting("colleague", "mail-1");
477
+ sighting("client", "spam-2");
478
+
479
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
480
+
481
+ assert.equal(addressCount(), 3);
482
+ });
483
+
484
+ test("keeps the rest of an address's flags", async () => {
485
+ address("noisy", {}, '{"muted":{"value":true,"setAt":7}}');
486
+ sighting("noisy", "spam-1");
487
+
488
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
489
+
490
+ assert.deepEqual(JSON.parse(read("noisy").flags).muted, {
491
+ value: true,
492
+ setAt: 7,
493
+ });
494
+ });
495
+
496
+ test("a second run writes nothing", async () => {
497
+ address("spammer");
498
+ sighting("spammer", "spam-1");
499
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
500
+ const first = read("spammer");
501
+
502
+ const report = await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
503
+
504
+ assert.equal(report.withholdable, 0);
505
+ assert.equal(report.restorable, 0);
506
+ assert.equal(report.withheld, 0);
507
+ assert.equal(report.restored, 0);
508
+ assert.deepEqual(read("spammer"), first);
509
+ });
510
+
511
+ test("check mode reports what it would do and writes nothing", async () => {
512
+ address("spammer");
513
+ sighting("spammer", "spam-1");
514
+ address("misfiled", {}, '{"junkOnly":{"value":true,"setAt":1}}');
515
+ sighting("misfiled", "mail-1");
516
+
517
+ const report = await sweepJunkOnlyAddresses(clientOver(sqlite), "check");
518
+
519
+ assert.equal(report.withholdable, 1);
520
+ assert.equal(report.restorable, 1);
521
+ assert.equal(report.withheld, 0);
522
+ assert.equal(report.restored, 0);
523
+ assert.equal(withheld("spammer"), false);
524
+ assert.equal(withheld("misfiled"), true);
525
+ });
526
+
527
+ test("survives a row whose flags were never populated", async () => {
528
+ address("legacy", {}, "");
529
+ sighting("legacy", "spam-1");
530
+
531
+ await sweepJunkOnlyAddresses(clientOver(sqlite), "repair");
532
+
533
+ assert.equal(withheld("legacy"), true);
534
+ });
535
+ });
@@ -0,0 +1,158 @@
1
+ import {
2
+ JUNK_FOLDER_NAMES,
3
+ TRASH_FOLDER_NAMES,
4
+ } from "@remit/data-ports/mailbox-role";
5
+ import { MailboxSpecialUse } from "@remit/domain-enums";
6
+
7
+ export interface JunkOnlyRepairClient {
8
+ all(sql: string, params: readonly unknown[]): Promise<unknown[]>;
9
+ run(sql: string, params: readonly unknown[]): Promise<number>;
10
+ }
11
+
12
+ export type JunkOnlyRepairMode = "check" | "repair";
13
+
14
+ export interface JunkOnlyReport {
15
+ readonly mode: JunkOnlyRepairMode;
16
+ readonly withholdable: number;
17
+ readonly withheld: number;
18
+ readonly restorable: number;
19
+ readonly restored: number;
20
+ }
21
+
22
+ export const JUNK_ONLY_FLAG = "junkOnly";
23
+
24
+ const STORED_FLAGS = "coalesce(nullif(address.flags, ''), '{}')";
25
+
26
+ const flagIsSet = (name: string): string =>
27
+ `coalesce(json_extract(${STORED_FLAGS}, '$.${name}.value'), 0) = 1`;
28
+
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
+ )`;
58
+
59
+ const IN_JUNK = mailboxCarriesRole(MailboxSpecialUse.Junk, JUNK_FOLDER_NAMES);
60
+ const IN_TRASH = mailboxCarriesRole(
61
+ MailboxSpecialUse.Trash,
62
+ TRASH_FOLDER_NAMES,
63
+ );
64
+
65
+ const sightingWhere = (extra: string): string => `exists (
66
+ SELECT 1 FROM envelope_address
67
+ JOIN message ON message.message_id = envelope_address.message_id
68
+ WHERE envelope_address.address_id = address.address_id${extra}
69
+ )`;
70
+
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 = `(
77
+ address.outbound_count > 0
78
+ OR address.reply_count > 0
79
+ OR ${flagIsSet("vip")}
80
+ OR ${flagIsSet("trusted")}
81
+ )`;
82
+
83
+ export const WITHHOLDABLE = `NOT ${flagIsSet(JUNK_ONLY_FLAG)}
84
+ AND NOT ${ACCOUNT_HAS_CORRESPONDED}
85
+ AND ${SIGHTING_IN_JUNK}
86
+ AND NOT ${SIGHTING_IN_LIVE_MAIL}`;
87
+
88
+ export const RESTORABLE = `${flagIsSet(JUNK_ONLY_FLAG)}
89
+ AND (${ACCOUNT_HAS_CORRESPONDED} OR ${SIGHTING_IN_LIVE_MAIL})`;
90
+
91
+ export const withholdSql = (scope = ""): string =>
92
+ `UPDATE address
93
+ SET flags = json_set(${STORED_FLAGS}, '$.${JUNK_ONLY_FLAG}',
94
+ json_object('value', json('true'), 'setAt', CAST(? AS INTEGER), 'setBy', ?)),
95
+ updated_at = ?
96
+ WHERE ${WITHHOLDABLE}${scope}`;
97
+
98
+ export const restoreSql = (scope = ""): string =>
99
+ `UPDATE address
100
+ SET flags = json_remove(${STORED_FLAGS}, '$.${JUNK_ONLY_FLAG}'), updated_at = ?
101
+ WHERE ${RESTORABLE}${scope}`;
102
+
103
+ const REPAIR_SET_BY = "junk-only-repair";
104
+
105
+ const countWhere = async (
106
+ client: JunkOnlyRepairClient,
107
+ predicate: string,
108
+ ): Promise<number> => {
109
+ const [row] = (await client.all(
110
+ `SELECT count(*) AS row_count FROM address WHERE ${predicate}`,
111
+ [],
112
+ )) as { row_count: number }[];
113
+ return row?.row_count ?? 0;
114
+ };
115
+
116
+ export const sweepJunkOnlyAddresses = async (
117
+ client: JunkOnlyRepairClient,
118
+ mode: JunkOnlyRepairMode,
119
+ now: number = Date.now(),
120
+ ): Promise<JunkOnlyReport> => {
121
+ const withholdable = await countWhere(client, WITHHOLDABLE);
122
+ const restorable = await countWhere(client, RESTORABLE);
123
+
124
+ if (mode === "check") {
125
+ return { mode, withholdable, withheld: 0, restorable, restored: 0 };
126
+ }
127
+
128
+ const withheld =
129
+ withholdable === 0
130
+ ? 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 };
136
+ };
137
+
138
+ export const formatJunkOnlyReport = (report: JunkOnlyReport): string[] => {
139
+ if (report.withholdable === 0 && report.restorable === 0) {
140
+ return ["no address stands only on mail in Junk"];
141
+ }
142
+ const lines: string[] = [];
143
+ if (report.withholdable > 0) {
144
+ lines.push(
145
+ report.mode === "check"
146
+ ? `${report.withholdable} address(es) stand only on mail in Junk, would be withheld from autocomplete`
147
+ : `${report.withheld} of ${report.withholdable} address(es) standing only on mail in Junk withheld from autocomplete`,
148
+ );
149
+ }
150
+ if (report.restorable > 0) {
151
+ lines.push(
152
+ report.mode === "check"
153
+ ? `${report.restorable} withheld address(es) now stand on live mail, would be restored`
154
+ : `${report.restored} of ${report.restorable} withheld address(es) restored to autocomplete`,
155
+ );
156
+ }
157
+ return lines;
158
+ };
@@ -80,6 +80,21 @@ const flagValue = (name: string): SQL<number> =>
80
80
  export const addressPreference = (): SQL<number> =>
81
81
  sql<number>`(2 * ${flagValue("vip")} + ${flagValue("trusted")})`;
82
82
 
83
+ const accountHasCorresponded = (): SQL<number> =>
84
+ sql<number>`(${addressTable.outboundCount} + ${addressTable.replyCount}
85
+ + ${flagValue("vip")} + ${flagValue("trusted")})`;
86
+
87
+ const accountHasFlagged = (): SQL<number> =>
88
+ sql<number>`(${flagValue("blocked")} + ${flagValue("muted")})`;
89
+
90
+ export const addressListable = (term: string | undefined): SQL => {
91
+ const shown = sql`${flagValue("junkOnly")} = 0
92
+ or ${accountHasCorresponded()} > 0
93
+ or ${accountHasFlagged()} > 0`;
94
+ if (!term) return sql`(${shown})`;
95
+ return sql`(${shown} or ${addressTable.normalizedEmail} = ${term.toLowerCase()})`;
96
+ };
97
+
83
98
  export const addressCorrespondence = (): SQL<number> =>
84
99
  sql<number>`(${addressTable.replyCount} + ${addressTable.inboundCount} + ${addressTable.outboundCount})`;
85
100
 
@@ -0,0 +1,203 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, beforeEach, describe, test } from "node:test";
3
+ import type Database from "better-sqlite3";
4
+ import { createTestDb, type TestDb } from "../test-db.js";
5
+ import { AddressRepo } from "./i4-address.js";
6
+
7
+ const CONFIG = "cfg-1";
8
+
9
+ describe("reconciling one message's addresses at the moment it moves", () => {
10
+ let db: TestDb;
11
+ let sqlite: Database.Database;
12
+ let close: () => Promise<void>;
13
+ let repo: AddressRepo;
14
+
15
+ const mailbox = (mailboxId: string, specialUse: string | null): void => {
16
+ sqlite
17
+ .prepare(
18
+ `INSERT INTO mailbox (
19
+ mailbox_id, account_id, namespace_prefix, hierarchy_delimiter,
20
+ full_path, uid_validity, uid_next, highest_modseq, message_count,
21
+ unseen_count, deleted_count, total_size, last_sync_uid,
22
+ high_water_mark_uid, last_message_sync_at, special_use,
23
+ created_at, updated_at
24
+ ) VALUES (?, 'acc', '', '/', ?, 1, 1, '0', 0, 0, 0, 0, 0, 0, 0, ?, 0, 0)`,
25
+ )
26
+ .run(mailboxId, mailboxId, specialUse);
27
+ };
28
+
29
+ const message = (messageId: string, mailboxId: string): void => {
30
+ sqlite
31
+ .prepare(
32
+ `INSERT INTO message (
33
+ message_id, mailbox_id, uid, sequence_number, rfc822_size,
34
+ internal_date, envelope_id, root_body_part_id, created_at, updated_at
35
+ ) VALUES (?, ?, 1, 1, 10, 0, 'env', 'body', 0, 0)`,
36
+ )
37
+ .run(messageId, mailboxId);
38
+ };
39
+
40
+ const sighting = (addressId: string, messageId: string): void => {
41
+ sqlite
42
+ .prepare(
43
+ `INSERT INTO envelope_address (
44
+ envelope_address_id, message_id, address_id, display_name,
45
+ normalized_email, address_role, address_order, created_at, updated_at
46
+ ) VALUES (?, ?, ?, 'Name', ?, 'From', 0, 0, 0)`,
47
+ )
48
+ .run(
49
+ `${addressId}-${messageId}`,
50
+ messageId,
51
+ addressId,
52
+ `${addressId}@example.com`,
53
+ );
54
+ };
55
+
56
+ const harvest = async (addressId: string) =>
57
+ repo.upsertCorrespondentAddress({
58
+ addressId,
59
+ accountConfigId: CONFIG,
60
+ displayName: "Name",
61
+ localPart: addressId,
62
+ domain: "example.com",
63
+ normalizedEmail: `${addressId}@example.com`,
64
+ normalizedCompound: `name ${addressId}@example.com`,
65
+ });
66
+
67
+ const moveTo = (messageId: string, mailboxId: string): void => {
68
+ sqlite
69
+ .prepare("UPDATE message SET mailbox_id = ? WHERE message_id = ?")
70
+ .run(mailboxId, messageId);
71
+ };
72
+
73
+ const withheld = async (addressId: string): Promise<boolean> =>
74
+ (await repo.getAddress(CONFIG, addressId)).flags?.junkOnly?.value === true;
75
+
76
+ const suggested = async (term: string): Promise<string[]> =>
77
+ (
78
+ await repo.listByAccountConfig({
79
+ accountConfigId: CONFIG,
80
+ search: term,
81
+ })
82
+ ).items.map((item) => item.addressId);
83
+
84
+ before(async () => {
85
+ ({ db, sqlite, close } = await createTestDb());
86
+ repo = new AddressRepo(db as never);
87
+ });
88
+
89
+ after(async () => {
90
+ await close();
91
+ });
92
+
93
+ beforeEach(() => {
94
+ for (const table of ["address", "envelope_address", "message", "mailbox"]) {
95
+ sqlite.exec(`DELETE FROM ${table}`);
96
+ }
97
+ mailbox("inbox", null);
98
+ mailbox("junk", '["Junk"]');
99
+ mailbox("trash", '["Trash"]');
100
+ });
101
+
102
+ test("a message moved into Junk stops the sender being suggested", async () => {
103
+ message("msg", "inbox");
104
+ await harvest("spammer");
105
+ sighting("spammer", "msg");
106
+ assert.deepEqual(await suggested("spammer"), ["spammer"]);
107
+
108
+ moveTo("msg", "junk");
109
+ await repo.reconcileJunkOnlyForMessage("msg");
110
+
111
+ assert.equal(await withheld("spammer"), true);
112
+ assert.deepEqual(await suggested("spammer"), []);
113
+ });
114
+
115
+ test("a sender the account has written to survives the move", async () => {
116
+ message("msg", "inbox");
117
+ await harvest("client");
118
+ sighting("client", "msg");
119
+ await repo.incrementOutboundCount(CONFIG, "client", Date.now());
120
+
121
+ moveTo("msg", "junk");
122
+ await repo.reconcileJunkOnlyForMessage("msg");
123
+
124
+ assert.equal(await withheld("client"), false);
125
+ });
126
+
127
+ test("a sender still on live mail survives the move", async () => {
128
+ message("spam", "inbox");
129
+ message("real", "inbox");
130
+ await harvest("colleague");
131
+ sighting("colleague", "spam");
132
+ sighting("colleague", "real");
133
+
134
+ moveTo("spam", "junk");
135
+ await repo.reconcileJunkOnlyForMessage("spam");
136
+
137
+ assert.equal(await withheld("colleague"), false);
138
+ });
139
+
140
+ test("a message rescued out of Junk offers the sender again", async () => {
141
+ message("msg", "junk");
142
+ await repo.upsertJunkAddress({
143
+ addressId: "misfiled",
144
+ accountConfigId: CONFIG,
145
+ displayName: "Name",
146
+ localPart: "misfiled",
147
+ domain: "example.com",
148
+ normalizedEmail: "misfiled@example.com",
149
+ normalizedCompound: "name misfiled@example.com",
150
+ });
151
+ sighting("misfiled", "msg");
152
+ assert.deepEqual(await suggested("misfiled"), []);
153
+
154
+ moveTo("msg", "inbox");
155
+ await repo.reconcileJunkOnlyForMessage("msg");
156
+
157
+ assert.deepEqual(await suggested("misfiled"), ["misfiled"]);
158
+ });
159
+
160
+ test("a spam message moved to Trash keeps the sender withheld", async () => {
161
+ message("msg", "junk");
162
+ await repo.upsertJunkAddress({
163
+ addressId: "spammer",
164
+ accountConfigId: CONFIG,
165
+ displayName: "Name",
166
+ localPart: "spammer",
167
+ domain: "example.com",
168
+ normalizedEmail: "spammer@example.com",
169
+ normalizedCompound: "name spammer@example.com",
170
+ });
171
+ sighting("spammer", "msg");
172
+
173
+ moveTo("msg", "trash");
174
+ await repo.reconcileJunkOnlyForMessage("msg");
175
+
176
+ assert.equal(await withheld("spammer"), true);
177
+ });
178
+
179
+ test("touches no address the message does not carry", async () => {
180
+ message("msg", "inbox");
181
+ message("other", "junk");
182
+ await harvest("bystander");
183
+ sighting("bystander", "other");
184
+
185
+ moveTo("msg", "junk");
186
+ await repo.reconcileJunkOnlyForMessage("msg");
187
+
188
+ assert.equal(await withheld("bystander"), false);
189
+ });
190
+
191
+ test("a second reconcile of the same move writes nothing new", async () => {
192
+ message("msg", "inbox");
193
+ await harvest("spammer");
194
+ sighting("spammer", "msg");
195
+ moveTo("msg", "junk");
196
+ await repo.reconcileJunkOnlyForMessage("msg");
197
+ const first = await repo.getAddress(CONFIG, "spammer");
198
+
199
+ await repo.reconcileJunkOnlyForMessage("msg");
200
+
201
+ assert.deepEqual(await repo.getAddress(CONFIG, "spammer"), first);
202
+ });
203
+ });
@@ -990,6 +990,223 @@ describe("AddressRepo", () => {
990
990
  await repo.deleteAddress(configB, b.addressId);
991
991
  });
992
992
 
993
+ describe("addresses met only in Junk (#822)", () => {
994
+ const junkInput = (accountConfigId: string, email: string) => ({
995
+ ...makeAddressInput(accountConfigId, email),
996
+ displayName: "Pharma Deals",
997
+ normalizedCompound: `pharma deals ${email}`,
998
+ });
999
+
1000
+ test("a suggestion list never offers a withheld address", async () => {
1001
+ const accountConfigId = randomId();
1002
+ const withheld = await repo.upsertJunkAddress(
1003
+ junkInput(accountConfigId, "sales@pharma.example"),
1004
+ );
1005
+ const ordinary = await repo.upsertCorrespondentAddress(
1006
+ makeAddressInput(accountConfigId, "colleague@pharma.example"),
1007
+ );
1008
+
1009
+ const page = await repo.listByAccountConfig({
1010
+ accountConfigId,
1011
+ search: "pharma",
1012
+ });
1013
+
1014
+ assert.deepEqual(
1015
+ page.items.map((a) => a.addressId),
1016
+ [ordinary.addressId],
1017
+ );
1018
+
1019
+ await repo.deleteManyAddresses(accountConfigId, [
1020
+ withheld.addressId,
1021
+ ordinary.addressId,
1022
+ ]);
1023
+ });
1024
+
1025
+ test("an exact address still resolves a withheld row", async () => {
1026
+ const accountConfigId = randomId();
1027
+ const withheld = await repo.upsertJunkAddress(
1028
+ junkInput(accountConfigId, "sales@pharma.example"),
1029
+ );
1030
+
1031
+ const page = await repo.listByAccountConfig({
1032
+ accountConfigId,
1033
+ search: "sales@pharma.example",
1034
+ });
1035
+
1036
+ assert.deepEqual(
1037
+ page.items.map((a) => a.addressId),
1038
+ [withheld.addressId],
1039
+ );
1040
+
1041
+ await repo.deleteAddress(accountConfigId, withheld.addressId);
1042
+ });
1043
+
1044
+ test("a bare listing never offers a withheld address", async () => {
1045
+ const accountConfigId = randomId();
1046
+ const withheld = await repo.upsertJunkAddress(
1047
+ junkInput(accountConfigId, "sales@pharma.example"),
1048
+ );
1049
+ const ordinary = await repo.upsertCorrespondentAddress(
1050
+ makeAddressInput(accountConfigId, "colleague@pharma.example"),
1051
+ );
1052
+
1053
+ const page = await repo.listByAccountConfig({ accountConfigId });
1054
+
1055
+ assert.deepEqual(
1056
+ page.items.map((a) => a.addressId),
1057
+ [ordinary.addressId],
1058
+ );
1059
+
1060
+ await repo.deleteManyAddresses(accountConfigId, [
1061
+ withheld.addressId,
1062
+ ordinary.addressId,
1063
+ ]);
1064
+ });
1065
+
1066
+ test("the row is still there to resolve the message that carried it", async () => {
1067
+ const accountConfigId = randomId();
1068
+ const input = junkInput(accountConfigId, "sales@pharma.example");
1069
+ const withheld = await repo.upsertJunkAddress(input);
1070
+
1071
+ const fetched = await repo.getAddress(accountConfigId, input.addressId);
1072
+ assert.equal(fetched.normalizedEmail, "sales@pharma.example");
1073
+ assert.equal(fetched.flags?.junkOnly?.value, true);
1074
+
1075
+ await repo.deleteAddress(accountConfigId, withheld.addressId);
1076
+ });
1077
+
1078
+ test("one sighting on live mail restores it", async () => {
1079
+ const accountConfigId = randomId();
1080
+ const input = junkInput(accountConfigId, "misfiled@pharma.example");
1081
+ await repo.upsertJunkAddress(input);
1082
+
1083
+ const harvested = await repo.upsertCorrespondentAddress(input);
1084
+
1085
+ assert.equal(harvested.flags?.junkOnly, undefined);
1086
+ const page = await repo.listByAccountConfig({
1087
+ accountConfigId,
1088
+ search: "misfiled",
1089
+ });
1090
+ assert.deepEqual(
1091
+ page.items.map((a) => a.addressId),
1092
+ [input.addressId],
1093
+ );
1094
+
1095
+ await repo.deleteAddress(accountConfigId, input.addressId);
1096
+ });
1097
+
1098
+ test("a sighting in Junk leaves an address the account knows alone", async () => {
1099
+ const accountConfigId = randomId();
1100
+ const known = makeAddressInput(accountConfigId, "friend@pharma.example");
1101
+ await repo.upsertCorrespondentAddress({
1102
+ ...known,
1103
+ displayName: "Real Friend",
1104
+ });
1105
+
1106
+ const after = await repo.upsertJunkAddress({
1107
+ ...known,
1108
+ displayName: "Pharma Deals",
1109
+ normalizedCompound: "pharma deals friend@pharma.example",
1110
+ });
1111
+
1112
+ assert.equal(after.displayName, "Real Friend");
1113
+ assert.equal(after.flags?.junkOnly, undefined);
1114
+
1115
+ await repo.deleteAddress(accountConfigId, known.addressId);
1116
+ });
1117
+
1118
+ test("a sighting in Trash decides nothing either way", async () => {
1119
+ const accountConfigId = randomId();
1120
+ const input = junkInput(accountConfigId, "discarded@pharma.example");
1121
+ await repo.upsertJunkAddress(input);
1122
+
1123
+ const after = await repo.upsertAddress(input);
1124
+
1125
+ assert.equal(after.flags?.junkOnly?.value, true);
1126
+
1127
+ await repo.deleteAddress(accountConfigId, input.addressId);
1128
+ });
1129
+
1130
+ test("a sender the account blocked stays findable, and marked", async () => {
1131
+ const accountConfigId = randomId();
1132
+ const input = junkInput(accountConfigId, "reported@pharma.example");
1133
+ await repo.upsertJunkAddress(input);
1134
+ await repo.mergeFlags(accountConfigId, input.addressId, {
1135
+ blocked: { value: true, setAt: 1 },
1136
+ });
1137
+
1138
+ const page = await repo.listByAccountConfig({
1139
+ accountConfigId,
1140
+ search: "pharma",
1141
+ });
1142
+
1143
+ assert.deepEqual(
1144
+ page.items.map((a) => a.addressId),
1145
+ [input.addressId],
1146
+ );
1147
+ assert.equal(page.items[0].flags?.junkOnly?.value, true);
1148
+
1149
+ await repo.deleteAddress(accountConfigId, input.addressId);
1150
+ });
1151
+
1152
+ test("the account's own mail to a sender offers them again", async () => {
1153
+ const accountConfigId = randomId();
1154
+ const input = junkInput(accountConfigId, "supplier@pharma.example");
1155
+ await repo.upsertJunkAddress(input);
1156
+ await repo.incrementOutboundCount(accountConfigId, input.addressId, 1);
1157
+
1158
+ const page = await repo.listByAccountConfig({
1159
+ accountConfigId,
1160
+ search: "pharma",
1161
+ });
1162
+
1163
+ assert.deepEqual(
1164
+ page.items.map((a) => a.addressId),
1165
+ [input.addressId],
1166
+ );
1167
+
1168
+ await repo.deleteAddress(accountConfigId, input.addressId);
1169
+ });
1170
+
1171
+ test("clearing the mark by hand offers the address again", async () => {
1172
+ const accountConfigId = randomId();
1173
+ const input = junkInput(accountConfigId, "rescued@pharma.example");
1174
+ await repo.upsertJunkAddress(input);
1175
+
1176
+ await repo.mergeFlags(accountConfigId, input.addressId, {
1177
+ junkOnly: null,
1178
+ });
1179
+
1180
+ const page = await repo.listByAccountConfig({
1181
+ accountConfigId,
1182
+ search: "pharma",
1183
+ });
1184
+
1185
+ assert.deepEqual(
1186
+ page.items.map((a) => a.addressId),
1187
+ [input.addressId],
1188
+ );
1189
+
1190
+ await repo.deleteAddress(accountConfigId, input.addressId);
1191
+ });
1192
+
1193
+ test("clearing the mark keeps the rest of the flags", async () => {
1194
+ const accountConfigId = randomId();
1195
+ const input = junkInput(accountConfigId, "noisy@pharma.example");
1196
+ await repo.upsertJunkAddress(input);
1197
+ await repo.mergeFlags(accountConfigId, input.addressId, {
1198
+ muted: { value: true, setAt: 7 },
1199
+ });
1200
+
1201
+ const harvested = await repo.upsertCorrespondentAddress(input);
1202
+
1203
+ assert.equal(harvested.flags?.junkOnly, undefined);
1204
+ assert.equal(harvested.flags?.muted?.value, true);
1205
+
1206
+ await repo.deleteAddress(accountConfigId, input.addressId);
1207
+ });
1208
+ });
1209
+
993
1210
  describe("continuation token rejection (#172)", () => {
994
1211
  for (const [label, token] of [
995
1212
  ["an unparseable", "not-a-cursor"],
@@ -24,10 +24,16 @@ import type { Db } from "../db.js";
24
24
  import { NotFoundError } from "../error.js";
25
25
  import { envelopeAddressId as deriveEnvelopeAddressId } from "../id.js";
26
26
  import { decodeToken, resultList } from "../pagination.js";
27
+ import {
28
+ JUNK_ONLY_FLAG,
29
+ restoreSql,
30
+ withholdSql,
31
+ } from "../repair/junk-only-address.js";
27
32
  import { addressTable } from "../schema/i4-address.js";
28
33
  import { envelopeAddressTable } from "../schema/message-data.js";
29
34
  import {
30
35
  addressCorrespondence,
36
+ addressListable,
31
37
  addressMatchRank,
32
38
  addressPreference,
33
39
  addressRecency,
@@ -151,6 +157,23 @@ function rowToEnvelopeAddress(
151
157
 
152
158
  const VIP_SUGGESTIONS_DEFAULT_LIMIT = 10;
153
159
 
160
+ const JUNK_HARVEST = "junk-harvest";
161
+ const JUNK_MOVE = "junk-move";
162
+
163
+ const withoutJunkOnlyFlagSql = (): SQL<string> =>
164
+ sql<string>`json_remove(coalesce(nullif(${addressTable.flags}, ''), '{}'), ${`$.${JUNK_ONLY_FLAG}`})`;
165
+
166
+ const boundToDrizzle = (query: string, params: readonly unknown[]): SQL => {
167
+ const chunks = query.split("?");
168
+ const head = sql.raw(chunks[0]);
169
+ return chunks
170
+ .slice(1)
171
+ .reduce(
172
+ (acc, chunk, index) => sql`${acc}${params[index]}${sql.raw(chunk)}`,
173
+ sql`${head}`,
174
+ );
175
+ };
176
+
154
177
  export class AddressRepo implements IAddressRepository {
155
178
  constructor(private db: DB) {}
156
179
 
@@ -220,6 +243,87 @@ export class AddressRepo implements IAddressRepository {
220
243
  return rowToAddress(row);
221
244
  }
222
245
 
246
+ async upsertCorrespondentAddress(
247
+ input: CreateAddressInput,
248
+ ): Promise<AddressItem> {
249
+ const now = Date.now();
250
+ const [row] = await this.db
251
+ .insert(addressTable)
252
+ .values({
253
+ addressId: input.addressId,
254
+ accountConfigId: input.accountConfigId,
255
+ displayName: input.displayName,
256
+ localPart: input.localPart,
257
+ domain: input.domain,
258
+ normalizedEmail: input.normalizedEmail,
259
+ normalizedCompound: input.normalizedCompound,
260
+ flags: input.flags ?? {},
261
+ inboundCount: input.inboundCount ?? 0,
262
+ outboundCount: input.outboundCount ?? 0,
263
+ replyCount: input.replyCount ?? 0,
264
+ lastInboundAt: input.lastInboundAt ?? 0,
265
+ lastOutboundAt: input.lastOutboundAt,
266
+ lastReplyAt: input.lastReplyAt ?? 0,
267
+ createdAt: now,
268
+ updatedAt: now,
269
+ })
270
+ .onConflictDoUpdate({
271
+ target: addressTable.addressId,
272
+ set: input.displayName
273
+ ? {
274
+ displayName: input.displayName,
275
+ normalizedCompound: input.normalizedCompound,
276
+ flags: withoutJunkOnlyFlagSql(),
277
+ updatedAt: now,
278
+ }
279
+ : { flags: withoutJunkOnlyFlagSql(), updatedAt: now },
280
+ })
281
+ .returning();
282
+ return rowToAddress(row);
283
+ }
284
+
285
+ async upsertJunkAddress(input: CreateAddressInput): Promise<AddressItem> {
286
+ const now = Date.now();
287
+ const [row] = await this.db
288
+ .insert(addressTable)
289
+ .values({
290
+ addressId: input.addressId,
291
+ accountConfigId: input.accountConfigId,
292
+ displayName: input.displayName,
293
+ localPart: input.localPart,
294
+ domain: input.domain,
295
+ normalizedEmail: input.normalizedEmail,
296
+ normalizedCompound: input.normalizedCompound,
297
+ flags: {
298
+ ...(input.flags ?? {}),
299
+ [JUNK_ONLY_FLAG]: { value: true, setAt: now, setBy: JUNK_HARVEST },
300
+ },
301
+ inboundCount: input.inboundCount ?? 0,
302
+ outboundCount: input.outboundCount ?? 0,
303
+ replyCount: input.replyCount ?? 0,
304
+ lastInboundAt: input.lastInboundAt ?? 0,
305
+ lastOutboundAt: input.lastOutboundAt,
306
+ lastReplyAt: input.lastReplyAt ?? 0,
307
+ createdAt: now,
308
+ updatedAt: now,
309
+ })
310
+ .onConflictDoNothing()
311
+ .returning();
312
+ if (!row) return this.getAddress(input.accountConfigId, input.addressId);
313
+ return rowToAddress(row);
314
+ }
315
+
316
+ async reconcileJunkOnlyForMessage(messageId: string): Promise<void> {
317
+ const scope = ` AND address.address_id IN (
318
+ SELECT address_id FROM envelope_address WHERE message_id = ?
319
+ )`;
320
+ const now = Date.now();
321
+ await this.db.run(
322
+ boundToDrizzle(withholdSql(scope), [now, JUNK_MOVE, now, messageId]),
323
+ );
324
+ await this.db.run(boundToDrizzle(restoreSql(scope), [now, messageId]));
325
+ }
326
+
223
327
  async getAddress(
224
328
  accountConfigId: string,
225
329
  addressId: string,
@@ -573,6 +677,7 @@ export class AddressRepo implements IAddressRepository {
573
677
  and(
574
678
  eq(addressTable.accountConfigId, accountConfigId),
575
679
  search ? addressSearchMatch(search) : undefined,
680
+ addressListable(search),
576
681
  position ? after(order, position) : undefined,
577
682
  ),
578
683
  )
@@ -0,0 +1,79 @@
1
+ import assert from "node:assert/strict";
2
+ import { randomUUID } from "node:crypto";
3
+ import { after, before, describe, test } from "node:test";
4
+ import { mailboxSpecialUseTable, mailboxTable } from "../schema.js";
5
+ import { createSqliteTestDb } from "../test-db-sqlite.js";
6
+ import { MailboxRepo } from "./i4-mailbox.js";
7
+ import { MailboxSpecialUseRepo } from "./i4-mailbox-special-use.js";
8
+
9
+ const makeMailboxInput = (accountId: string, fullPath: string) => ({
10
+ accountId,
11
+ namespaceType: "personal" as const,
12
+ namespacePrefix: "",
13
+ hierarchyDelimiter: "/",
14
+ fullPath,
15
+ uidValidity: 1,
16
+ uidNext: 1,
17
+ highestModseq: "0",
18
+ messageCount: 0,
19
+ unseenCount: 0,
20
+ deletedCount: 0,
21
+ totalSize: 0,
22
+ lastSyncUid: 0,
23
+ highWaterMarkUid: 0,
24
+ lastMessageSyncAt: Date.now(),
25
+ });
26
+
27
+ describe("MailboxSpecialUseRepo.findJunkMailbox (sqlite)", () => {
28
+ let close: () => Promise<void>;
29
+ let repo: MailboxSpecialUseRepo;
30
+ let mailboxes: MailboxRepo;
31
+
32
+ before(async () => {
33
+ const testDb = await createSqliteTestDb({
34
+ mailbox: mailboxTable,
35
+ mailboxSpecialUse: mailboxSpecialUseTable,
36
+ });
37
+ close = testDb.close;
38
+ repo = new MailboxSpecialUseRepo(testDb.db as never);
39
+ mailboxes = new MailboxRepo(testDb.db as never);
40
+ });
41
+
42
+ after(async () => {
43
+ await close();
44
+ });
45
+
46
+ test("resolves an INBOX-nested Junk folder that advertises \\Junk", async () => {
47
+ const accountId = randomUUID();
48
+ await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
49
+ const spam = await mailboxes.create(
50
+ makeMailboxInput(accountId, "INBOX/Spam"),
51
+ );
52
+ await repo.create(spam.mailboxId, "Junk");
53
+
54
+ const found = await repo.findJunkMailbox(accountId);
55
+ assert.equal(found?.mailboxId, spam.mailboxId);
56
+ });
57
+
58
+ test("resolves an INBOX-nested Junk folder that advertises no special use", async () => {
59
+ // The name fallback used to compare the whole path against a list of
60
+ // bare names, so `INBOX/Spam` resolved to nothing on a server that
61
+ // advertises no \Junk.
62
+ const accountId = randomUUID();
63
+ await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
64
+ const spam = await mailboxes.create(
65
+ makeMailboxInput(accountId, "INBOX/Spam"),
66
+ );
67
+
68
+ const found = await repo.findJunkMailbox(accountId);
69
+ assert.equal(found?.mailboxId, spam.mailboxId);
70
+ });
71
+
72
+ test("answers null when the account has no Junk folder at all", async () => {
73
+ const accountId = randomUUID();
74
+ await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
75
+ await mailboxes.create(makeMailboxInput(accountId, "INBOX/Work"));
76
+
77
+ assert.equal(await repo.findJunkMailbox(accountId), null);
78
+ });
79
+ });
@@ -3,6 +3,8 @@ import type {
3
3
  MailboxSpecialUseItem,
4
4
  MailboxSpecialUseValue,
5
5
  } from "@remit/data-ports";
6
+ import { resolveMailboxByLeafName } from "@remit/data-ports/mailbox-name";
7
+ import { JUNK_FOLDER_NAMES } from "@remit/data-ports/mailbox-role";
6
8
  import { eq } from "drizzle-orm";
7
9
  import type { Db } from "../db.js";
8
10
  import { randomId } from "../id.js";
@@ -155,9 +157,7 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
155
157
  .select()
156
158
  .from(mailboxTable)
157
159
  .where(eq(mailboxTable.accountId, accountId));
158
-
159
- const names = ["junk", "spam", "bulk mail", "junk e-mail", "[gmail]/spam"];
160
- const found = rows.find((r) => names.includes(r.fullPath.toLowerCase()));
160
+ const found = resolveMailboxByLeafName(rows, JUNK_FOLDER_NAMES);
161
161
  return found
162
162
  ? { mailboxId: found.mailboxId, fullPath: found.fullPath }
163
163
  : null;