@remit/drizzle-service 0.0.49 → 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 +1 -1
- package/src/repair/junk-only-address.sqlite.test.ts +535 -0
- package/src/repair/junk-only-address.ts +158 -0
- package/src/repos/address-search-predicates.ts +15 -0
- package/src/repos/i4-address-junk-move.sqlite.test.ts +203 -0
- package/src/repos/i4-address.test.ts +217 -0
- package/src/repos/i4-address.ts +105 -0
- package/src/repos/i4-mailbox-special-use.ts +1 -8
package/package.json
CHANGED
|
@@ -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"],
|
package/src/repos/i4-address.ts
CHANGED
|
@@ -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
|
)
|
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
MailboxSpecialUseValue,
|
|
5
5
|
} from "@remit/data-ports";
|
|
6
6
|
import { resolveMailboxByLeafName } from "@remit/data-ports/mailbox-name";
|
|
7
|
+
import { JUNK_FOLDER_NAMES } from "@remit/data-ports/mailbox-role";
|
|
7
8
|
import { eq } from "drizzle-orm";
|
|
8
9
|
import type { Db } from "../db.js";
|
|
9
10
|
import { randomId } from "../id.js";
|
|
@@ -11,14 +12,6 @@ import { mailboxSpecialUseTable, mailboxTable } from "../schema/i4-mailbox.js";
|
|
|
11
12
|
|
|
12
13
|
type DB = Db<Record<string, unknown>>;
|
|
13
14
|
|
|
14
|
-
const JUNK_FOLDER_NAMES = [
|
|
15
|
-
"junk",
|
|
16
|
-
"spam",
|
|
17
|
-
"junk e-mail",
|
|
18
|
-
"junk email",
|
|
19
|
-
"bulk mail",
|
|
20
|
-
];
|
|
21
|
-
|
|
22
15
|
function rowToSpecialUse(
|
|
23
16
|
row: typeof mailboxSpecialUseTable.$inferSelect,
|
|
24
17
|
): MailboxSpecialUseItem {
|