@remit/drizzle-service 0.0.61 → 0.0.62

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.61",
3
+ "version": "0.0.62",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,3 +1,4 @@
1
+ import { domainToASCII } from "node:url";
1
2
  import { eq, or, type SQL, sql } from "drizzle-orm";
2
3
  import { addressTable } from "../schema/i4-address.js";
3
4
 
@@ -33,12 +34,42 @@ const patterns = (term: string) => {
33
34
  return { leading: `${escaped}%`, anywhere: `%${escaped}%` };
34
35
  };
35
36
 
37
+ /**
38
+ * The envelope delivers internationalized domains in punycode, so every column
39
+ * holds `xn--bcher-kva.de` even though the reader types `bücher.de` (#905). A
40
+ * term that names a domain is converted the same way, label by label, and both
41
+ * spellings are searched. Only a whole label converts to what the row holds:
42
+ * `bücher` becomes `xn--bcher-kva`, but half of it, `büche`, becomes
43
+ * `xn--bche-0ra`, which prefixes nothing. Only a term carrying a `.` or an `@`
44
+ * is folded at all, so a name like `Özcan` does not pay for a second spelling
45
+ * in every predicate. The address is rebuilt around the converted domain, so
46
+ * the whole address the reader typed still meets the row it names.
47
+ *
48
+ * The other direction is out of scope: sync writes `addr.host` as the envelope
49
+ * spelled it, so an SMTPUTF8 delivery can store a domain in unicode that a
50
+ * punycode term then misses. #905 covers the punycode rows only.
51
+ */
52
+ const punyVariants = (term: string): string[] => {
53
+ const folded = term.toLowerCase();
54
+ if (!/[.@]/.test(folded)) return [folded];
55
+ const at = folded.indexOf("@");
56
+ const local = at === -1 ? "" : folded.slice(0, at);
57
+ const domain = at === -1 ? folded : folded.slice(at + 1);
58
+ if (!/[^\p{ASCII}]/u.test(domain)) return [folded];
59
+ const ascii = domainToASCII(domain);
60
+ if (!ascii) return [folded];
61
+ return [folded, local ? `${local}@${ascii}` : ascii];
62
+ };
63
+
36
64
  export const addressSearchMatch = (term: string): SQL => {
37
- const { anywhere } = patterns(term);
65
+ const variants = punyVariants(term);
38
66
  const matched = or(
39
- ...[...SEARCH_COLUMNS, FOLDED_FALLBACK].map((column) =>
40
- like(column, anywhere),
41
- ),
67
+ ...variants.flatMap((variant) => {
68
+ const { anywhere } = patterns(variant);
69
+ return [...SEARCH_COLUMNS, FOLDED_FALLBACK].map((column) =>
70
+ like(column, anywhere),
71
+ );
72
+ }),
42
73
  );
43
74
  if (matched === undefined) throw new Error("no address column to search");
44
75
  return matched;
@@ -60,6 +91,15 @@ export const addressMatchRank = (term: string | undefined): SQL<number> => {
60
91
  // Not the bare literal `0`: SQLite reads an integer literal in ORDER BY as a
61
92
  // column index and rejects it as out of range.
62
93
  if (!term) return sql<number>`cast(0 as integer)`;
94
+ // The best rank across every spelling of the term: whichever form the row
95
+ // was stored under decides where it sorts. With one argument `max()` is an
96
+ // aggregate, so it is only used when there is more than one spelling.
97
+ const ranks = punyVariants(term).map(rankOneTerm);
98
+ if (ranks.length === 1) return ranks[0];
99
+ return sql<number>`max(${sql.join(ranks, sql`, `)})`;
100
+ };
101
+
102
+ const rankOneTerm = (term: string): SQL<number> => {
63
103
  const { leading, anywhere } = patterns(term);
64
104
  const tiers = [
65
105
  eq(addressTable.normalizedEmail, term.toLowerCase()),
@@ -97,7 +137,13 @@ export const addressListable = (term: string | undefined): SQL => {
97
137
  or ${accountHasCorresponded()} > 0
98
138
  or ${accountHasFlagged()} > 0`;
99
139
  if (!term) return sql`(${shown})`;
100
- return sql`(${shown} or ${addressTable.normalizedEmail} = ${term.toLowerCase()})`;
140
+ const exact = or(
141
+ ...punyVariants(term).map(
142
+ (variant) =>
143
+ sql`${addressTable.normalizedEmail} = ${variant.toLowerCase()}`,
144
+ ),
145
+ );
146
+ return sql`(${shown} or ${exact})`;
101
147
  };
102
148
 
103
149
  export const addressCorrespondence = (): SQL<number> =>
@@ -1320,4 +1320,105 @@ describe("AddressRepo", () => {
1320
1320
  });
1321
1321
  }
1322
1322
  });
1323
+
1324
+ describe("unicode domains match punycode rows (#905)", () => {
1325
+ test("bücher.de finds a row stored as xn--bcher-kva.de", async () => {
1326
+ const accountConfigId = randomId();
1327
+ const addr = await repo.createAddress({
1328
+ addressId: randomId(),
1329
+ accountConfigId,
1330
+ localPart: "postmaster",
1331
+ domain: "xn--bcher-kva.de",
1332
+ normalizedEmail: "postmaster@xn--bcher-kva.de",
1333
+ normalizedCompound: "postmaster@xn--bcher-kva.de:postmaster",
1334
+ lastInboundAt: Date.now(),
1335
+ });
1336
+
1337
+ try {
1338
+ const { items } = await repo.listByAccountConfig({
1339
+ accountConfigId,
1340
+ search: "bücher.de",
1341
+ });
1342
+ assert.ok(items.some((item) => item.addressId === addr.addressId));
1343
+ } finally {
1344
+ await repo.deleteAddress(accountConfigId, addr.addressId);
1345
+ }
1346
+ });
1347
+
1348
+ test("a partial term still matches while its unicode label is whole", async () => {
1349
+ const accountConfigId = randomId();
1350
+ const addr = await repo.createAddress({
1351
+ addressId: randomId(),
1352
+ accountConfigId,
1353
+ localPart: "postmaster",
1354
+ domain: "xn--bcher-kva.de",
1355
+ normalizedEmail: "postmaster@xn--bcher-kva.de",
1356
+ normalizedCompound: "postmaster@xn--bcher-kva.de:postmaster",
1357
+ lastInboundAt: Date.now(),
1358
+ });
1359
+
1360
+ try {
1361
+ const { items } = await repo.listByAccountConfig({
1362
+ accountConfigId,
1363
+ search: "bücher.d",
1364
+ });
1365
+ assert.ok(items.some((item) => item.addressId === addr.addressId));
1366
+ } finally {
1367
+ await repo.deleteAddress(accountConfigId, addr.addressId);
1368
+ }
1369
+ });
1370
+
1371
+ test("an address met only in Junk answers to the whole address typed", async () => {
1372
+ const accountConfigId = randomId();
1373
+ const withheld = await repo.upsertJunkAddress(
1374
+ makeAddressInput(accountConfigId, "postmaster@xn--bcher-kva.de"),
1375
+ );
1376
+
1377
+ const page = await repo.listByAccountConfig({
1378
+ accountConfigId,
1379
+ search: "postmaster@bücher.de",
1380
+ });
1381
+
1382
+ assert.deepEqual(
1383
+ page.items.map((a) => a.addressId),
1384
+ [withheld.addressId],
1385
+ "the address typed whole reaches the row its punycode spelling hides",
1386
+ );
1387
+
1388
+ await repo.deleteAddress(accountConfigId, withheld.addressId);
1389
+ });
1390
+
1391
+ test("the address a unicode term spells out leads a sibling at its domain", async () => {
1392
+ const accountConfigId = randomId();
1393
+ // The address typed, worn as a display name by the busiest row at the
1394
+ // domain it names.
1395
+ const sibling = await repo.createAddress({
1396
+ ...makeAddressInput(accountConfigId, "sales@xn--bcher-kva.de"),
1397
+ displayName: "postmaster@bücher.de",
1398
+ normalizedCompound: "postmaster@bücher.de sales@xn--bcher-kva.de",
1399
+ inboundCount: 900,
1400
+ });
1401
+ const own = await repo.createAddress({
1402
+ ...makeAddressInput(accountConfigId, "postmaster@xn--bcher-kva.de"),
1403
+ displayName: "Bücher",
1404
+ normalizedCompound: "bücher postmaster@xn--bcher-kva.de",
1405
+ inboundCount: 1,
1406
+ });
1407
+
1408
+ const found = await repo.listByAccountConfig({
1409
+ accountConfigId,
1410
+ search: "postmaster@bücher.de",
1411
+ });
1412
+ assert.deepEqual(
1413
+ found.items.map((a) => a.normalizedEmail),
1414
+ [own.normalizedEmail, sibling.normalizedEmail],
1415
+ "volume at the domain must not take the top slot from the address typed",
1416
+ );
1417
+
1418
+ await repo.deleteManyAddresses(accountConfigId, [
1419
+ sibling.addressId,
1420
+ own.addressId,
1421
+ ]);
1422
+ });
1423
+ });
1323
1424
  });