@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/drizzle-service",
3
- "version": "0.0.39",
3
+ "version": "0.0.41",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
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 treats LIKE metacharacters in a search term literally", async () => {
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 plain = await repo.createAddress(
247
- makeAddressInput(accountConfigId, "ab@x.com"),
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
- const result = await repo.listByAccountConfig({
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: "a_@x.com",
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
- result.items.length,
256
- 0,
257
- "`_` must not act as a single-character wildcard",
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, [plain.addressId]);
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(
@@ -9,40 +9,105 @@ import type {
9
9
  ResultList,
10
10
  UpdateAddressInput,
11
11
  } from "@remit/data-ports";
12
- import { and, asc, eq, gt, inArray, or, sql } from "drizzle-orm";
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
- * Escape the LIKE metacharacters so a term containing `%` or `_` (both legal in
25
- * an email local part) matches literally instead of as a wildcard.
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 escapeLikeTerm = (term: string): string =>
28
- term.replace(/[\\%_]/g, (char) => `\\${char}`);
51
+ const compoundOfSql = (displayName: string): SQL<string> =>
52
+ sql<string>`trim(${displayName.toLowerCase()} || ' ' || ${addressTable.normalizedEmail})`;
29
53
 
30
54
  /**
31
- * Match an address search term as a prefix of either the display-name compound
32
- * or the normalized email.
33
- *
34
- * `normalizedCompound` is stored as `"<display name> <email>"`, so a prefix
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 addressSearchPredicate = (term: string) => {
41
- const pattern = `${escapeLikeTerm(term)}%`;
42
- return or(
43
- sql`${addressTable.normalizedCompound} LIKE ${pattern} ESCAPE '\\'`,
44
- sql`${addressTable.normalizedEmail} LIKE ${pattern} ESCAPE '\\'`,
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
- displayName: input.displayName ?? sql`${addressTable.displayName}`,
144
- updatedAt: now,
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: Partial<typeof addressTable.$inferInsert> = {
196
- updatedAt: now,
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
- _isBulk?: boolean,
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 decoded = cursor ? decodeToken(cursor) : undefined;
488
- const after = decoded
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 ? addressSearchPredicate(search) : undefined,
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
- asc(addressTable.normalizedCompound),
515
- asc(addressTable.addressId),
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 items = rows.slice(0, limit).map(rowToAddress);
521
- const lastItem = items[items.length - 1];
587
+ const page = rows.slice(0, limit);
588
+ const lastRow = page[page.length - 1];
522
589
  return resultList(
523
- items,
590
+ page.map(rowToAddress),
524
591
  limit,
525
- hasMore && lastItem
592
+ hasMore && lastRow
526
593
  ? {
527
- normalizedCompound: lastItem.normalizedCompound,
528
- addressId: lastItem.addressId,
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
+ }
@@ -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";