@remit/drizzle-service 0.0.47 → 0.0.49

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.47",
3
+ "version": "0.0.49",
4
4
  "description": "Drizzle ORM service over SQLite",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,13 +1,3 @@
1
- /**
2
- * The repair against the shape a deployment actually runs: the committed
3
- * `CREATE TABLE` blocks, not a schema pushed from the drizzle table objects.
4
- *
5
- * What this has to hold, on a live database with no second copy of the data:
6
- * every name the harvest guard would keep survives, a name that says something
7
- * besides the address keeps that something, no row is removed, and a second run
8
- * writes nothing.
9
- */
10
-
11
1
  import assert from "node:assert/strict";
12
2
  import { after, before, describe, test } from "node:test";
13
3
  import { storedDisplayName } from "@remit/data-ports/display-name";
@@ -232,11 +222,6 @@ describe("rewriting display names that claim another address", () => {
232
222
  });
233
223
  });
234
224
 
235
- /**
236
- * The SQL in this repair narrows; it never decides. That only holds if no name
237
- * the rule rewrites can slip past the narrowing — the case where a planted name
238
- * would survive the sweep unseen.
239
- */
240
225
  describe("the SQL narrowing is a superset of the rule", () => {
241
226
  test("selects every name the rule rewrites", () => {
242
227
  const sqlite = new Database(":memory:");
@@ -1,29 +1,5 @@
1
1
  import { storedDisplayName } from "@remit/data-ports/display-name";
2
2
 
3
- /**
4
- * Rewriting the display names a spoofing sender already planted (issue #826).
5
- *
6
- * The harvest guard only decides what is stored from now on. Every name already
7
- * written stays live, and none of the three write paths repairs one on a
8
- * re-sync: `upsertAddress` refuses to overwrite a name with an empty one,
9
- * `upsertEnvelopeAddress` is `onConflictDoNothing`, and a ThreadMessage is
10
- * created once. On the instance that was hit there were 150 email-shaped
11
- * display names in `address` alone, 30 of them naming a different address.
12
- *
13
- * The decision is `storedDisplayName` and nothing else. Expressing it a second
14
- * time in SQL is what makes this dangerous: SQLite's `lower()` folds ASCII
15
- * where JS folds all of Unicode, and its `trim()` and a literal space in a GLOB
16
- * know only U+0020, so a SQL twin rewrites `Özcan@example.com` on
17
- * `özcan@example.com` and `foo\tbar@baz.com` on anything — names the guard
18
- * keeps, destroyed on a database holding the only copy. SQL narrows the scan
19
- * and never decides.
20
- *
21
- * This is a repair rather than a migration because a migration is SQL, and SQL
22
- * is exactly what must not hold the rule. It is convergent: a name the guard
23
- * would keep is never rewritten twice, so re-running it, or resuming it after a
24
- * crash part-way through, writes only what is left to write.
25
- */
26
-
27
3
  export interface DisplayNameRepairClient {
28
4
  all(sql: string, params: readonly unknown[]): Promise<unknown[]>;
29
5
  run(sql: string, params: readonly unknown[]): Promise<number>;
@@ -31,53 +7,39 @@ export interface DisplayNameRepairClient {
31
7
 
32
8
  export type DisplayNameRepairMode = "check" | "repair";
33
9
 
34
- /**
35
- * The scan narrows to names that could carry an address at all: everything
36
- * `storedDisplayName` rewrites contains `x@y.zz`, so a row this misses cannot
37
- * be claiming anything. It is a filter, never the decision — the pairing is
38
- * pinned by a test that runs both halves over the same strings.
39
- */
40
10
  export const EMBEDDED_ADDRESS_LIKE = "%_@_%.__%";
41
11
 
42
12
  interface RepairSite {
43
13
  readonly table: string;
44
- readonly key: string;
45
- readonly name: string;
46
- readonly email: string;
47
- /** The compound the search path reads, where the table keeps one. */
48
- readonly compound?: string;
49
- /** What the write path stores for an absent name on this table. */
50
- readonly absent: "" | null;
14
+ readonly keyColumn: string;
15
+ readonly nameColumn: string;
16
+ readonly emailColumn: string;
17
+ readonly searchCompoundColumn?: string;
18
+ readonly storedWhenNameIsAbsent: "" | null;
51
19
  }
52
20
 
53
- /**
54
- * Every column an attacker-chosen name lands in. `address.display_name` is the
55
- * one autocomplete reads; `envelope_address.display_name` is the From line the
56
- * message header renders; `thread_message.from_name` is the sender label in the
57
- * message list and the text the search index tokenizes.
58
- */
59
21
  const SITES: readonly RepairSite[] = [
60
22
  {
61
23
  table: "address",
62
- key: "address_id",
63
- name: "display_name",
64
- email: "normalized_email",
65
- compound: "normalized_compound",
66
- absent: "",
24
+ keyColumn: "address_id",
25
+ nameColumn: "display_name",
26
+ emailColumn: "normalized_email",
27
+ searchCompoundColumn: "normalized_compound",
28
+ storedWhenNameIsAbsent: "",
67
29
  },
68
30
  {
69
31
  table: "envelope_address",
70
- key: "envelope_address_id",
71
- name: "display_name",
72
- email: "normalized_email",
73
- absent: "",
32
+ keyColumn: "envelope_address_id",
33
+ nameColumn: "display_name",
34
+ emailColumn: "normalized_email",
35
+ storedWhenNameIsAbsent: "",
74
36
  },
75
37
  {
76
38
  table: "thread_message",
77
- key: "thread_message_id",
78
- name: "from_name",
79
- email: "from_email",
80
- absent: null,
39
+ keyColumn: "thread_message_id",
40
+ nameColumn: "from_name",
41
+ emailColumn: "from_email",
42
+ storedWhenNameIsAbsent: null,
81
43
  },
82
44
  ];
83
45
 
@@ -110,44 +72,37 @@ const candidates = async (
110
72
  site: RepairSite,
111
73
  ): Promise<CandidateRow[]> => {
112
74
  const rows = await client.all(
113
- `SELECT ${site.key} AS id, ${site.name} AS name, ${site.email} AS email
75
+ `SELECT ${site.keyColumn} AS id, ${site.nameColumn} AS name, ${site.emailColumn} AS email
114
76
  FROM ${site.table}
115
- WHERE ${site.name} LIKE ?`,
77
+ WHERE ${site.nameColumn} LIKE ?`,
116
78
  [EMBEDDED_ADDRESS_LIKE],
117
79
  );
118
80
  return rows.filter(isCandidateRow);
119
81
  };
120
82
 
121
- /**
122
- * One row at a time, because each row keeps a different remainder. The set is
123
- * what a spoofing sender planted, not the table.
124
- */
125
83
  const rewrite = async (
126
84
  client: DisplayNameRepairClient,
127
85
  site: RepairSite,
128
86
  row: CandidateRow,
129
87
  stored: string,
130
88
  ): Promise<number> => {
131
- const columns = [`${site.name} = ?`];
132
- const params: unknown[] = [stored === "" ? site.absent : stored];
89
+ const columns = [`${site.nameColumn} = ?`];
90
+ const params: unknown[] = [
91
+ stored === "" ? site.storedWhenNameIsAbsent : stored,
92
+ ];
133
93
 
134
- if (site.compound) {
135
- columns.push(`${site.compound} = ?`);
94
+ if (site.searchCompoundColumn) {
95
+ columns.push(`${site.searchCompoundColumn} = ?`);
136
96
  params.push(`${stored.toLowerCase()} ${row.email ?? ""}`.trim());
137
97
  }
138
98
  params.push(row.id);
139
99
 
140
100
  return client.run(
141
- `UPDATE ${site.table} SET ${columns.join(", ")} WHERE ${site.key} = ?`,
101
+ `UPDATE ${site.table} SET ${columns.join(", ")} WHERE ${site.keyColumn} = ?`,
142
102
  params,
143
103
  );
144
104
  };
145
105
 
146
- /**
147
- * `check` writes nothing, so it can be pointed at a live instance; `repair`
148
- * runs the same scan and rewrites what it finds. One code path, so the report
149
- * can never describe a decision the repair does not make.
150
- */
151
106
  export const sweepDisplayNames = async (
152
107
  client: DisplayNameRepairClient,
153
108
  mode: DisplayNameRepairMode,
@@ -0,0 +1,79 @@
1
+ import assert from "node:assert/strict";
2
+ import { randomUUID } from "node:crypto";
3
+ import { after, before, describe, test } from "node:test";
4
+ import { mailboxSpecialUseTable, mailboxTable } from "../schema.js";
5
+ import { createSqliteTestDb } from "../test-db-sqlite.js";
6
+ import { MailboxRepo } from "./i4-mailbox.js";
7
+ import { MailboxSpecialUseRepo } from "./i4-mailbox-special-use.js";
8
+
9
+ const makeMailboxInput = (accountId: string, fullPath: string) => ({
10
+ accountId,
11
+ namespaceType: "personal" as const,
12
+ namespacePrefix: "",
13
+ hierarchyDelimiter: "/",
14
+ fullPath,
15
+ uidValidity: 1,
16
+ uidNext: 1,
17
+ highestModseq: "0",
18
+ messageCount: 0,
19
+ unseenCount: 0,
20
+ deletedCount: 0,
21
+ totalSize: 0,
22
+ lastSyncUid: 0,
23
+ highWaterMarkUid: 0,
24
+ lastMessageSyncAt: Date.now(),
25
+ });
26
+
27
+ describe("MailboxSpecialUseRepo.findJunkMailbox (sqlite)", () => {
28
+ let close: () => Promise<void>;
29
+ let repo: MailboxSpecialUseRepo;
30
+ let mailboxes: MailboxRepo;
31
+
32
+ before(async () => {
33
+ const testDb = await createSqliteTestDb({
34
+ mailbox: mailboxTable,
35
+ mailboxSpecialUse: mailboxSpecialUseTable,
36
+ });
37
+ close = testDb.close;
38
+ repo = new MailboxSpecialUseRepo(testDb.db as never);
39
+ mailboxes = new MailboxRepo(testDb.db as never);
40
+ });
41
+
42
+ after(async () => {
43
+ await close();
44
+ });
45
+
46
+ test("resolves an INBOX-nested Junk folder that advertises \\Junk", async () => {
47
+ const accountId = randomUUID();
48
+ await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
49
+ const spam = await mailboxes.create(
50
+ makeMailboxInput(accountId, "INBOX/Spam"),
51
+ );
52
+ await repo.create(spam.mailboxId, "Junk");
53
+
54
+ const found = await repo.findJunkMailbox(accountId);
55
+ assert.equal(found?.mailboxId, spam.mailboxId);
56
+ });
57
+
58
+ test("resolves an INBOX-nested Junk folder that advertises no special use", async () => {
59
+ // The name fallback used to compare the whole path against a list of
60
+ // bare names, so `INBOX/Spam` resolved to nothing on a server that
61
+ // advertises no \Junk.
62
+ const accountId = randomUUID();
63
+ await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
64
+ const spam = await mailboxes.create(
65
+ makeMailboxInput(accountId, "INBOX/Spam"),
66
+ );
67
+
68
+ const found = await repo.findJunkMailbox(accountId);
69
+ assert.equal(found?.mailboxId, spam.mailboxId);
70
+ });
71
+
72
+ test("answers null when the account has no Junk folder at all", async () => {
73
+ const accountId = randomUUID();
74
+ await mailboxes.create(makeMailboxInput(accountId, "INBOX"));
75
+ await mailboxes.create(makeMailboxInput(accountId, "INBOX/Work"));
76
+
77
+ assert.equal(await repo.findJunkMailbox(accountId), null);
78
+ });
79
+ });
@@ -3,6 +3,7 @@ import type {
3
3
  MailboxSpecialUseItem,
4
4
  MailboxSpecialUseValue,
5
5
  } from "@remit/data-ports";
6
+ import { resolveMailboxByLeafName } from "@remit/data-ports/mailbox-name";
6
7
  import { eq } from "drizzle-orm";
7
8
  import type { Db } from "../db.js";
8
9
  import { randomId } from "../id.js";
@@ -10,6 +11,14 @@ import { mailboxSpecialUseTable, mailboxTable } from "../schema/i4-mailbox.js";
10
11
 
11
12
  type DB = Db<Record<string, unknown>>;
12
13
 
14
+ const JUNK_FOLDER_NAMES = [
15
+ "junk",
16
+ "spam",
17
+ "junk e-mail",
18
+ "junk email",
19
+ "bulk mail",
20
+ ];
21
+
13
22
  function rowToSpecialUse(
14
23
  row: typeof mailboxSpecialUseTable.$inferSelect,
15
24
  ): MailboxSpecialUseItem {
@@ -155,9 +164,7 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
155
164
  .select()
156
165
  .from(mailboxTable)
157
166
  .where(eq(mailboxTable.accountId, accountId));
158
-
159
- const names = ["junk", "spam", "bulk mail", "junk e-mail", "[gmail]/spam"];
160
- const found = rows.find((r) => names.includes(r.fullPath.toLowerCase()));
167
+ const found = resolveMailboxByLeafName(rows, JUNK_FOLDER_NAMES);
161
168
  return found
162
169
  ? { mailboxId: found.mailboxId, fullPath: found.fullPath }
163
170
  : null;