@remit/drizzle-service 0.0.63 → 0.0.65

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.
@@ -1,8 +1,4 @@
1
- import {
2
- JUNK_FOLDER_NAMES,
3
- TRASH_FOLDER_NAMES,
4
- } from "@remit/data-ports/mailbox-role";
5
- import { MailboxSpecialUse } from "@remit/domain-enums";
1
+ import type { JunkRoleMailboxes } from "@remit/data-ports/folder-role";
6
2
 
7
3
  export interface JunkOnlyRepairClient {
8
4
  all(sql: string, params: readonly unknown[]): Promise<unknown[]>;
@@ -19,6 +15,12 @@ export interface JunkOnlyReport {
19
15
  readonly restored: number;
20
16
  }
21
17
 
18
+ /** A statement or fragment with the values its `?` placeholders take, in order. */
19
+ export interface BoundSql {
20
+ readonly sql: string;
21
+ readonly params: readonly unknown[];
22
+ }
23
+
22
24
  export const JUNK_ONLY_FLAG = "junkOnly";
23
25
 
24
26
  const STORED_FLAGS = "coalesce(nullif(address.flags, ''), '{}')";
@@ -26,41 +28,23 @@ const STORED_FLAGS = "coalesce(nullif(address.flags, ''), '{}')";
26
28
  const flagIsSet = (name: string): string =>
27
29
  `coalesce(json_extract(${STORED_FLAGS}, '$.${name}.value'), 0) = 1`;
28
30
 
29
- const quoted = (names: readonly string[]): string =>
30
- names.map((name) => `'${name}'`).join(", ");
31
-
32
- const MAILBOX_LEAF = `lower(substr(
33
- mailbox.full_path,
34
- length(rtrim(
35
- mailbox.full_path,
36
- replace(mailbox.full_path, mailbox.hierarchy_delimiter, '')
37
- )) + 1
38
- ))`;
39
-
40
- const mailboxCarriesRole = (
41
- specialUse: string,
42
- names: readonly string[],
43
- ): string => `(
44
- exists (
45
- SELECT 1 FROM mailbox_special_use_entry entry
46
- WHERE entry.mailbox_id = message.mailbox_id
47
- AND entry.special_use = '${specialUse}'
48
- )
49
- OR exists (
50
- SELECT 1 FROM mailbox
51
- WHERE mailbox.mailbox_id = message.mailbox_id
52
- AND (
53
- mailbox.special_use LIKE '%"${specialUse}"%'
54
- OR ${MAILBOX_LEAF} IN (${quoted(names)})
55
- )
56
- )
57
- )`;
31
+ const EMPTY: BoundSql = { sql: "", params: [] };
58
32
 
59
- const IN_JUNK = mailboxCarriesRole(MailboxSpecialUse.Junk, JUNK_FOLDER_NAMES);
60
- const IN_TRASH = mailboxCarriesRole(
61
- MailboxSpecialUse.Trash,
62
- TRASH_FOLDER_NAMES,
63
- );
33
+ const MATCHES_NOTHING: BoundSql = { sql: "0 = 1", params: [] };
34
+
35
+ /**
36
+ * Whether the message sits in one of these mailboxes. An empty list is
37
+ * false — no mailbox holds the role, so no message is in one. That reading is
38
+ * right for Trash and wrong for Junk, which is why an unresolvable Junk folder
39
+ * is handled before this is ever reached.
40
+ */
41
+ const inMailboxes = (mailboxIds: readonly string[]): BoundSql =>
42
+ mailboxIds.length === 0
43
+ ? { sql: "(0 = 1)", params: [] }
44
+ : {
45
+ sql: `(message.mailbox_id IN (${mailboxIds.map(() => "?").join(", ")}))`,
46
+ params: mailboxIds,
47
+ };
64
48
 
65
49
  const sightingWhere = (extra: string): string => `exists (
66
50
  SELECT 1 FROM envelope_address
@@ -68,47 +52,95 @@ const sightingWhere = (extra: string): string => `exists (
68
52
  WHERE envelope_address.address_id = address.address_id${extra}
69
53
  )`;
70
54
 
71
- const SIGHTING_IN_JUNK = sightingWhere(` AND ${IN_JUNK}`);
72
- const SIGHTING_IN_LIVE_MAIL = sightingWhere(
73
- ` AND NOT ${IN_JUNK} AND NOT ${IN_TRASH}`,
74
- );
75
-
76
- export const ACCOUNT_HAS_CORRESPONDED = `(
55
+ const ACCOUNT_HAS_CORRESPONDED = `(
77
56
  address.outbound_count > 0
78
57
  OR address.reply_count > 0
79
58
  OR ${flagIsSet("vip")}
80
59
  OR ${flagIsSet("trusted")}
81
60
  )`;
82
61
 
83
- export const WITHHOLDABLE = `NOT ${flagIsSet(JUNK_ONLY_FLAG)}
62
+ /**
63
+ * No account in scope has a Junk folder, so nothing is known about any
64
+ * sighting: the mark cannot be earned. Silence is not the same as "every
65
+ * message is live mail" — read that way, one move would restore every address
66
+ * the sweep had withheld.
67
+ */
68
+ const withholdable = (roles: JunkRoleMailboxes): BoundSql => {
69
+ if (roles.junkMailboxIds.length === 0) return MATCHES_NOTHING;
70
+ const inJunk = inMailboxes(roles.junkMailboxIds);
71
+ const inTrash = inMailboxes(roles.trashMailboxIds);
72
+ return {
73
+ sql: `NOT ${flagIsSet(JUNK_ONLY_FLAG)}
84
74
  AND NOT ${ACCOUNT_HAS_CORRESPONDED}
85
- AND ${SIGHTING_IN_JUNK}
86
- AND NOT ${SIGHTING_IN_LIVE_MAIL}`;
75
+ AND ${sightingWhere(` AND ${inJunk.sql}`)}
76
+ AND NOT ${sightingWhere(` AND NOT ${inJunk.sql} AND NOT ${inTrash.sql}`)}`,
77
+ params: [...inJunk.params, ...inJunk.params, ...inTrash.params],
78
+ };
79
+ };
87
80
 
88
- export const RESTORABLE = `${flagIsSet(JUNK_ONLY_FLAG)}
89
- AND (${ACCOUNT_HAS_CORRESPONDED} OR ${SIGHTING_IN_LIVE_MAIL})`;
81
+ /**
82
+ * The same silence lifts no mark either — "stands on live mail" is
83
+ * unanswerable without knowing which folder is Junk. Standing the account
84
+ * gave the address itself still lifts it: that evidence needs no folder.
85
+ */
86
+ const restorable = (roles: JunkRoleMailboxes): BoundSql => {
87
+ if (roles.junkMailboxIds.length === 0) {
88
+ return {
89
+ sql: `${flagIsSet(JUNK_ONLY_FLAG)}
90
+ AND ${ACCOUNT_HAS_CORRESPONDED}`,
91
+ params: [],
92
+ };
93
+ }
94
+ const inJunk = inMailboxes(roles.junkMailboxIds);
95
+ const inTrash = inMailboxes(roles.trashMailboxIds);
96
+ return {
97
+ sql: `${flagIsSet(JUNK_ONLY_FLAG)}
98
+ AND (${ACCOUNT_HAS_CORRESPONDED}
99
+ OR ${sightingWhere(` AND NOT ${inJunk.sql} AND NOT ${inTrash.sql}`)})`,
100
+ params: [...inJunk.params, ...inTrash.params],
101
+ };
102
+ };
90
103
 
91
- export const withholdSql = (scope = ""): string =>
92
- `UPDATE address
104
+ export const withholdSql = (
105
+ roles: JunkRoleMailboxes,
106
+ now: number,
107
+ setBy: string,
108
+ scope: BoundSql = EMPTY,
109
+ ): BoundSql => {
110
+ const predicate = withholdable(roles);
111
+ return {
112
+ sql: `UPDATE address
93
113
  SET flags = json_set(${STORED_FLAGS}, '$.${JUNK_ONLY_FLAG}',
94
114
  json_object('value', json('true'), 'setAt', CAST(? AS INTEGER), 'setBy', ?)),
95
115
  updated_at = ?
96
- WHERE ${WITHHOLDABLE}${scope}`;
116
+ WHERE ${predicate.sql}${scope.sql}`,
117
+ params: [now, setBy, now, ...predicate.params, ...scope.params],
118
+ };
119
+ };
97
120
 
98
- export const restoreSql = (scope = ""): string =>
99
- `UPDATE address
121
+ export const restoreSql = (
122
+ roles: JunkRoleMailboxes,
123
+ now: number,
124
+ scope: BoundSql = EMPTY,
125
+ ): BoundSql => {
126
+ const predicate = restorable(roles);
127
+ return {
128
+ sql: `UPDATE address
100
129
  SET flags = json_remove(${STORED_FLAGS}, '$.${JUNK_ONLY_FLAG}'), updated_at = ?
101
- WHERE ${RESTORABLE}${scope}`;
130
+ WHERE ${predicate.sql}${scope.sql}`,
131
+ params: [now, ...predicate.params, ...scope.params],
132
+ };
133
+ };
102
134
 
103
135
  const REPAIR_SET_BY = "junk-only-repair";
104
136
 
105
137
  const countWhere = async (
106
138
  client: JunkOnlyRepairClient,
107
- predicate: string,
139
+ predicate: BoundSql,
108
140
  ): Promise<number> => {
109
141
  const [row] = (await client.all(
110
- `SELECT count(*) AS row_count FROM address WHERE ${predicate}`,
111
- [],
142
+ `SELECT count(*) AS row_count FROM address WHERE ${predicate.sql}`,
143
+ predicate.params,
112
144
  )) as { row_count: number }[];
113
145
  return row?.row_count ?? 0;
114
146
  };
@@ -116,23 +148,39 @@ const countWhere = async (
116
148
  export const sweepJunkOnlyAddresses = async (
117
149
  client: JunkOnlyRepairClient,
118
150
  mode: JunkOnlyRepairMode,
151
+ roles: JunkRoleMailboxes,
119
152
  now: number = Date.now(),
120
153
  ): Promise<JunkOnlyReport> => {
121
- const withholdable = await countWhere(client, WITHHOLDABLE);
122
- const restorable = await countWhere(client, RESTORABLE);
154
+ const withholdableCount = await countWhere(client, withholdable(roles));
155
+ const restorableCount = await countWhere(client, restorable(roles));
123
156
 
124
157
  if (mode === "check") {
125
- return { mode, withholdable, withheld: 0, restorable, restored: 0 };
158
+ return {
159
+ mode,
160
+ withholdable: withholdableCount,
161
+ withheld: 0,
162
+ restorable: restorableCount,
163
+ restored: 0,
164
+ };
126
165
  }
127
166
 
167
+ const withhold = withholdSql(roles, now, REPAIR_SET_BY);
128
168
  const withheld =
129
- withholdable === 0
169
+ withholdableCount === 0
130
170
  ? 0
131
- : await client.run(withholdSql(), [now, REPAIR_SET_BY, now]);
132
-
133
- const restored = restorable === 0 ? 0 : await client.run(restoreSql(), [now]);
134
-
135
- return { mode, withholdable, withheld, restorable, restored };
171
+ : await client.run(withhold.sql, withhold.params);
172
+
173
+ const restore = restoreSql(roles, now);
174
+ const restored =
175
+ restorableCount === 0 ? 0 : await client.run(restore.sql, restore.params);
176
+
177
+ return {
178
+ mode,
179
+ withholdable: withholdableCount,
180
+ withheld,
181
+ restorable: restorableCount,
182
+ restored,
183
+ };
136
184
  };
137
185
 
138
186
  export const formatJunkOnlyReport = (report: JunkOnlyReport): string[] => {
@@ -5,7 +5,7 @@ import type {
5
5
  IAccountSettingRepository,
6
6
  UpsertAccountSettingInput,
7
7
  } from "@remit/data-ports";
8
- import { eq } from "drizzle-orm";
8
+ import { and, eq, inArray } from "drizzle-orm";
9
9
  import type { Db } from "../db.js";
10
10
  import { accountSettingTable } from "../schema/i4-account-setting.js";
11
11
 
@@ -48,6 +48,28 @@ export class AccountSettingRepo implements IAccountSettingRepository {
48
48
  return row ? rowToAccountSetting(row) : null;
49
49
  }
50
50
 
51
+ /**
52
+ * The named settings of several configs in one read, for callers that would
53
+ * otherwise `get` once per account. Missing rows are simply absent from the
54
+ * result rather than an error.
55
+ */
56
+ async getMany(
57
+ accountConfigIds: readonly string[],
58
+ names: readonly string[],
59
+ ): Promise<AccountSettingItem[]> {
60
+ if (accountConfigIds.length === 0 || names.length === 0) return [];
61
+ const rows = await this.db
62
+ .select()
63
+ .from(accountSettingTable)
64
+ .where(
65
+ and(
66
+ inArray(accountSettingTable.accountConfigId, [...accountConfigIds]),
67
+ inArray(accountSettingTable.name, [...names]),
68
+ ),
69
+ );
70
+ return rows.map(rowToAccountSetting);
71
+ }
72
+
51
73
  async listByAccountConfig(
52
74
  accountConfigId: string,
53
75
  ): Promise<AccountSettingItem[]> {
@@ -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(