@remit/drizzle-service 0.0.64 → 0.0.66

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.
@@ -3,6 +3,7 @@ import { after, before, beforeEach, describe, test } from "node:test";
3
3
  import type Database from "better-sqlite3";
4
4
  import { createTestDb, type TestDb } from "../test-db.js";
5
5
  import { AddressRepo } from "./i4-address.js";
6
+ import { MailboxSpecialUseRepo } from "./i4-mailbox-special-use.js";
6
7
 
7
8
  const CONFIG = "cfg-1";
8
9
 
@@ -11,8 +12,34 @@ describe("reconciling one message's addresses at the moment it moves", () => {
11
12
  let sqlite: Database.Database;
12
13
  let close: () => Promise<void>;
13
14
  let repo: AddressRepo;
15
+ let specialUse: MailboxSpecialUseRepo;
14
16
 
15
- const mailbox = (mailboxId: string, specialUse: string | null): void => {
17
+ const account = (accountId: string): void => {
18
+ sqlite
19
+ .prepare(
20
+ `INSERT OR IGNORE INTO account (
21
+ account_id, account_config_id, username, email, imap_host,
22
+ imap_port, imap_tls, imap_start_tls, smtp_port, is_active,
23
+ connection_state, created_at, updated_at
24
+ ) VALUES (?, ?, 'user', 'user@example.com', 'imap.example.com',
25
+ 993, 1, 0, 465, 1, 'disconnected', 0, 0)`,
26
+ )
27
+ .run(accountId, CONFIG);
28
+ };
29
+
30
+ /**
31
+ * A folder the server designated, written the way mailbox sync writes one:
32
+ * the denormalized column AND the entry row. The paths are deliberately not
33
+ * English — the role has to be read off the designation, not guessed from
34
+ * the name.
35
+ */
36
+ const mailbox = (
37
+ mailboxId: string,
38
+ designation: string | null,
39
+ folder: { accountId?: string; fullPath?: string } = {},
40
+ ): void => {
41
+ const accountId = folder.accountId ?? "acc";
42
+ account(accountId);
16
43
  sqlite
17
44
  .prepare(
18
45
  `INSERT INTO mailbox (
@@ -21,9 +48,22 @@ describe("reconciling one message's addresses at the moment it moves", () => {
21
48
  unseen_count, deleted_count, total_size, last_sync_uid,
22
49
  high_water_mark_uid, last_message_sync_at, special_use,
23
50
  created_at, updated_at
24
- ) VALUES (?, 'acc', '', '/', ?, 1, 1, '0', 0, 0, 0, 0, 0, 0, 0, ?, 0, 0)`,
51
+ ) VALUES (?, ?, '', '/', ?, 1, 1, '0', 0, 0, 0, 0, 0, 0, 0, ?, 0, 0)`,
52
+ )
53
+ .run(
54
+ mailboxId,
55
+ accountId,
56
+ folder.fullPath ?? mailboxId,
57
+ designation ? JSON.stringify([designation]) : null,
58
+ );
59
+ if (!designation) return;
60
+ sqlite
61
+ .prepare(
62
+ `INSERT INTO mailbox_special_use_entry (
63
+ mailbox_special_use_id, mailbox_id, special_use
64
+ ) VALUES (?, ?, ?)`,
25
65
  )
26
- .run(mailboxId, mailboxId, specialUse);
66
+ .run(`${mailboxId}-${designation}`, mailboxId, designation);
27
67
  };
28
68
 
29
69
  const message = (messageId: string, mailboxId: string): void => {
@@ -84,19 +124,38 @@ describe("reconciling one message's addresses at the moment it moves", () => {
84
124
  before(async () => {
85
125
  ({ db, sqlite, close } = await createTestDb());
86
126
  repo = new AddressRepo(db as never);
127
+ specialUse = new MailboxSpecialUseRepo(db as never);
87
128
  });
88
129
 
130
+ /**
131
+ * The roles the caller resolves once and hands down, covering every account
132
+ * under the config — which is the scope the predicate reads, because the
133
+ * address book is keyed by config.
134
+ */
135
+ const reconcile = async (messageId: string): Promise<void> =>
136
+ repo.reconcileJunkOnlyForMessage(
137
+ messageId,
138
+ await specialUse.resolveJunkRolesForConfig(CONFIG),
139
+ );
140
+
89
141
  after(async () => {
90
142
  await close();
91
143
  });
92
144
 
93
145
  beforeEach(() => {
94
- for (const table of ["address", "envelope_address", "message", "mailbox"]) {
146
+ for (const table of [
147
+ "address",
148
+ "envelope_address",
149
+ "message",
150
+ "mailbox",
151
+ "mailbox_special_use_entry",
152
+ "account",
153
+ ]) {
95
154
  sqlite.exec(`DELETE FROM ${table}`);
96
155
  }
97
- mailbox("inbox", null);
98
- mailbox("junk", '["Junk"]');
99
- mailbox("trash", '["Trash"]');
156
+ mailbox("inbox", null, { fullPath: "INBOX" });
157
+ mailbox("junk", "Junk", { fullPath: "Ongewenst" });
158
+ mailbox("trash", "Trash", { fullPath: "Prullenbak" });
100
159
  });
101
160
 
102
161
  test("a message moved into Junk stops the sender being suggested", async () => {
@@ -106,7 +165,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
106
165
  assert.deepEqual(await suggested("spammer"), ["spammer"]);
107
166
 
108
167
  moveTo("msg", "junk");
109
- await repo.reconcileJunkOnlyForMessage("msg");
168
+ await reconcile("msg");
110
169
 
111
170
  assert.equal(await withheld("spammer"), true);
112
171
  assert.deepEqual(await suggested("spammer"), []);
@@ -119,7 +178,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
119
178
  await repo.incrementOutboundCount(CONFIG, "client", Date.now());
120
179
 
121
180
  moveTo("msg", "junk");
122
- await repo.reconcileJunkOnlyForMessage("msg");
181
+ await reconcile("msg");
123
182
 
124
183
  assert.equal(await withheld("client"), false);
125
184
  });
@@ -132,7 +191,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
132
191
  sighting("colleague", "real");
133
192
 
134
193
  moveTo("spam", "junk");
135
- await repo.reconcileJunkOnlyForMessage("spam");
194
+ await reconcile("spam");
136
195
 
137
196
  assert.equal(await withheld("colleague"), false);
138
197
  });
@@ -152,7 +211,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
152
211
  assert.deepEqual(await suggested("misfiled"), []);
153
212
 
154
213
  moveTo("msg", "inbox");
155
- await repo.reconcileJunkOnlyForMessage("msg");
214
+ await reconcile("msg");
156
215
 
157
216
  assert.deepEqual(await suggested("misfiled"), ["misfiled"]);
158
217
  });
@@ -171,7 +230,7 @@ describe("reconciling one message's addresses at the moment it moves", () => {
171
230
  sighting("spammer", "msg");
172
231
 
173
232
  moveTo("msg", "trash");
174
- await repo.reconcileJunkOnlyForMessage("msg");
233
+ await reconcile("msg");
175
234
 
176
235
  assert.equal(await withheld("spammer"), true);
177
236
  });
@@ -183,20 +242,65 @@ describe("reconciling one message's addresses at the moment it moves", () => {
183
242
  sighting("bystander", "other");
184
243
 
185
244
  moveTo("msg", "junk");
186
- await repo.reconcileJunkOnlyForMessage("msg");
245
+ await reconcile("msg");
187
246
 
188
247
  assert.equal(await withheld("bystander"), false);
189
248
  });
190
249
 
250
+ test("a sender met only in Junk on two accounts of one config stays withheld", async () => {
251
+ mailbox("junk-b", "Junk", {
252
+ accountId: "acc-b",
253
+ fullPath: "Indésirables",
254
+ });
255
+ message("ma", "junk");
256
+ message("mb", "junk-b");
257
+ await harvest("spammer");
258
+ sighting("spammer", "ma");
259
+ sighting("spammer", "mb");
260
+
261
+ await reconcile("ma");
262
+ assert.equal(await withheld("spammer"), true);
263
+
264
+ await reconcile("mb");
265
+
266
+ assert.equal(await withheld("spammer"), true);
267
+ });
268
+
269
+ /**
270
+ * The name hints are English only, so an account whose server flags nothing
271
+ * and whose Junk folder is called `Ongewenst` resolves no Junk at all. That
272
+ * silence must lift no mark: reading it as "every sighting is live mail"
273
+ * would hand the spammer back on the next move.
274
+ */
275
+ test("a move never lifts a mark when no folder holds Junk", async () => {
276
+ sqlite.exec("DELETE FROM mailbox_special_use_entry");
277
+ sqlite.exec("UPDATE mailbox SET special_use = NULL");
278
+ message("msg", "inbox");
279
+ await repo.upsertJunkAddress({
280
+ addressId: "spammer",
281
+ accountConfigId: CONFIG,
282
+ displayName: "Name",
283
+ localPart: "spammer",
284
+ domain: "example.com",
285
+ normalizedEmail: "spammer@example.com",
286
+ normalizedCompound: "name spammer@example.com",
287
+ });
288
+ sighting("spammer", "msg");
289
+
290
+ await reconcile("msg");
291
+
292
+ assert.equal(await withheld("spammer"), true);
293
+ });
294
+
191
295
  test("a second reconcile of the same move writes nothing new", async () => {
192
296
  message("msg", "inbox");
193
297
  await harvest("spammer");
194
298
  sighting("spammer", "msg");
195
299
  moveTo("msg", "junk");
196
- await repo.reconcileJunkOnlyForMessage("msg");
300
+ await reconcile("msg");
197
301
  const first = await repo.getAddress(CONFIG, "spammer");
198
302
 
199
- await repo.reconcileJunkOnlyForMessage("msg");
303
+ await reconcile("msg");
200
304
 
201
305
  assert.deepEqual(await repo.getAddress(CONFIG, "spammer"), first);
202
306
  });
@@ -10,6 +10,7 @@ import type {
10
10
  UpdateAddressInput,
11
11
  } from "@remit/data-ports";
12
12
  import { BadRequestError } from "@remit/data-ports/errors";
13
+ import type { JunkRoleMailboxes } from "@remit/data-ports/folder-role";
13
14
  import { shouldPromoteWellknown } from "@remit/data-ports/wellknown";
14
15
  import {
15
16
  and,
@@ -26,6 +27,7 @@ import { NotFoundError } from "../error.js";
26
27
  import { envelopeAddressId } from "../id.js";
27
28
  import { decodeToken, resultList } from "../pagination.js";
28
29
  import {
30
+ type BoundSql,
29
31
  JUNK_ONLY_FLAG,
30
32
  restoreSql,
31
33
  withholdSql,
@@ -331,15 +333,21 @@ export class AddressRepo implements IAddressRepository {
331
333
  return rowToAddress(row);
332
334
  }
333
335
 
334
- async reconcileJunkOnlyForMessage(messageId: string): Promise<void> {
335
- const scope = ` AND address.address_id IN (
336
+ async reconcileJunkOnlyForMessage(
337
+ messageId: string,
338
+ roles: JunkRoleMailboxes,
339
+ ): Promise<void> {
340
+ const scope: BoundSql = {
341
+ sql: ` AND address.address_id IN (
336
342
  SELECT address_id FROM envelope_address WHERE message_id = ?
337
- )`;
343
+ )`,
344
+ params: [messageId],
345
+ };
338
346
  const now = Date.now();
339
- await this.db.run(
340
- boundToDrizzle(withholdSql(scope), [now, JUNK_MOVE, now, messageId]),
341
- );
342
- await this.db.run(boundToDrizzle(restoreSql(scope), [now, messageId]));
347
+ const withhold = withholdSql(roles, now, JUNK_MOVE, scope);
348
+ await this.db.run(boundToDrizzle(withhold.sql, withhold.params));
349
+ const restore = restoreSql(roles, now, scope);
350
+ await this.db.run(boundToDrizzle(restore.sql, restore.params));
343
351
  }
344
352
 
345
353
  async getAddress(
@@ -0,0 +1,94 @@
1
+ import type {
2
+ ConfigImportItem,
3
+ CreateConfigImportInput,
4
+ IConfigImportRepository,
5
+ UpdateConfigImportInput,
6
+ } from "@remit/data-ports";
7
+ import { desc, eq } from "drizzle-orm";
8
+ import type { Db } from "../db.js";
9
+ import { NotFoundError } from "../error.js";
10
+ import { randomId } from "../id.js";
11
+ import { configImportTable } from "../schema/i4-config-import.js";
12
+
13
+ type DB = Db<Record<string, unknown>>;
14
+
15
+ function rowToConfigImport(
16
+ row: typeof configImportTable.$inferSelect,
17
+ ): ConfigImportItem {
18
+ return {
19
+ importId: row.importId,
20
+ accountConfigId: row.accountConfigId,
21
+ schemaVersion: row.schemaVersion,
22
+ state: row.state as ConfigImportItem["state"],
23
+ document: row.document as ConfigImportItem["document"],
24
+ unresolvedRefs: row.unresolvedRefs as ConfigImportItem["unresolvedRefs"],
25
+ createdAt: row.createdAt,
26
+ completedAt: row.completedAt,
27
+ updatedAt: row.updatedAt,
28
+ };
29
+ }
30
+
31
+ export class ConfigImportRepo implements IConfigImportRepository {
32
+ constructor(private db: DB) {}
33
+
34
+ async create(input: CreateConfigImportInput): Promise<ConfigImportItem> {
35
+ const now = Date.now();
36
+ const [row] = await this.db
37
+ .insert(configImportTable)
38
+ .values({
39
+ importId: randomId(),
40
+ accountConfigId: input.accountConfigId,
41
+ schemaVersion: input.schemaVersion,
42
+ state: input.state ?? "Pending",
43
+ document: input.document as never,
44
+ unresolvedRefs: input.unresolvedRefs as never,
45
+ createdAt: now,
46
+ completedAt: input.completedAt ?? 0,
47
+ updatedAt: now,
48
+ })
49
+ .returning();
50
+ return rowToConfigImport(row);
51
+ }
52
+
53
+ async get(importId: string): Promise<ConfigImportItem> {
54
+ const [row] = await this.db
55
+ .select()
56
+ .from(configImportTable)
57
+ .where(eq(configImportTable.importId, importId));
58
+ if (!row) throw new NotFoundError(`ConfigImport not found: ${importId}`);
59
+ return rowToConfigImport(row);
60
+ }
61
+
62
+ async update(
63
+ importId: string,
64
+ input: UpdateConfigImportInput,
65
+ ): Promise<ConfigImportItem> {
66
+ const updates: Partial<typeof configImportTable.$inferInsert> = {
67
+ updatedAt: Date.now(),
68
+ };
69
+ if (input.state !== undefined) updates.state = input.state;
70
+ if (input.unresolvedRefs !== undefined)
71
+ updates.unresolvedRefs = input.unresolvedRefs as never;
72
+ if (input.completedAt !== undefined)
73
+ updates.completedAt = input.completedAt;
74
+
75
+ const [row] = await this.db
76
+ .update(configImportTable)
77
+ .set(updates)
78
+ .where(eq(configImportTable.importId, importId))
79
+ .returning();
80
+ if (!row) throw new NotFoundError(`ConfigImport not found: ${importId}`);
81
+ return rowToConfigImport(row);
82
+ }
83
+
84
+ async listByAccountConfig(
85
+ accountConfigId: string,
86
+ ): Promise<ConfigImportItem[]> {
87
+ const rows = await this.db
88
+ .select()
89
+ .from(configImportTable)
90
+ .where(eq(configImportTable.accountConfigId, accountConfigId))
91
+ .orderBy(desc(configImportTable.createdAt));
92
+ return rows.map(rowToConfigImport);
93
+ }
94
+ }
@@ -6,6 +6,7 @@ import type {
6
6
  import {
7
7
  type CanonicalMailboxRoleValue,
8
8
  composeFolderRoleAppointmentName,
9
+ type JunkRoleMailboxes,
9
10
  type RoleMailboxCandidate,
10
11
  type RoleResolution,
11
12
  resolveMailboxForRole,
@@ -22,6 +23,23 @@ import { AccountSettingRepo } from "./i4-account-setting.js";
22
23
 
23
24
  type DB = Db<Record<string, unknown>>;
24
25
 
26
+ const JUNK_ROLES: readonly CanonicalMailboxRoleValue[] = [
27
+ CanonicalMailboxRole.Junk,
28
+ CanonicalMailboxRole.Trash,
29
+ ];
30
+
31
+ const appointmentKey = (
32
+ accountId: string,
33
+ role: CanonicalMailboxRoleValue,
34
+ ): string => `${accountId}\u0000${role}`;
35
+
36
+ // The only place this repository names an appointment setting, so that no read
37
+ // here can reach the display-only label row sitting beside it (#887).
38
+ const appointmentSettingName = (
39
+ accountId: string,
40
+ role: CanonicalMailboxRoleValue,
41
+ ): string => composeFolderRoleAppointmentName(accountId, role);
42
+
25
43
  interface RoleCandidate extends RoleMailboxCandidate {
26
44
  fullPath: string;
27
45
  }
@@ -173,6 +191,148 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
173
191
  return this.findMailboxForRole(accountId, CanonicalMailboxRole.Junk);
174
192
  }
175
193
 
194
+ /**
195
+ * Junk and Trash for every account under one config, in a fixed number of
196
+ * reads however many accounts the config holds — this feeds a predicate the
197
+ * per-message reconcile runs inside the sync loop.
198
+ */
199
+ async resolveJunkRolesForConfig(
200
+ accountConfigId: string,
201
+ ): Promise<JunkRoleMailboxes> {
202
+ const accounts = await this.db
203
+ .select({ accountId: accountTable.accountId })
204
+ .from(accountTable)
205
+ .where(eq(accountTable.accountConfigId, accountConfigId));
206
+ return this.resolveJunkRoles(accounts.map((row) => row.accountId));
207
+ }
208
+
209
+ /**
210
+ * The same answer for every account the instance holds. A mailbox id belongs
211
+ * to exactly one account, so a union across accounts is no less selective
212
+ * than asking each of them separately — which is what lets the boot sweep
213
+ * run one pass over the address table instead of one per config.
214
+ */
215
+ async resolveJunkRolesForInstance(): Promise<JunkRoleMailboxes> {
216
+ const accounts = await this.db
217
+ .select({ accountId: accountTable.accountId })
218
+ .from(accountTable);
219
+ return this.resolveJunkRoles(accounts.map((row) => row.accountId));
220
+ }
221
+
222
+ private async resolveJunkRoles(
223
+ accountIds: readonly string[],
224
+ ): Promise<JunkRoleMailboxes> {
225
+ if (accountIds.length === 0) {
226
+ return { junkMailboxIds: [], trashMailboxIds: [] };
227
+ }
228
+ const [candidates, appointments] = await Promise.all([
229
+ this.roleCandidatesFor(accountIds),
230
+ this.appointedMailboxIds(accountIds, JUNK_ROLES),
231
+ ]);
232
+
233
+ const junkMailboxIds: string[] = [];
234
+ const trashMailboxIds: string[] = [];
235
+ for (const accountId of accountIds) {
236
+ const mailboxes = candidates.get(accountId) ?? [];
237
+ const junk = resolveMailboxForRole(
238
+ CanonicalMailboxRole.Junk,
239
+ mailboxes,
240
+ appointments.get(appointmentKey(accountId, CanonicalMailboxRole.Junk)),
241
+ );
242
+ if (junk) junkMailboxIds.push(junk.mailboxId);
243
+ const trash = resolveMailboxForRole(
244
+ CanonicalMailboxRole.Trash,
245
+ mailboxes,
246
+ appointments.get(appointmentKey(accountId, CanonicalMailboxRole.Trash)),
247
+ );
248
+ if (trash) trashMailboxIds.push(trash.mailboxId);
249
+ }
250
+ return { junkMailboxIds, trashMailboxIds };
251
+ }
252
+
253
+ /**
254
+ * Each account's appointment for each role, in two reads. Same precedence
255
+ * input as `appointedMailboxId`, batched: an account row missing is a caller
256
+ * racing a delete, and leaves the account with no appointment rather than
257
+ * failing the lookup.
258
+ */
259
+ private async appointedMailboxIds(
260
+ accountIds: readonly string[],
261
+ roles: readonly CanonicalMailboxRoleValue[],
262
+ ): Promise<Map<string, string>> {
263
+ const accounts = await this.db
264
+ .select({
265
+ accountId: accountTable.accountId,
266
+ accountConfigId: accountTable.accountConfigId,
267
+ })
268
+ .from(accountTable)
269
+ .where(inArray(accountTable.accountId, [...accountIds]));
270
+ if (accounts.length === 0) return new Map();
271
+
272
+ const wanted = new Map<string, string>();
273
+ for (const account of accounts) {
274
+ for (const role of roles) {
275
+ wanted.set(
276
+ appointmentSettingName(account.accountId, role),
277
+ appointmentKey(account.accountId, role),
278
+ );
279
+ }
280
+ }
281
+
282
+ const settings = await this.accountSetting.getMany(
283
+ accounts.map((account) => account.accountConfigId),
284
+ [...wanted.keys()],
285
+ );
286
+
287
+ const appointed = new Map<string, string>();
288
+ for (const setting of settings) {
289
+ const key = wanted.get(setting.name);
290
+ if (!key) continue;
291
+ if (setting.value.kind !== "String") continue;
292
+ appointed.set(key, setting.value.value);
293
+ }
294
+ return appointed;
295
+ }
296
+
297
+ private async roleCandidatesFor(
298
+ accountIds: readonly string[],
299
+ ): Promise<Map<string, RoleCandidate[]>> {
300
+ const rows = await this.db
301
+ .select()
302
+ .from(mailboxTable)
303
+ .where(inArray(mailboxTable.accountId, [...accountIds]));
304
+ if (rows.length === 0) return new Map();
305
+
306
+ const entries = await this.db
307
+ .select()
308
+ .from(mailboxSpecialUseTable)
309
+ .where(
310
+ inArray(
311
+ mailboxSpecialUseTable.mailboxId,
312
+ rows.map((row) => row.mailboxId),
313
+ ),
314
+ );
315
+ const byMailbox = new Map<string, string[]>();
316
+ for (const entry of entries) {
317
+ const designations = byMailbox.get(entry.mailboxId) ?? [];
318
+ designations.push(entry.specialUse);
319
+ byMailbox.set(entry.mailboxId, designations);
320
+ }
321
+
322
+ const byAccount = new Map<string, RoleCandidate[]>();
323
+ for (const row of rows) {
324
+ const candidates = byAccount.get(row.accountId) ?? [];
325
+ candidates.push({
326
+ mailboxId: row.mailboxId,
327
+ fullPath: row.fullPath,
328
+ hierarchyDelimiter: row.hierarchyDelimiter,
329
+ specialUse: byMailbox.get(row.mailboxId) ?? [],
330
+ });
331
+ byAccount.set(row.accountId, candidates);
332
+ }
333
+ return byAccount;
334
+ }
335
+
176
336
  /**
177
337
  * The one read behind every `find<Role>Mailbox`: the account's mailboxes and
178
338
  * its appointment for this role, handed to the shared precedence rule. Read
@@ -211,7 +371,7 @@ export class MailboxSpecialUseRepo implements IMailboxSpecialUseRepository {
211
371
 
212
372
  const setting = await this.accountSetting.get(
213
373
  account.accountConfigId,
214
- composeFolderRoleAppointmentName(accountId, role),
374
+ appointmentSettingName(accountId, role),
215
375
  );
216
376
  if (!setting || setting.value.kind !== "String") return undefined;
217
377
  return setting.value.value;
@@ -0,0 +1,3 @@
1
+ import * as entities from "@remit/drizzle-sqlite-schema";
2
+
3
+ export const configImportTable = entities.configImports;
package/src/schema.ts CHANGED
@@ -17,6 +17,7 @@ export * from "./schema/i4-account-config.js";
17
17
  export * from "./schema/i4-account-export-request.js";
18
18
  export * from "./schema/i4-account-setting.js";
19
19
  export * from "./schema/i4-address.js";
20
+ export * from "./schema/i4-config-import.js";
20
21
  export * from "./schema/i4-mailbox.js";
21
22
  export * from "./schema/i4-mailbox-lock.js";
22
23
  export * from "./schema/i4-message-flag-push.js";