@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.
- package/package.json +2 -1
- package/src/index.ts +2 -0
- package/src/repair/junk-only-address.sqlite.test.ts +221 -72
- package/src/repair/junk-only-address.ts +116 -68
- package/src/repos/config-import-atomicity.sqlite.test.ts +255 -0
- package/src/repos/i4-account-setting.ts +23 -1
- package/src/repos/i4-address-junk-move.sqlite.test.ts +119 -15
- package/src/repos/i4-address.ts +15 -7
- package/src/repos/i4-config-import.ts +94 -0
- package/src/repos/i4-mailbox-special-use.ts +161 -1
- package/src/schema/i4-config-import.ts +3 -0
- package/src/schema.ts +1 -0
|
@@ -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
|
|
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 (?,
|
|
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
|
|
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 [
|
|
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",
|
|
99
|
-
mailbox("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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
300
|
+
await reconcile("msg");
|
|
197
301
|
const first = await repo.getAddress(CONFIG, "spammer");
|
|
198
302
|
|
|
199
|
-
await
|
|
303
|
+
await reconcile("msg");
|
|
200
304
|
|
|
201
305
|
assert.deepEqual(await repo.getAddress(CONFIG, "spammer"), first);
|
|
202
306
|
});
|
package/src/repos/i4-address.ts
CHANGED
|
@@ -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(
|
|
335
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
);
|
|
342
|
-
await this.db.run(boundToDrizzle(
|
|
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
|
-
|
|
374
|
+
appointmentSettingName(accountId, role),
|
|
215
375
|
);
|
|
216
376
|
if (!setting || setting.value.kind !== "String") return undefined;
|
|
217
377
|
return setting.value.value;
|
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";
|