@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,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
|
|
65
|
+
const variants = punyVariants(term);
|
|
38
66
|
const matched = or(
|
|
39
|
-
...
|
|
40
|
-
|
|
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
|
-
|
|
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"));
|
package/src/repos/i4-mailbox.ts
CHANGED
|
@@ -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)
|