@remit/drizzle-service 0.0.39 → 0.0.41
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/index.ts +1 -0
- package/src/repos/address-search-predicates.ts +82 -0
- package/src/repos/i4-address.test.ts +448 -9
- package/src/repos/i4-address.ts +125 -54
- package/src/repos/i4-sender-signer-standing.test.ts +188 -0
- package/src/repos/i4-sender-signer-standing.ts +101 -0
- package/src/repos/message.ts +6 -0
- package/src/repos/sender-signer-standing.conformance.sqlite.test.ts +23 -0
- package/src/schema.ts +1 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ export {
|
|
|
33
33
|
export * from "./repos/i4-organize-job-request.js";
|
|
34
34
|
export { OutboxAttachmentRepo } from "./repos/i4-outbox-attachment.js";
|
|
35
35
|
export * from "./repos/i4-outbox-message.js";
|
|
36
|
+
export { SenderSignerStandingRepo } from "./repos/i4-sender-signer-standing.js";
|
|
36
37
|
export { LabelRepo } from "./repos/label.js";
|
|
37
38
|
export {
|
|
38
39
|
DrizzleMessageRepository,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { or, type SQL, sql } from "drizzle-orm";
|
|
2
|
+
import { addressTable } from "../schema/i4-address.js";
|
|
3
|
+
|
|
4
|
+
// The address search seam. A term is matched as a substring of the display name,
|
|
5
|
+
// the local part, the domain and the whole address, and the order it comes back
|
|
6
|
+
// in is decided by which of those matched and where (#704).
|
|
7
|
+
|
|
8
|
+
const escapeLike = (term: string): string => term.replace(/[\\%_]/g, "\\$&");
|
|
9
|
+
|
|
10
|
+
const SEARCH_COLUMNS = [
|
|
11
|
+
sql`lower(coalesce(${addressTable.displayName}, ''))`,
|
|
12
|
+
sql`${addressTable.localPart}`,
|
|
13
|
+
sql`${addressTable.domain}`,
|
|
14
|
+
sql`${addressTable.normalizedEmail}`,
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* SQL `lower()` and `like` both fold ASCII only, so a column read through them
|
|
19
|
+
* can never meet a term folded by JavaScript: `Öz` would miss `Özcan Bakker`.
|
|
20
|
+
* `normalizedCompound` is written already folded by JavaScript, on the same
|
|
21
|
+
* rules as the term, so matching it raw is what keeps a name outside ASCII
|
|
22
|
+
* findable. It carries no column identity — the name and the address are
|
|
23
|
+
* concatenated in it — so a row reached only this way scores no rank and sorts
|
|
24
|
+
* last, which is where a fallback belongs.
|
|
25
|
+
*/
|
|
26
|
+
const FOLDED_FALLBACK = sql`${addressTable.normalizedCompound}`;
|
|
27
|
+
|
|
28
|
+
const like = (column: SQL, pattern: string): SQL =>
|
|
29
|
+
sql`${column} like ${pattern} escape '\\'`;
|
|
30
|
+
|
|
31
|
+
const patterns = (term: string) => {
|
|
32
|
+
const escaped = escapeLike(term.toLowerCase());
|
|
33
|
+
return { leading: `${escaped}%`, anywhere: `%${escaped}%` };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const addressSearchMatch = (term: string): SQL => {
|
|
37
|
+
const { anywhere } = patterns(term);
|
|
38
|
+
const matched = or(
|
|
39
|
+
...[...SEARCH_COLUMNS, FOLDED_FALLBACK].map((column) =>
|
|
40
|
+
like(column, anywhere),
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
if (matched === undefined) throw new Error("no address column to search");
|
|
44
|
+
return matched;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Where the term hit, as one number: every match at the start of a column
|
|
49
|
+
* outranks every match in the middle of one, and within each the display name
|
|
50
|
+
* outranks the local part, the domain and the whole address. A mid-string match
|
|
51
|
+
* still comes back — this only decides the order.
|
|
52
|
+
*/
|
|
53
|
+
export const addressMatchRank = (term: string | undefined): SQL<number> => {
|
|
54
|
+
// Not the bare literal `0`: SQLite reads an integer literal in ORDER BY as a
|
|
55
|
+
// column index and rejects it as out of range.
|
|
56
|
+
if (!term) return sql<number>`cast(0 as integer)`;
|
|
57
|
+
const { leading, anywhere } = patterns(term);
|
|
58
|
+
const arms = [
|
|
59
|
+
...SEARCH_COLUMNS.map((column) => like(column, leading)),
|
|
60
|
+
...SEARCH_COLUMNS.map((column) => like(column, anywhere)),
|
|
61
|
+
].map(
|
|
62
|
+
(condition, index) =>
|
|
63
|
+
sql`when ${condition} then ${SEARCH_COLUMNS.length * 2 - index}`,
|
|
64
|
+
);
|
|
65
|
+
return sql<number>`case ${sql.join(arms, sql` `)} else 0 end`;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// `json_extract` raises on text that is not JSON, and this runs on every row in
|
|
69
|
+
// the account's scan — one unparseable `flags` value would take the whole
|
|
70
|
+
// account's autocomplete down rather than its own row.
|
|
71
|
+
const flagValue = (name: string): SQL<number> =>
|
|
72
|
+
sql<number>`coalesce(json_extract(coalesce(nullif(${addressTable.flags}, ''), '{}'), ${`$.${name}.value`}), 0)`;
|
|
73
|
+
|
|
74
|
+
/** The account's own standing for the sender: a VIP first, then one it trusts. */
|
|
75
|
+
export const addressPreference = (): SQL<number> =>
|
|
76
|
+
sql<number>`(2 * ${flagValue("vip")} + ${flagValue("trusted")})`;
|
|
77
|
+
|
|
78
|
+
export const addressCorrespondence = (): SQL<number> =>
|
|
79
|
+
sql<number>`(${addressTable.replyCount} + ${addressTable.inboundCount} + ${addressTable.outboundCount})`;
|
|
80
|
+
|
|
81
|
+
export const addressRecency = (): SQL<number> =>
|
|
82
|
+
sql<number>`max(${addressTable.lastInboundAt}, ${addressTable.lastReplyAt}, coalesce(${addressTable.lastOutboundAt}, 0))`;
|
|
@@ -98,6 +98,45 @@ describe("AddressRepo", () => {
|
|
|
98
98
|
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
test("three inbound messages promote a person-shaped sender to wellknown", async () => {
|
|
102
|
+
const addr = await repo.createAddress(makeAddressInput(randomId()));
|
|
103
|
+
const now = Date.now();
|
|
104
|
+
|
|
105
|
+
for (let i = 0; i < 3; i++) {
|
|
106
|
+
await repo.incrementInboundCount(
|
|
107
|
+
addr.accountConfigId,
|
|
108
|
+
addr.addressId,
|
|
109
|
+
now,
|
|
110
|
+
false,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const updated = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
115
|
+
assert.equal(updated.flags?.wellknown?.value, true);
|
|
116
|
+
|
|
117
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("a bulk sender never reaches wellknown on inbound volume alone", async () => {
|
|
121
|
+
const addr = await repo.createAddress(makeAddressInput(randomId()));
|
|
122
|
+
const now = Date.now();
|
|
123
|
+
|
|
124
|
+
for (let i = 0; i < 5; i++) {
|
|
125
|
+
await repo.incrementInboundCount(
|
|
126
|
+
addr.accountConfigId,
|
|
127
|
+
addr.addressId,
|
|
128
|
+
now,
|
|
129
|
+
true,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const updated = await repo.getAddress(addr.accountConfigId, addr.addressId);
|
|
134
|
+
assert.equal(updated.inboundCount, 5);
|
|
135
|
+
assert.equal(updated.flags?.wellknown, undefined);
|
|
136
|
+
|
|
137
|
+
await repo.deleteAddress(addr.accountConfigId, addr.addressId);
|
|
138
|
+
});
|
|
139
|
+
|
|
101
140
|
test("createEnvelopeAddress and getEnvelopeAddress", async () => {
|
|
102
141
|
const messageId = randomUUID();
|
|
103
142
|
const addressId = randomUUID();
|
|
@@ -241,23 +280,417 @@ describe("AddressRepo", () => {
|
|
|
241
280
|
await repo.deleteManyAddresses(accountConfigId, [created.addressId]);
|
|
242
281
|
});
|
|
243
282
|
|
|
244
|
-
test("listByAccountConfig
|
|
283
|
+
test("listByAccountConfig matches any word of the display name, the local part and the domain (#704)", async () => {
|
|
245
284
|
const accountConfigId = randomId();
|
|
246
|
-
const
|
|
247
|
-
makeAddressInput(accountConfigId, "
|
|
285
|
+
const created = await repo.createAddress({
|
|
286
|
+
...makeAddressInput(accountConfigId, "amsterdam@pocahondas.nl"),
|
|
287
|
+
displayName: "Pocahondas locatie amsterdam",
|
|
288
|
+
normalizedCompound:
|
|
289
|
+
"pocahondas locatie amsterdam amsterdam@pocahondas.nl",
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
for (const term of [
|
|
293
|
+
"po",
|
|
294
|
+
"pocahondas",
|
|
295
|
+
"locatie",
|
|
296
|
+
"amsterdam",
|
|
297
|
+
"pocahondas.nl",
|
|
298
|
+
"@pocahondas.nl",
|
|
299
|
+
"amsterdam@pocahondas.nl",
|
|
300
|
+
]) {
|
|
301
|
+
const found = await repo.listByAccountConfig({
|
|
302
|
+
accountConfigId,
|
|
303
|
+
search: term,
|
|
304
|
+
});
|
|
305
|
+
assert.deepEqual(
|
|
306
|
+
found.items.map((a) => a.addressId),
|
|
307
|
+
[created.addressId],
|
|
308
|
+
`"${term}" must resolve the address`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await repo.deleteManyAddresses(accountConfigId, [created.addressId]);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("listByAccountConfig finds a display name whose first letter is not ASCII", async () => {
|
|
316
|
+
const accountConfigId = randomId();
|
|
317
|
+
// Written the way message sync writes it: the compound folded in JavaScript.
|
|
318
|
+
const names = [
|
|
319
|
+
["Özcan Bakker", "o.bakker@kliniek.nl", ["Öz", "öz", "özcan", "zcan"]],
|
|
320
|
+
["Émile Zola", "emile@zola.test", ["Ém", "ém", "émile", "zola"]],
|
|
321
|
+
["Ángela Ruiz", "angela@ruiz.test", ["Án", "án", "ángela", "ngela"]],
|
|
322
|
+
] as const;
|
|
323
|
+
|
|
324
|
+
for (const [displayName, email, terms] of names) {
|
|
325
|
+
const created = await repo.createAddress({
|
|
326
|
+
...makeAddressInput(accountConfigId, email),
|
|
327
|
+
displayName,
|
|
328
|
+
normalizedCompound: `${displayName.toLowerCase()} ${email}`,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
for (const term of terms) {
|
|
332
|
+
const found = await repo.listByAccountConfig({
|
|
333
|
+
accountConfigId,
|
|
334
|
+
search: term,
|
|
335
|
+
});
|
|
336
|
+
assert.deepEqual(
|
|
337
|
+
found.items.map((a) => a.addressId),
|
|
338
|
+
[created.addressId],
|
|
339
|
+
`"${term}" must resolve ${displayName}`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
await repo.deleteManyAddresses(accountConfigId, [created.addressId]);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("updateAddress keeps the compound findable when a name is not ASCII", async () => {
|
|
348
|
+
const accountConfigId = randomId();
|
|
349
|
+
const created = await repo.createAddress(
|
|
350
|
+
makeAddressInput(accountConfigId, "o.bakker@kliniek.nl"),
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
await repo.updateAddress(accountConfigId, created.addressId, {
|
|
354
|
+
displayName: "Özcan Bakker",
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const found = await repo.listByAccountConfig({
|
|
358
|
+
accountConfigId,
|
|
359
|
+
search: "Öz",
|
|
360
|
+
});
|
|
361
|
+
assert.deepEqual(
|
|
362
|
+
found.items.map((a) => a.addressId),
|
|
363
|
+
[created.addressId],
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
await repo.deleteManyAddresses(accountConfigId, [created.addressId]);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("listByAccountConfig offers a two-letter name match ahead of a louder domain match (#704)", async () => {
|
|
370
|
+
const accountConfigId = randomId();
|
|
371
|
+
const colin = await repo.createAddress({
|
|
372
|
+
...makeAddressInput(accountConfigId, "colin@personal.com"),
|
|
373
|
+
displayName: "Colin Baker",
|
|
374
|
+
inboundCount: 4,
|
|
375
|
+
});
|
|
376
|
+
const newsletter = await repo.createAddress({
|
|
377
|
+
...makeAddressInput(accountConfigId, "info@acme.com"),
|
|
378
|
+
displayName: "Acme Newsletter",
|
|
379
|
+
inboundCount: 500,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const byInitials = await repo.listByAccountConfig({
|
|
383
|
+
accountConfigId,
|
|
384
|
+
search: "co",
|
|
385
|
+
limit: 1,
|
|
386
|
+
});
|
|
387
|
+
assert.deepEqual(
|
|
388
|
+
byInitials.items.map((a) => a.addressId),
|
|
389
|
+
[colin.addressId],
|
|
390
|
+
"volume must not take the only suggestion slot from a name match",
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
const shop = await repo.createAddress({
|
|
394
|
+
...makeAddressInput(accountConfigId, "hello@corner.test"),
|
|
395
|
+
displayName: "Corner Shop",
|
|
396
|
+
});
|
|
397
|
+
const loud = await repo.createAddress({
|
|
398
|
+
...makeAddressInput(accountConfigId, "news@corner.test"),
|
|
399
|
+
displayName: "Loud List",
|
|
400
|
+
inboundCount: 500,
|
|
401
|
+
});
|
|
402
|
+
const byName = await repo.listByAccountConfig({
|
|
403
|
+
accountConfigId,
|
|
404
|
+
search: "corner",
|
|
405
|
+
});
|
|
406
|
+
assert.deepEqual(
|
|
407
|
+
byName.items.map((a) => a.addressId),
|
|
408
|
+
[shop.addressId, loud.addressId],
|
|
409
|
+
"a name match outranks a shared domain",
|
|
248
410
|
);
|
|
249
411
|
|
|
250
|
-
|
|
412
|
+
await repo.deleteManyAddresses(accountConfigId, [
|
|
413
|
+
colin.addressId,
|
|
414
|
+
newsletter.addressId,
|
|
415
|
+
shop.addressId,
|
|
416
|
+
loud.addressId,
|
|
417
|
+
]);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("cross-tenant: a search never reaches another account's addresses", async () => {
|
|
421
|
+
const mine = randomId();
|
|
422
|
+
const theirs = randomId();
|
|
423
|
+
const ours = await repo.createAddress({
|
|
424
|
+
...makeAddressInput(mine, "amsterdam@pocahondas.nl"),
|
|
425
|
+
displayName: "Pocahondas locatie amsterdam",
|
|
426
|
+
});
|
|
427
|
+
const foreign = await repo.createAddress({
|
|
428
|
+
...makeAddressInput(theirs, "amsterdam@pocahondas.nl"),
|
|
429
|
+
displayName: "Pocahondas locatie amsterdam",
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
for (const term of ["po", "amsterdam", "pocahondas.nl"]) {
|
|
433
|
+
const found = await repo.listByAccountConfig({
|
|
434
|
+
accountConfigId: mine,
|
|
435
|
+
search: term,
|
|
436
|
+
});
|
|
437
|
+
assert.deepEqual(
|
|
438
|
+
found.items.map((a) => a.addressId),
|
|
439
|
+
[ours.addressId],
|
|
440
|
+
`"${term}" must stay inside the caller's account`,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
await repo.deleteManyAddresses(mine, [ours.addressId]);
|
|
445
|
+
await repo.deleteManyAddresses(theirs, [foreign.addressId]);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("listByAccountConfig puts a match at the start of a name above one in the middle", async () => {
|
|
449
|
+
const accountConfigId = randomId();
|
|
450
|
+
const leading = await repo.createAddress({
|
|
451
|
+
...makeAddressInput(accountConfigId, "amsterdam@pocahondas.nl"),
|
|
452
|
+
displayName: "Pocahondas locatie amsterdam",
|
|
453
|
+
});
|
|
454
|
+
const middle = await repo.createAddress({
|
|
455
|
+
...makeAddressInput(accountConfigId, "hello@example.test"),
|
|
456
|
+
displayName: "Studio Pocahondas",
|
|
457
|
+
inboundCount: 500,
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const found = await repo.listByAccountConfig({
|
|
251
461
|
accountConfigId,
|
|
252
|
-
search: "
|
|
462
|
+
search: "po",
|
|
463
|
+
});
|
|
464
|
+
assert.deepEqual(
|
|
465
|
+
found.items.map((a) => a.addressId),
|
|
466
|
+
[leading.addressId, middle.addressId],
|
|
467
|
+
"a mid-name match is returned, but below the one the term starts",
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
await repo.deleteManyAddresses(accountConfigId, [
|
|
471
|
+
leading.addressId,
|
|
472
|
+
middle.addressId,
|
|
473
|
+
]);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("listByAccountConfig leads a tier with the account's own VIP", async () => {
|
|
477
|
+
const accountConfigId = randomId();
|
|
478
|
+
const vip = await repo.createAddress({
|
|
479
|
+
...makeAddressInput(accountConfigId, "one@vips.test"),
|
|
480
|
+
displayName: "Sales Alice",
|
|
481
|
+
flags: { vip: { value: true, setAt: 1 } },
|
|
253
482
|
});
|
|
483
|
+
const trusted = await repo.createAddress({
|
|
484
|
+
...makeAddressInput(accountConfigId, "two@vips.test"),
|
|
485
|
+
displayName: "Sales Bob",
|
|
486
|
+
flags: { trusted: { value: true, setAt: 1 } },
|
|
487
|
+
inboundCount: 40,
|
|
488
|
+
});
|
|
489
|
+
const stranger = await repo.createAddress({
|
|
490
|
+
...makeAddressInput(accountConfigId, "three@vips.test"),
|
|
491
|
+
displayName: "Sales Carol",
|
|
492
|
+
inboundCount: 900,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const found = await repo.listByAccountConfig({
|
|
496
|
+
accountConfigId,
|
|
497
|
+
search: "sales",
|
|
498
|
+
});
|
|
499
|
+
assert.deepEqual(
|
|
500
|
+
found.items.map((a) => a.addressId),
|
|
501
|
+
[vip.addressId, trusted.addressId, stranger.addressId],
|
|
502
|
+
"standing decides ahead of volume",
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
await repo.deleteManyAddresses(accountConfigId, [
|
|
506
|
+
vip.addressId,
|
|
507
|
+
trusted.addressId,
|
|
508
|
+
stranger.addressId,
|
|
509
|
+
]);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("listByAccountConfig finds an address whose display name arrived after the first sighting", async () => {
|
|
513
|
+
const accountConfigId = randomId();
|
|
514
|
+
// How message-sync writes a first sighting with no display name, then a
|
|
515
|
+
// later one that carries it.
|
|
516
|
+
const nameless = {
|
|
517
|
+
...makeAddressInput(accountConfigId, "amsterdam@pocahondas.nl"),
|
|
518
|
+
displayName: "",
|
|
519
|
+
normalizedCompound: "amsterdam@pocahondas.nl",
|
|
520
|
+
};
|
|
521
|
+
await repo.upsertAddress(nameless);
|
|
522
|
+
const created = await repo.upsertAddress({
|
|
523
|
+
...nameless,
|
|
524
|
+
displayName: "Pocahondas locatie amsterdam",
|
|
525
|
+
normalizedCompound:
|
|
526
|
+
"pocahondas locatie amsterdam amsterdam@pocahondas.nl",
|
|
527
|
+
});
|
|
528
|
+
assert.equal(created.displayName, "Pocahondas locatie amsterdam");
|
|
529
|
+
|
|
530
|
+
for (const term of ["po", "locatie", "pocahondas"]) {
|
|
531
|
+
const found = await repo.listByAccountConfig({
|
|
532
|
+
accountConfigId,
|
|
533
|
+
search: term,
|
|
534
|
+
});
|
|
535
|
+
assert.deepEqual(
|
|
536
|
+
found.items.map((a) => a.addressId),
|
|
537
|
+
[created.addressId],
|
|
538
|
+
`"${term}" must resolve the address`,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
await repo.deleteManyAddresses(accountConfigId, [created.addressId]);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("upsertAddress keeps a known display name when a later sighting carries none", async () => {
|
|
546
|
+
const accountConfigId = randomId();
|
|
547
|
+
const named = {
|
|
548
|
+
...makeAddressInput(accountConfigId, "amsterdam@pocahondas.nl"),
|
|
549
|
+
displayName: "Pocahondas locatie amsterdam",
|
|
550
|
+
normalizedCompound:
|
|
551
|
+
"pocahondas locatie amsterdam amsterdam@pocahondas.nl",
|
|
552
|
+
};
|
|
553
|
+
await repo.upsertAddress(named);
|
|
554
|
+
const after = await repo.upsertAddress({
|
|
555
|
+
...named,
|
|
556
|
+
displayName: "",
|
|
557
|
+
normalizedCompound: "amsterdam@pocahondas.nl",
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
assert.equal(after.displayName, "Pocahondas locatie amsterdam");
|
|
254
561
|
assert.equal(
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
562
|
+
after.normalizedCompound,
|
|
563
|
+
"pocahondas locatie amsterdam amsterdam@pocahondas.nl",
|
|
564
|
+
);
|
|
565
|
+
const found = await repo.listByAccountConfig({
|
|
566
|
+
accountConfigId,
|
|
567
|
+
search: "locatie",
|
|
568
|
+
});
|
|
569
|
+
assert.deepEqual(
|
|
570
|
+
found.items.map((a) => a.addressId),
|
|
571
|
+
[named.addressId],
|
|
572
|
+
);
|
|
573
|
+
|
|
574
|
+
await repo.deleteManyAddresses(accountConfigId, [named.addressId]);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("listByAccountConfig ranks an address the account writes to above an alphabetically earlier stranger (#704)", async () => {
|
|
578
|
+
const accountConfigId = randomId();
|
|
579
|
+
const stranger = await repo.createAddress({
|
|
580
|
+
...makeAddressInput(accountConfigId, "aaron@example.com"),
|
|
581
|
+
displayName: "Aaron Stranger",
|
|
582
|
+
normalizedCompound: "aaron stranger aaron@example.com",
|
|
583
|
+
});
|
|
584
|
+
const frequent = await repo.createAddress({
|
|
585
|
+
...makeAddressInput(accountConfigId, "zoe@example.com"),
|
|
586
|
+
displayName: "Zoe Frequent",
|
|
587
|
+
normalizedCompound: "zoe frequent zoe@example.com",
|
|
588
|
+
});
|
|
589
|
+
// Written to, never heard back from — the compose field's most useful
|
|
590
|
+
// suggestion and the one a received-mail-only score would bury.
|
|
591
|
+
const now = Date.now();
|
|
592
|
+
await repo.incrementOutboundCount(accountConfigId, frequent.addressId, now);
|
|
593
|
+
await repo.incrementOutboundCount(accountConfigId, frequent.addressId, now);
|
|
594
|
+
|
|
595
|
+
const ranked = await repo.listByAccountConfig({
|
|
596
|
+
accountConfigId,
|
|
597
|
+
search: "example.com",
|
|
598
|
+
});
|
|
599
|
+
assert.deepEqual(
|
|
600
|
+
ranked.items.map((a) => a.normalizedEmail),
|
|
601
|
+
["zoe@example.com", "aaron@example.com"],
|
|
602
|
+
"correspondence outranks the alphabet",
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
const cut = await repo.listByAccountConfig({
|
|
606
|
+
accountConfigId,
|
|
607
|
+
search: "example.com",
|
|
608
|
+
limit: 1,
|
|
609
|
+
});
|
|
610
|
+
assert.deepEqual(
|
|
611
|
+
cut.items.map((a) => a.normalizedEmail),
|
|
612
|
+
["zoe@example.com"],
|
|
613
|
+
"a short suggestion list keeps the address worth suggesting",
|
|
258
614
|
);
|
|
259
615
|
|
|
260
|
-
await repo.deleteManyAddresses(accountConfigId, [
|
|
616
|
+
await repo.deleteManyAddresses(accountConfigId, [
|
|
617
|
+
stranger.addressId,
|
|
618
|
+
frequent.addressId,
|
|
619
|
+
]);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test("listByAccountConfig pages a ranked search without dupes or gaps", async () => {
|
|
623
|
+
const accountConfigId = randomId();
|
|
624
|
+
const created: string[] = [];
|
|
625
|
+
for (const [index, name] of ["a", "b", "c", "d", "e"].entries()) {
|
|
626
|
+
const addr = await repo.createAddress({
|
|
627
|
+
...makeAddressInput(accountConfigId, `${name}@ranked.test`),
|
|
628
|
+
displayName: name,
|
|
629
|
+
normalizedCompound: `${name} ${name}@ranked.test`,
|
|
630
|
+
inboundCount: index % 2,
|
|
631
|
+
});
|
|
632
|
+
created.push(addr.addressId);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const seen: string[] = [];
|
|
636
|
+
let cursor: string | undefined;
|
|
637
|
+
let pages = 0;
|
|
638
|
+
do {
|
|
639
|
+
const page = await repo.listByAccountConfig({
|
|
640
|
+
accountConfigId,
|
|
641
|
+
search: "ranked.test",
|
|
642
|
+
limit: 2,
|
|
643
|
+
cursor,
|
|
644
|
+
});
|
|
645
|
+
seen.push(...page.items.map((a) => a.addressId));
|
|
646
|
+
cursor = page.continuationToken;
|
|
647
|
+
pages++;
|
|
648
|
+
assert.ok(pages < 10, "pagination must terminate");
|
|
649
|
+
} while (cursor);
|
|
650
|
+
|
|
651
|
+
assert.equal(new Set(seen).size, 5, "every row returned exactly once");
|
|
652
|
+
assert.deepEqual([...seen].sort(), [...created].sort(), "no gaps");
|
|
653
|
+
|
|
654
|
+
await repo.deleteManyAddresses(accountConfigId, created);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("listByAccountConfig treats LIKE metacharacters in a search term literally", async () => {
|
|
658
|
+
const accountConfigId = randomId();
|
|
659
|
+
const plain = await repo.createAddress(
|
|
660
|
+
makeAddressInput(accountConfigId, "ab@x.com"),
|
|
661
|
+
);
|
|
662
|
+
const literal = await repo.createAddress(
|
|
663
|
+
makeAddressInput(accountConfigId, "a%b_c@x.com"),
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
for (const term of ["a_@x.com", "a%x", "a%b_c@y"]) {
|
|
667
|
+
const found = await repo.listByAccountConfig({
|
|
668
|
+
accountConfigId,
|
|
669
|
+
search: term,
|
|
670
|
+
});
|
|
671
|
+
assert.deepEqual(
|
|
672
|
+
found.items.map((a) => a.addressId),
|
|
673
|
+
[],
|
|
674
|
+
`"${term}" must match no wildcard`,
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
for (const term of ["a%b", "b_c", "a%b_c@x.com"]) {
|
|
679
|
+
const found = await repo.listByAccountConfig({
|
|
680
|
+
accountConfigId,
|
|
681
|
+
search: term,
|
|
682
|
+
});
|
|
683
|
+
assert.deepEqual(
|
|
684
|
+
found.items.map((a) => a.addressId),
|
|
685
|
+
[literal.addressId],
|
|
686
|
+
`"${term}" must match the metacharacter literally`,
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
await repo.deleteManyAddresses(accountConfigId, [
|
|
691
|
+
plain.addressId,
|
|
692
|
+
literal.addressId,
|
|
693
|
+
]);
|
|
261
694
|
});
|
|
262
695
|
|
|
263
696
|
test("cross-tenant: getAddress refuses a foreign accountConfig", async () => {
|
|
@@ -412,6 +845,12 @@ describe("AddressRepo", () => {
|
|
|
412
845
|
["an unparseable", "not-a-cursor"],
|
|
413
846
|
["a bare number", Buffer.from("123").toString("base64url")],
|
|
414
847
|
["a JSON array", Buffer.from("[1,2]").toString("base64url")],
|
|
848
|
+
[
|
|
849
|
+
"a token without a ranking key",
|
|
850
|
+
Buffer.from(
|
|
851
|
+
JSON.stringify({ normalizedCompound: "a", addressId: "b" }),
|
|
852
|
+
).toString("base64url"),
|
|
853
|
+
],
|
|
415
854
|
] as const) {
|
|
416
855
|
test(`${label} cursor is rejected as a 400`, async () => {
|
|
417
856
|
await assert.rejects(
|
package/src/repos/i4-address.ts
CHANGED
|
@@ -9,40 +9,105 @@ import type {
|
|
|
9
9
|
ResultList,
|
|
10
10
|
UpdateAddressInput,
|
|
11
11
|
} from "@remit/data-ports";
|
|
12
|
-
import {
|
|
12
|
+
import { BadRequestError } from "@remit/data-ports/errors";
|
|
13
|
+
import {
|
|
14
|
+
and,
|
|
15
|
+
asc,
|
|
16
|
+
desc,
|
|
17
|
+
eq,
|
|
18
|
+
getTableColumns,
|
|
19
|
+
inArray,
|
|
20
|
+
type SQL,
|
|
21
|
+
sql,
|
|
22
|
+
} from "drizzle-orm";
|
|
13
23
|
import type { Db } from "../db.js";
|
|
14
24
|
import { NotFoundError } from "../error.js";
|
|
15
25
|
import { envelopeAddressId as deriveEnvelopeAddressId } from "../id.js";
|
|
16
26
|
import { decodeToken, resultList } from "../pagination.js";
|
|
17
27
|
import { addressTable } from "../schema/i4-address.js";
|
|
18
28
|
import { envelopeAddressTable } from "../schema/message-data.js";
|
|
29
|
+
import {
|
|
30
|
+
addressCorrespondence,
|
|
31
|
+
addressMatchRank,
|
|
32
|
+
addressPreference,
|
|
33
|
+
addressRecency,
|
|
34
|
+
addressSearchMatch,
|
|
35
|
+
} from "./address-search-predicates.js";
|
|
19
36
|
import { shouldPromoteWellknown } from "./i4-address-wellknown.js";
|
|
20
37
|
|
|
21
38
|
type DB = Db<Record<string, unknown>>;
|
|
22
39
|
|
|
40
|
+
type AddressUpdate = Partial<{
|
|
41
|
+
[K in keyof typeof addressTable.$inferInsert]:
|
|
42
|
+
| (typeof addressTable.$inferInsert)[K]
|
|
43
|
+
| SQL;
|
|
44
|
+
}>;
|
|
45
|
+
|
|
23
46
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
47
|
+
* The stored `"<display name> <email>"` compound, folded in JavaScript exactly
|
|
48
|
+
* as message sync folds it — SQL `lower()` stops at ASCII, and the search reads
|
|
49
|
+
* this column expecting a full fold.
|
|
26
50
|
*/
|
|
27
|
-
const
|
|
28
|
-
|
|
51
|
+
const compoundOfSql = (displayName: string): SQL<string> =>
|
|
52
|
+
sql<string>`trim(${displayName.toLowerCase()} || ' ' || ${addressTable.normalizedEmail})`;
|
|
29
53
|
|
|
30
54
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* match on it only ever answers display-name queries — an exact-address lookup
|
|
36
|
-
* such as `support@npmjs.com` can never match a row whose sender has a display
|
|
37
|
-
* name. Matching `normalizedEmail` in the same predicate is what makes address
|
|
38
|
-
* resolution by email work at all (issue #51).
|
|
55
|
+
* The order a suggestion list comes back in: where the term hit, then the
|
|
56
|
+
* account's own standing for the sender, then how much it corresponds with it,
|
|
57
|
+
* then how recently — and the stored compound and the id last, so the order is
|
|
58
|
+
* total and a page boundary is a position rather than a guess.
|
|
39
59
|
*/
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
60
|
+
const searchOrder = (search: string | undefined) =>
|
|
61
|
+
[
|
|
62
|
+
{ key: "rank", expr: addressMatchRank(search), direction: "desc" },
|
|
63
|
+
{ key: "preference", expr: addressPreference(), direction: "desc" },
|
|
64
|
+
{ key: "correspondence", expr: addressCorrespondence(), direction: "desc" },
|
|
65
|
+
{ key: "recency", expr: addressRecency(), direction: "desc" },
|
|
66
|
+
{
|
|
67
|
+
key: "normalizedCompound",
|
|
68
|
+
expr: sql`${addressTable.normalizedCompound}`,
|
|
69
|
+
direction: "asc",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
key: "addressId",
|
|
73
|
+
expr: sql`${addressTable.addressId}`,
|
|
74
|
+
direction: "asc",
|
|
75
|
+
},
|
|
76
|
+
] as const;
|
|
77
|
+
|
|
78
|
+
type SearchOrder = ReturnType<typeof searchOrder>;
|
|
79
|
+
type CursorPosition = Record<SearchOrder[number]["key"], number | string>;
|
|
80
|
+
|
|
81
|
+
const decodeAddressCursor = (
|
|
82
|
+
cursor: string,
|
|
83
|
+
order: SearchOrder,
|
|
84
|
+
): CursorPosition => {
|
|
85
|
+
const decoded = decodeToken(cursor);
|
|
86
|
+
const position = {} as Record<string, number | string>;
|
|
87
|
+
for (const { key } of order) {
|
|
88
|
+
const value = decoded[key];
|
|
89
|
+
if (typeof value !== "number" && typeof value !== "string") {
|
|
90
|
+
throw new BadRequestError("Invalid continuationToken");
|
|
91
|
+
}
|
|
92
|
+
position[key] = value;
|
|
93
|
+
}
|
|
94
|
+
return position as CursorPosition;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resume after a position in that order: strictly past the first key, or equal
|
|
99
|
+
* on it and strictly past the rest.
|
|
100
|
+
*/
|
|
101
|
+
const after = (order: SearchOrder, position: CursorPosition): SQL => {
|
|
102
|
+
const step = (index: number): SQL => {
|
|
103
|
+
const { key, expr, direction } = order[index];
|
|
104
|
+
const value = position[key];
|
|
105
|
+
const past =
|
|
106
|
+
direction === "desc" ? sql`${expr} < ${value}` : sql`${expr} > ${value}`;
|
|
107
|
+
if (index === order.length - 1) return past;
|
|
108
|
+
return sql`(${past} or (${expr} = ${value} and ${step(index + 1)}))`;
|
|
109
|
+
};
|
|
110
|
+
return step(0);
|
|
46
111
|
};
|
|
47
112
|
|
|
48
113
|
export function rowToAddress(
|
|
@@ -115,6 +180,10 @@ export class AddressRepo implements IAddressRepository {
|
|
|
115
180
|
return rowToAddress(row);
|
|
116
181
|
}
|
|
117
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Message sync sends `""` for a bare address, so a sighting carrying no
|
|
185
|
+
* display name must leave the stored one alone rather than erase it.
|
|
186
|
+
*/
|
|
118
187
|
async upsertAddress(input: CreateAddressInput): Promise<AddressItem> {
|
|
119
188
|
const now = Date.now();
|
|
120
189
|
const [row] = await this.db
|
|
@@ -139,10 +208,13 @@ export class AddressRepo implements IAddressRepository {
|
|
|
139
208
|
})
|
|
140
209
|
.onConflictDoUpdate({
|
|
141
210
|
target: addressTable.addressId,
|
|
142
|
-
set:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
211
|
+
set: input.displayName
|
|
212
|
+
? {
|
|
213
|
+
displayName: input.displayName,
|
|
214
|
+
normalizedCompound: input.normalizedCompound,
|
|
215
|
+
updatedAt: now,
|
|
216
|
+
}
|
|
217
|
+
: { updatedAt: now },
|
|
146
218
|
})
|
|
147
219
|
.returning();
|
|
148
220
|
return rowToAddress(row);
|
|
@@ -192,11 +264,11 @@ export class AddressRepo implements IAddressRepository {
|
|
|
192
264
|
input: UpdateAddressInput,
|
|
193
265
|
): Promise<AddressItem> {
|
|
194
266
|
const now = Date.now();
|
|
195
|
-
const updates:
|
|
196
|
-
|
|
197
|
-
};
|
|
198
|
-
if (input.displayName !== undefined)
|
|
267
|
+
const updates: AddressUpdate = { updatedAt: now };
|
|
268
|
+
if (input.displayName !== undefined) {
|
|
199
269
|
updates.displayName = input.displayName;
|
|
270
|
+
updates.normalizedCompound = compoundOfSql(input.displayName);
|
|
271
|
+
}
|
|
200
272
|
if (input.flags !== undefined) updates.flags = input.flags as never;
|
|
201
273
|
if (input.inboundCount !== undefined)
|
|
202
274
|
updates.inboundCount = input.inboundCount;
|
|
@@ -324,13 +396,14 @@ export class AddressRepo implements IAddressRepository {
|
|
|
324
396
|
accountConfigId: string,
|
|
325
397
|
addressId: string,
|
|
326
398
|
now: number,
|
|
327
|
-
|
|
399
|
+
isBulk?: boolean,
|
|
328
400
|
): Promise<void> {
|
|
329
401
|
const current = await this.getAddress(accountConfigId, addressId);
|
|
330
402
|
const post = {
|
|
331
403
|
...current,
|
|
332
404
|
inboundCount: (current.inboundCount ?? 0) + 1,
|
|
333
405
|
lastInboundAt: now,
|
|
406
|
+
isBulk: isBulk ?? false,
|
|
334
407
|
};
|
|
335
408
|
if (shouldPromoteWellknown(post, now)) {
|
|
336
409
|
const nextFlags: AddressFlags = {
|
|
@@ -484,48 +557,46 @@ export class AddressRepo implements IAddressRepository {
|
|
|
484
557
|
limit?: number;
|
|
485
558
|
}): Promise<ResultList<AddressItem>> {
|
|
486
559
|
const { accountConfigId, search, cursor, limit = 100 } = input;
|
|
487
|
-
const
|
|
488
|
-
const
|
|
489
|
-
? {
|
|
490
|
-
normalizedCompound: decoded.normalizedCompound as string,
|
|
491
|
-
addressId: decoded.addressId as string,
|
|
492
|
-
}
|
|
493
|
-
: undefined;
|
|
560
|
+
const order = searchOrder(search);
|
|
561
|
+
const position = cursor ? decodeAddressCursor(cursor, order) : undefined;
|
|
494
562
|
|
|
495
563
|
const rows = await this.db
|
|
496
|
-
.select(
|
|
564
|
+
.select({
|
|
565
|
+
...getTableColumns(addressTable),
|
|
566
|
+
rank: order[0].expr,
|
|
567
|
+
preference: order[1].expr,
|
|
568
|
+
correspondence: order[2].expr,
|
|
569
|
+
recency: order[3].expr,
|
|
570
|
+
})
|
|
497
571
|
.from(addressTable)
|
|
498
572
|
.where(
|
|
499
573
|
and(
|
|
500
574
|
eq(addressTable.accountConfigId, accountConfigId),
|
|
501
|
-
search ?
|
|
502
|
-
after
|
|
503
|
-
? or(
|
|
504
|
-
gt(addressTable.normalizedCompound, after.normalizedCompound),
|
|
505
|
-
and(
|
|
506
|
-
eq(addressTable.normalizedCompound, after.normalizedCompound),
|
|
507
|
-
gt(addressTable.addressId, after.addressId),
|
|
508
|
-
),
|
|
509
|
-
)
|
|
510
|
-
: undefined,
|
|
575
|
+
search ? addressSearchMatch(search) : undefined,
|
|
576
|
+
position ? after(order, position) : undefined,
|
|
511
577
|
),
|
|
512
578
|
)
|
|
513
579
|
.orderBy(
|
|
514
|
-
|
|
515
|
-
|
|
580
|
+
...order.map(({ expr, direction }) =>
|
|
581
|
+
direction === "desc" ? desc(expr) : asc(expr),
|
|
582
|
+
),
|
|
516
583
|
)
|
|
517
584
|
.limit(limit + 1);
|
|
518
585
|
|
|
519
586
|
const hasMore = rows.length > limit;
|
|
520
|
-
const
|
|
521
|
-
const
|
|
587
|
+
const page = rows.slice(0, limit);
|
|
588
|
+
const lastRow = page[page.length - 1];
|
|
522
589
|
return resultList(
|
|
523
|
-
|
|
590
|
+
page.map(rowToAddress),
|
|
524
591
|
limit,
|
|
525
|
-
hasMore &&
|
|
592
|
+
hasMore && lastRow
|
|
526
593
|
? {
|
|
527
|
-
|
|
528
|
-
|
|
594
|
+
rank: lastRow.rank,
|
|
595
|
+
preference: lastRow.preference,
|
|
596
|
+
correspondence: lastRow.correspondence,
|
|
597
|
+
recency: lastRow.recency,
|
|
598
|
+
normalizedCompound: lastRow.normalizedCompound,
|
|
599
|
+
addressId: lastRow.addressId,
|
|
529
600
|
}
|
|
530
601
|
: undefined,
|
|
531
602
|
);
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { after, before, describe, test } from "node:test";
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
4
|
+
import { NotFoundError } from "../error.js";
|
|
5
|
+
import { senderSignerStandingTable } from "../schema.js";
|
|
6
|
+
import { createTestDb, randomId, type TestDb } from "../test-db.js";
|
|
7
|
+
import { SenderSignerStandingRepo } from "./i4-sender-signer-standing.js";
|
|
8
|
+
|
|
9
|
+
describe("SenderSignerStandingRepo", () => {
|
|
10
|
+
let db: TestDb;
|
|
11
|
+
let close: () => Promise<void>;
|
|
12
|
+
let repo: SenderSignerStandingRepo;
|
|
13
|
+
|
|
14
|
+
const rowsFor = async (accountConfigId: string) =>
|
|
15
|
+
db
|
|
16
|
+
.select()
|
|
17
|
+
.from(senderSignerStandingTable)
|
|
18
|
+
.where(eq(senderSignerStandingTable.accountConfigId, accountConfigId));
|
|
19
|
+
|
|
20
|
+
before(async () => {
|
|
21
|
+
({ db, close } = await createTestDb());
|
|
22
|
+
repo = new SenderSignerStandingRepo(db as never);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
after(async () => {
|
|
26
|
+
await close();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("observing a new key inserts one row at a count of one", async () => {
|
|
30
|
+
const accountConfigId = randomId();
|
|
31
|
+
const observedAt = 1_700_000_000_000;
|
|
32
|
+
|
|
33
|
+
const standing = await repo.observe({
|
|
34
|
+
accountConfigId,
|
|
35
|
+
senderKey: "vip.example",
|
|
36
|
+
signerDomain: "esp.example",
|
|
37
|
+
observedAt,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
assert.equal(standing.messageCount, 1);
|
|
41
|
+
assert.equal(standing.firstSeenAt, observedAt);
|
|
42
|
+
assert.equal(standing.lastSeenAt, observedAt);
|
|
43
|
+
assert.equal(standing.userAffirmedAt, 0);
|
|
44
|
+
assert.equal((await rowsFor(accountConfigId)).length, 1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("observing the same key again increments the count in place rather than inserting a second row", async () => {
|
|
48
|
+
const accountConfigId = randomId();
|
|
49
|
+
const key = {
|
|
50
|
+
accountConfigId,
|
|
51
|
+
senderKey: "vip.example",
|
|
52
|
+
signerDomain: "esp.example",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
await repo.observe({ ...key, observedAt: 1_700_000_000_000 });
|
|
56
|
+
await repo.observe({ ...key, observedAt: 1_700_000_060_000 });
|
|
57
|
+
const third = await repo.observe({
|
|
58
|
+
...key,
|
|
59
|
+
observedAt: 1_700_000_120_000,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
assert.equal(third.messageCount, 3);
|
|
63
|
+
const rows = await rowsFor(accountConfigId);
|
|
64
|
+
assert.equal(rows.length, 1);
|
|
65
|
+
assert.equal(rows[0]?.messageCount, 3);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// The failure this guards is silent: naming first_seen_at in the conflict
|
|
69
|
+
// `set` makes every message reset the key's age, so standing reads as
|
|
70
|
+
// brand-new forever and nothing downstream can tell.
|
|
71
|
+
test("a repeat leaves firstSeenAt at the first observation while lastSeenAt follows the latest", async () => {
|
|
72
|
+
const accountConfigId = randomId();
|
|
73
|
+
const key = {
|
|
74
|
+
accountConfigId,
|
|
75
|
+
senderKey: "list.example",
|
|
76
|
+
signerDomain: "unverified",
|
|
77
|
+
};
|
|
78
|
+
const first = 1_600_000_000_000;
|
|
79
|
+
|
|
80
|
+
await repo.observe({ ...key, observedAt: first });
|
|
81
|
+
await repo.observe({ ...key, observedAt: first + 3_600_000 });
|
|
82
|
+
const latest = await repo.observe({
|
|
83
|
+
...key,
|
|
84
|
+
observedAt: first + 7_200_000,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
assert.equal(latest.firstSeenAt, first);
|
|
88
|
+
assert.equal(latest.lastSeenAt, first + 7_200_000);
|
|
89
|
+
|
|
90
|
+
const [row] = await rowsFor(accountConfigId);
|
|
91
|
+
assert.equal(row?.firstSeenAt, first);
|
|
92
|
+
assert.equal(row?.lastSeenAt, first + 7_200_000);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("an out-of-order observation still counts, and never rewrites firstSeenAt", async () => {
|
|
96
|
+
const accountConfigId = randomId();
|
|
97
|
+
const key = {
|
|
98
|
+
accountConfigId,
|
|
99
|
+
senderKey: "delayed.example",
|
|
100
|
+
signerDomain: "esp.example",
|
|
101
|
+
};
|
|
102
|
+
const first = 1_650_000_000_000;
|
|
103
|
+
|
|
104
|
+
await repo.observe({ ...key, observedAt: first });
|
|
105
|
+
const older = await repo.observe({
|
|
106
|
+
...key,
|
|
107
|
+
observedAt: first - 86_400_000,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
assert.equal(older.messageCount, 2);
|
|
111
|
+
assert.equal(older.firstSeenAt, first);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("the same sender under two signer domains keeps two independent rows", async () => {
|
|
115
|
+
const accountConfigId = randomId();
|
|
116
|
+
const observedAt = 1_700_000_000_000;
|
|
117
|
+
|
|
118
|
+
await repo.observe({
|
|
119
|
+
accountConfigId,
|
|
120
|
+
senderKey: "shop.example",
|
|
121
|
+
signerDomain: "esp-one.example",
|
|
122
|
+
observedAt,
|
|
123
|
+
});
|
|
124
|
+
await repo.observe({
|
|
125
|
+
accountConfigId,
|
|
126
|
+
senderKey: "shop.example",
|
|
127
|
+
signerDomain: "esp-one.example",
|
|
128
|
+
observedAt,
|
|
129
|
+
});
|
|
130
|
+
const other = await repo.observe({
|
|
131
|
+
accountConfigId,
|
|
132
|
+
senderKey: "shop.example",
|
|
133
|
+
signerDomain: "esp-two.example",
|
|
134
|
+
observedAt,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
assert.equal(other.messageCount, 1);
|
|
138
|
+
assert.equal((await rowsFor(accountConfigId)).length, 2);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("standing never crosses accounts", async () => {
|
|
142
|
+
const mine = randomId();
|
|
143
|
+
const theirs = randomId();
|
|
144
|
+
const key = {
|
|
145
|
+
senderKey: "shared.example",
|
|
146
|
+
signerDomain: "esp.example",
|
|
147
|
+
observedAt: 1_700_000_000_000,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
await repo.observe({ accountConfigId: mine, ...key });
|
|
151
|
+
await repo.observe({ accountConfigId: mine, ...key });
|
|
152
|
+
const foreign = await repo.observe({ accountConfigId: theirs, ...key });
|
|
153
|
+
|
|
154
|
+
assert.equal(foreign.messageCount, 1);
|
|
155
|
+
assert.equal(
|
|
156
|
+
(await repo.get(mine, key.senderKey, key.signerDomain)).messageCount,
|
|
157
|
+
2,
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("get raises NotFoundError for a key that was never observed", async () => {
|
|
162
|
+
await assert.rejects(
|
|
163
|
+
repo.get(randomId(), "stranger.example", "esp.example"),
|
|
164
|
+
(error) => error instanceof NotFoundError,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("get reads back the row the last observation returned", async () => {
|
|
169
|
+
const accountConfigId = randomId();
|
|
170
|
+
const key = {
|
|
171
|
+
accountConfigId,
|
|
172
|
+
senderKey: "readback.example",
|
|
173
|
+
signerDomain: "esp.example",
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const written = await repo.observe({
|
|
177
|
+
...key,
|
|
178
|
+
observedAt: 1_700_000_000_000,
|
|
179
|
+
});
|
|
180
|
+
const read = await repo.get(
|
|
181
|
+
accountConfigId,
|
|
182
|
+
key.senderKey,
|
|
183
|
+
key.signerDomain,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
assert.deepEqual(read, written);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ISenderSignerStandingRepository,
|
|
3
|
+
ObserveSenderSignerStandingInput,
|
|
4
|
+
SenderSignerStandingItem,
|
|
5
|
+
} from "@remit/data-ports";
|
|
6
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
7
|
+
import type { Db } from "../db.js";
|
|
8
|
+
import { NotFoundError } from "../error.js";
|
|
9
|
+
import { senderSignerStandingTable } from "../schema.js";
|
|
10
|
+
|
|
11
|
+
type DB = Db<Record<string, unknown>>;
|
|
12
|
+
|
|
13
|
+
function rowToStanding(
|
|
14
|
+
row: typeof senderSignerStandingTable.$inferSelect,
|
|
15
|
+
): SenderSignerStandingItem {
|
|
16
|
+
return {
|
|
17
|
+
accountConfigId: row.accountConfigId,
|
|
18
|
+
senderKey: row.senderKey,
|
|
19
|
+
signerDomain: row.signerDomain,
|
|
20
|
+
messageCount: row.messageCount,
|
|
21
|
+
firstSeenAt: row.firstSeenAt,
|
|
22
|
+
lastSeenAt: row.lastSeenAt,
|
|
23
|
+
userAffirmedAt: row.userAffirmedAt,
|
|
24
|
+
createdAt: row.createdAt,
|
|
25
|
+
updatedAt: row.updatedAt,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class SenderSignerStandingRepo
|
|
30
|
+
implements ISenderSignerStandingRepository
|
|
31
|
+
{
|
|
32
|
+
constructor(private db: DB) {}
|
|
33
|
+
|
|
34
|
+
async observe(
|
|
35
|
+
input: ObserveSenderSignerStandingInput,
|
|
36
|
+
): Promise<SenderSignerStandingItem> {
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
// `first_seen_at` is deliberately absent from the conflict `set`.
|
|
39
|
+
// onConflictDoUpdate writes only the columns it names, so naming it here
|
|
40
|
+
// would let every message reset the key's age to its own arrival and the
|
|
41
|
+
// standing this row exists to record would never be older than the last
|
|
42
|
+
// message. The mirror-image mistake — a conflict path that must reset a
|
|
43
|
+
// timestamp and would silently inherit the old one by omitting it — is at
|
|
44
|
+
// i4-message-flag-push.ts:70.
|
|
45
|
+
const [row] = await this.db
|
|
46
|
+
.insert(senderSignerStandingTable)
|
|
47
|
+
.values({
|
|
48
|
+
accountConfigId: input.accountConfigId,
|
|
49
|
+
senderKey: input.senderKey,
|
|
50
|
+
signerDomain: input.signerDomain,
|
|
51
|
+
messageCount: 1,
|
|
52
|
+
firstSeenAt: input.observedAt,
|
|
53
|
+
lastSeenAt: input.observedAt,
|
|
54
|
+
userAffirmedAt: 0,
|
|
55
|
+
createdAt: now,
|
|
56
|
+
updatedAt: now,
|
|
57
|
+
})
|
|
58
|
+
.onConflictDoUpdate({
|
|
59
|
+
target: [
|
|
60
|
+
senderSignerStandingTable.accountConfigId,
|
|
61
|
+
senderSignerStandingTable.senderKey,
|
|
62
|
+
senderSignerStandingTable.signerDomain,
|
|
63
|
+
],
|
|
64
|
+
set: {
|
|
65
|
+
messageCount: sql`${senderSignerStandingTable.messageCount} + 1`,
|
|
66
|
+
lastSeenAt: input.observedAt,
|
|
67
|
+
updatedAt: now,
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
.returning();
|
|
71
|
+
if (!row) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Failed to upsert SenderSignerStanding: ${input.accountConfigId}/${input.senderKey}/${input.signerDomain}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return rowToStanding(row);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async get(
|
|
80
|
+
accountConfigId: string,
|
|
81
|
+
senderKey: string,
|
|
82
|
+
signerDomain: string,
|
|
83
|
+
): Promise<SenderSignerStandingItem> {
|
|
84
|
+
const [row] = await this.db
|
|
85
|
+
.select()
|
|
86
|
+
.from(senderSignerStandingTable)
|
|
87
|
+
.where(
|
|
88
|
+
and(
|
|
89
|
+
eq(senderSignerStandingTable.accountConfigId, accountConfigId),
|
|
90
|
+
eq(senderSignerStandingTable.senderKey, senderKey),
|
|
91
|
+
eq(senderSignerStandingTable.signerDomain, signerDomain),
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
if (!row) {
|
|
95
|
+
throw new NotFoundError(
|
|
96
|
+
`SenderSignerStanding not found: ${senderKey}/${signerDomain}`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return rowToStanding(row);
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/repos/message.ts
CHANGED
|
@@ -59,6 +59,7 @@ function toMessageItem(row: typeof messageTable.$inferSelect): MessageItem {
|
|
|
59
59
|
status: row.status,
|
|
60
60
|
syncStatus: row.syncStatus,
|
|
61
61
|
category: row.category,
|
|
62
|
+
authenticityVerdict: row.authenticityVerdict,
|
|
62
63
|
hasListUnsubscribe: row.hasListUnsubscribe,
|
|
63
64
|
movedByRemit: row.movedByRemit,
|
|
64
65
|
createdAt: row.createdAt,
|
|
@@ -184,6 +185,8 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
184
185
|
status: input.status ?? ("active" as const),
|
|
185
186
|
syncStatus: input.syncStatus ?? ("pending" as const),
|
|
186
187
|
category: input.category ?? ("uncategorized" as const),
|
|
188
|
+
authenticityVerdict:
|
|
189
|
+
input.authenticityVerdict ?? ("NotEvaluated" as const),
|
|
187
190
|
hasListUnsubscribe: input.hasListUnsubscribe ?? false,
|
|
188
191
|
movedByRemit: input.movedByRemit ?? false,
|
|
189
192
|
messageIdHeader: input.messageIdHeader ?? null,
|
|
@@ -365,6 +368,9 @@ export class DrizzleMessageRepository implements IMessageRepository {
|
|
|
365
368
|
? { syncStatus: input.syncStatus }
|
|
366
369
|
: {}),
|
|
367
370
|
...(input.category !== undefined ? { category: input.category } : {}),
|
|
371
|
+
...(input.authenticityVerdict !== undefined
|
|
372
|
+
? { authenticityVerdict: input.authenticityVerdict }
|
|
373
|
+
: {}),
|
|
368
374
|
...(input.hasListUnsubscribe !== undefined
|
|
369
375
|
? { hasListUnsubscribe: input.hasListUnsubscribe }
|
|
370
376
|
: {}),
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { senderSignerStandingRepositoryConformance } from "@remit/data-ports/conformance";
|
|
2
|
+
import { NotFoundError } from "../error.js";
|
|
3
|
+
import { randomId } from "../id.js";
|
|
4
|
+
import { senderSignerStandingTable } from "../schema.js";
|
|
5
|
+
import { createSqliteTestDb } from "../test-db-sqlite.js";
|
|
6
|
+
import { SenderSignerStandingRepo } from "./i4-sender-signer-standing.js";
|
|
7
|
+
|
|
8
|
+
let close: (() => Promise<void>) | undefined;
|
|
9
|
+
|
|
10
|
+
senderSignerStandingRepositoryConformance({
|
|
11
|
+
async createRepository() {
|
|
12
|
+
const { db, close: closeDb } = await createSqliteTestDb({
|
|
13
|
+
senderSignerStandings: senderSignerStandingTable,
|
|
14
|
+
});
|
|
15
|
+
close = closeDb;
|
|
16
|
+
return new SenderSignerStandingRepo(db as never);
|
|
17
|
+
},
|
|
18
|
+
teardown: async () => {
|
|
19
|
+
await close?.();
|
|
20
|
+
},
|
|
21
|
+
makeId: () => randomId(),
|
|
22
|
+
isNotFoundError: (error) => error instanceof NotFoundError,
|
|
23
|
+
});
|
package/src/schema.ts
CHANGED
|
@@ -14,6 +14,7 @@ export const labelTable = entities.labels;
|
|
|
14
14
|
export const mailboxAttributeEntryTable = entities.mailboxAttributeEntries;
|
|
15
15
|
export const mailboxFlagTable = entities.mailboxFlags;
|
|
16
16
|
export const messageLabelTable = entities.messageLabels;
|
|
17
|
+
export const senderSignerStandingTable = entities.senderSignerStandings;
|
|
17
18
|
export * from "./schema/i4-account-config.js";
|
|
18
19
|
export * from "./schema/i4-account-export-request.js";
|
|
19
20
|
export * from "./schema/i4-account-setting.js";
|