@remit/drizzle-service 0.0.49 → 0.0.51

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.
@@ -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
  )