@remit/drizzle-service 0.0.55 → 0.0.57
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,4 +1,4 @@
|
|
|
1
|
-
import { or, type SQL, sql } from "drizzle-orm";
|
|
1
|
+
import { eq, or, type SQL, sql } from "drizzle-orm";
|
|
2
2
|
import { addressTable } from "../schema/i4-address.js";
|
|
3
3
|
|
|
4
4
|
// The address search seam. A term is matched as a substring of the display name,
|
|
@@ -49,10 +49,12 @@ export const addressSearchMatch = (term: string): SQL => {
|
|
|
49
49
|
* spells out ranks above every prefix of it: a display name is free text the
|
|
50
50
|
* sender picks, and a domain somebody else registered is a prefix away from the
|
|
51
51
|
* address the reader typed, so neither may take the suggestion slot from the
|
|
52
|
-
* address that matches whole.
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
52
|
+
* address that matches whole. A term that spells out a domain whole ranks next,
|
|
53
|
+
* so `ischen.nl` reaches the domain it names before `ischen.nl.co`, which only
|
|
54
|
+
* starts with it (#829). Below that, a match at the start of a column outranks
|
|
55
|
+
* one in the middle, and within each the whole address, the local part and the
|
|
56
|
+
* display name outrank the domain they share. A mid-string match still comes
|
|
57
|
+
* back — this only decides the order.
|
|
56
58
|
*/
|
|
57
59
|
export const addressMatchRank = (term: string | undefined): SQL<number> => {
|
|
58
60
|
// Not the bare literal `0`: SQLite reads an integer literal in ORDER BY as a
|
|
@@ -60,7 +62,10 @@ export const addressMatchRank = (term: string | undefined): SQL<number> => {
|
|
|
60
62
|
if (!term) return sql<number>`cast(0 as integer)`;
|
|
61
63
|
const { leading, anywhere } = patterns(term);
|
|
62
64
|
const tiers = [
|
|
63
|
-
|
|
65
|
+
eq(addressTable.normalizedEmail, term.toLowerCase()),
|
|
66
|
+
// `domain` is stored as the envelope spelled it, so `Ischen.NL` only meets
|
|
67
|
+
// a folded term through `lower()`; `like` folds ASCII on its own.
|
|
68
|
+
sql`lower(${addressTable.domain}) = ${term.toLowerCase()}`,
|
|
64
69
|
...SEARCH_COLUMNS.map((column) => like(column, leading)),
|
|
65
70
|
...SEARCH_COLUMNS.map((column) => like(column, anywhere)),
|
|
66
71
|
];
|
|
@@ -474,6 +474,85 @@ describe("AddressRepo", () => {
|
|
|
474
474
|
]);
|
|
475
475
|
});
|
|
476
476
|
|
|
477
|
+
test("listByAccountConfig ranks the domain a bare term names above one that only starts with it", async () => {
|
|
478
|
+
const accountConfigId = randomId();
|
|
479
|
+
const lookalike = await repo.createAddress({
|
|
480
|
+
...makeAddressInput(accountConfigId, "matthijs@ischen.nl.co"),
|
|
481
|
+
displayName: "Ischen Support",
|
|
482
|
+
normalizedCompound: "ischen support matthijs@ischen.nl.co",
|
|
483
|
+
inboundCount: 900,
|
|
484
|
+
});
|
|
485
|
+
// The envelope's own spelling: `domain` is stored unfolded.
|
|
486
|
+
const own = await repo.createAddress({
|
|
487
|
+
...makeAddressInput(accountConfigId, "matthijs@ischen.nl"),
|
|
488
|
+
domain: "Ischen.NL",
|
|
489
|
+
displayName: "Matthijs van Henten",
|
|
490
|
+
normalizedCompound: "matthijs van henten matthijs@ischen.nl",
|
|
491
|
+
inboundCount: 1,
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
const found = await repo.listByAccountConfig({
|
|
495
|
+
accountConfigId,
|
|
496
|
+
search: "ischen.nl",
|
|
497
|
+
});
|
|
498
|
+
assert.deepEqual(
|
|
499
|
+
found.items.map((a) => a.normalizedEmail),
|
|
500
|
+
[own.normalizedEmail, lookalike.normalizedEmail],
|
|
501
|
+
"the domain the term names leads one that registered a suffix of it",
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
const onlySuggestion = await repo.listByAccountConfig({
|
|
505
|
+
accountConfigId,
|
|
506
|
+
search: "ischen.nl",
|
|
507
|
+
limit: 1,
|
|
508
|
+
});
|
|
509
|
+
assert.deepEqual(
|
|
510
|
+
onlySuggestion.items.map((a) => a.normalizedEmail),
|
|
511
|
+
[own.normalizedEmail],
|
|
512
|
+
"volume on the lookalike must not take the single suggestion slot",
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
await repo.deleteManyAddresses(accountConfigId, [
|
|
516
|
+
lookalike.addressId,
|
|
517
|
+
own.addressId,
|
|
518
|
+
]);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
test("listByAccountConfig keeps an address the term spells out whole above a row that only owns the domain", async () => {
|
|
522
|
+
const accountConfigId = randomId();
|
|
523
|
+
// A harvested header that carried the bare domain where an address belongs.
|
|
524
|
+
const bare = await repo.createAddress({
|
|
525
|
+
addressId: randomId(),
|
|
526
|
+
accountConfigId,
|
|
527
|
+
localPart: "ischen.nl",
|
|
528
|
+
domain: "",
|
|
529
|
+
normalizedEmail: "ischen.nl",
|
|
530
|
+
normalizedCompound: "ischen.nl",
|
|
531
|
+
displayName: "Ischen",
|
|
532
|
+
});
|
|
533
|
+
const domainOwner = await repo.createAddress({
|
|
534
|
+
...makeAddressInput(accountConfigId, "matthijs@ischen.nl"),
|
|
535
|
+
displayName: "Matthijs van Henten",
|
|
536
|
+
normalizedCompound: "matthijs van henten matthijs@ischen.nl",
|
|
537
|
+
inboundCount: 900,
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
const found = await repo.listByAccountConfig({
|
|
541
|
+
accountConfigId,
|
|
542
|
+
search: "ischen.nl",
|
|
543
|
+
});
|
|
544
|
+
assert.deepEqual(
|
|
545
|
+
found.items.map((a) => a.normalizedEmail),
|
|
546
|
+
[bare.normalizedEmail, domainOwner.normalizedEmail],
|
|
547
|
+
"the address that matches whole stays above the domain tier below it",
|
|
548
|
+
);
|
|
549
|
+
|
|
550
|
+
await repo.deleteManyAddresses(accountConfigId, [
|
|
551
|
+
bare.addressId,
|
|
552
|
+
domainOwner.addressId,
|
|
553
|
+
]);
|
|
554
|
+
});
|
|
555
|
+
|
|
477
556
|
test("listByAccountConfig keeps a leading display-name match above a domain that only contains the term", async () => {
|
|
478
557
|
const accountConfigId = randomId();
|
|
479
558
|
const shop = await repo.createAddress({
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { after, before, describe, test } from "node:test";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
composeFolderRoleAppointmentName,
|
|
6
|
+
meetsTrashAssurance,
|
|
7
|
+
} from "@remit/data-ports/folder-role";
|
|
5
8
|
import { CanonicalMailboxRole } from "@remit/domain-enums";
|
|
6
9
|
import {
|
|
7
10
|
accountSettingTable,
|
|
@@ -175,7 +178,9 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
175
178
|
makeMailboxInput(accountId, "[Gmail]/Trash"),
|
|
176
179
|
);
|
|
177
180
|
|
|
178
|
-
|
|
181
|
+
const proposal = await repo.resolveTrashRole(accountId);
|
|
182
|
+
assert.equal(proposal.kind, "proposed");
|
|
183
|
+
assert.equal(meetsTrashAssurance(proposal, "confirmed"), false);
|
|
179
184
|
|
|
180
185
|
await appoint(
|
|
181
186
|
accountConfigId,
|
|
@@ -183,9 +188,11 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
183
188
|
CanonicalMailboxRole.Trash,
|
|
184
189
|
trash.mailboxId,
|
|
185
190
|
);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
191
|
+
assert.deepEqual(await repo.resolveTrashRole(accountId), {
|
|
192
|
+
kind: "appointed",
|
|
193
|
+
mailbox: { mailboxId: trash.mailboxId, fullPath: "[Gmail]/Trash" },
|
|
194
|
+
});
|
|
195
|
+
assert.notEqual(trash.mailboxId, keepsakes.mailboxId);
|
|
189
196
|
});
|
|
190
197
|
|
|
191
198
|
test("keeps a stale Trash appointment visible instead of answering the fallback", async () => {
|
|
@@ -214,6 +221,17 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
214
221
|
mailbox: { mailboxId: flagged.mailboxId, fullPath: "[Gmail]/Trash" },
|
|
215
222
|
},
|
|
216
223
|
});
|
|
224
|
+
|
|
225
|
+
// The flagged folder is where a delete files mail, and no evidence at all
|
|
226
|
+
// that the user meant it to be emptied.
|
|
227
|
+
assert.equal(
|
|
228
|
+
meetsTrashAssurance(await repo.resolveTrashRole(accountId), "confirmed"),
|
|
229
|
+
false,
|
|
230
|
+
);
|
|
231
|
+
assert.equal(
|
|
232
|
+
(await repo.findTrashMailbox(accountId))?.mailboxId,
|
|
233
|
+
flagged.mailboxId,
|
|
234
|
+
);
|
|
217
235
|
});
|
|
218
236
|
|
|
219
237
|
test("resolves an INBOX-nested Junk folder that advertises \\Junk", async () => {
|
|
@@ -259,7 +277,10 @@ describe("MailboxSpecialUseRepo role lookups (sqlite)", () => {
|
|
|
259
277
|
// Resolving the name widens where a delete FILES mail, never what an
|
|
260
278
|
// Empty Trash may expunge (#846): that still needs the flag or an
|
|
261
279
|
// appointment.
|
|
262
|
-
assert.equal(
|
|
280
|
+
assert.equal(
|
|
281
|
+
meetsTrashAssurance(await repo.resolveTrashRole(accountId), "confirmed"),
|
|
282
|
+
false,
|
|
283
|
+
);
|
|
263
284
|
});
|
|
264
285
|
|
|
265
286
|
test("resolves an INBOX-nested Archive folder that advertises no special use", async () => {
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
composeFolderRoleAppointmentName,
|
|
9
9
|
type RoleMailboxCandidate,
|
|
10
10
|
type RoleResolution,
|
|
11
|
-
resolveConfirmedMailboxForRole,
|
|
12
11
|
resolveMailboxForRole,
|
|
13
12
|
resolveRoleForAccount,
|
|
14
13
|
type UnappointedRoleResolution,
|
|
@@ -144,16 +143,6 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
|
|
|
144
143
|
return this.findMailboxForRole(accountId, CanonicalMailboxRole.Trash);
|
|
145
144
|
}
|
|
146
145
|
|
|
147
|
-
findConfirmedTrashMailbox(
|
|
148
|
-
accountId: string,
|
|
149
|
-
): Promise<{ mailboxId: string; fullPath: string } | null> {
|
|
150
|
-
return this.findMailboxForRole(
|
|
151
|
-
accountId,
|
|
152
|
-
CanonicalMailboxRole.Trash,
|
|
153
|
-
resolveConfirmedMailboxForRole,
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
146
|
/**
|
|
158
147
|
* Trash with its evidence, for the verbs that weigh it. Reads exactly what
|
|
159
148
|
* `findMailboxForRole` reads — a `null` answer is thrown away there, and this
|
|
@@ -194,13 +183,12 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
|
|
|
194
183
|
private async findMailboxForRole(
|
|
195
184
|
accountId: string,
|
|
196
185
|
role: CanonicalMailboxRoleValue,
|
|
197
|
-
resolve: typeof resolveMailboxForRole = resolveMailboxForRole,
|
|
198
186
|
): Promise<{ mailboxId: string; fullPath: string } | null> {
|
|
199
187
|
const [candidates, appointedMailboxId] = await Promise.all([
|
|
200
188
|
this.roleCandidates(accountId),
|
|
201
189
|
this.appointedMailboxId(accountId, role),
|
|
202
190
|
]);
|
|
203
|
-
const found =
|
|
191
|
+
const found = resolveMailboxForRole(role, candidates, appointedMailboxId);
|
|
204
192
|
return found
|
|
205
193
|
? { mailboxId: found.mailboxId, fullPath: found.fullPath }
|
|
206
194
|
: null;
|