@remit/drizzle-service 0.0.60 → 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.60",
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
  });
@@ -135,6 +135,14 @@ describe("MailboxRepo", () => {
135
135
  );
136
136
  });
137
137
 
138
+ test("findByPathPrefix finds nothing in a flat namespace", async () => {
139
+ const accountId = randomId();
140
+ await repo.create(makeMailboxInput(accountId, "Work"));
141
+ await repo.create(makeMailboxInput(accountId, "Workshop"));
142
+
143
+ assert.deepEqual(await repo.findByPathPrefix(accountId, "Work", ""), []);
144
+ });
145
+
138
146
  test("renameChildPaths updates all children", async () => {
139
147
  const accountId = randomId();
140
148
  await repo.create(makeMailboxInput(accountId, "OldName/Sub1"));
@@ -306,6 +306,9 @@ export class MailboxRepo implements IMailboxRepository {
306
306
  pathPrefix: string,
307
307
  delimiter = "/",
308
308
  ): Promise<MailboxItem[]> {
309
+ // A flat namespace nests nothing: with no delimiter the prefix is the
310
+ // folder’s own path, which would match every sibling starting with it.
311
+ if (delimiter.length === 0) return [];
309
312
  const rows = await this.db
310
313
  .select()
311
314
  .from(mailboxTable)