@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.
- package/package.json +1 -1
- package/src/repair/junk-only-address.sqlite.test.ts +221 -72
- package/src/repair/junk-only-address.ts +116 -68
- 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-mailbox-special-use.ts +161 -1
- package/src/repos/i4-outbox-message.test.ts +46 -0
- package/src/repos/i4-outbox-message.ts +39 -4
|
@@ -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;
|
|
@@ -105,6 +105,52 @@ describe("OutboxMessageRepo", () => {
|
|
|
105
105
|
await repo.delete(accountConfigId, msg.outboxMessageId);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
test("updateIfStatus writes on the expected status and refuses any other", async () => {
|
|
109
|
+
const accountConfigId = randomId();
|
|
110
|
+
const msg = await repo.create(makeOutboxInput(randomId(), accountConfigId));
|
|
111
|
+
|
|
112
|
+
const written = await repo.updateIfStatus(
|
|
113
|
+
accountConfigId,
|
|
114
|
+
msg.outboxMessageId,
|
|
115
|
+
"queued",
|
|
116
|
+
{ status: "failed", lastError: "the queue refused it" },
|
|
117
|
+
);
|
|
118
|
+
assert.equal(written?.status, "failed");
|
|
119
|
+
|
|
120
|
+
// The compare-and-set every outbox transition rests on: a caller that read
|
|
121
|
+
// `queued` and decided on it must not overwrite a row the worker has since
|
|
122
|
+
// moved. `null` says another writer got there first.
|
|
123
|
+
const refused = await repo.updateIfStatus(
|
|
124
|
+
accountConfigId,
|
|
125
|
+
msg.outboxMessageId,
|
|
126
|
+
"queued",
|
|
127
|
+
{ status: "draft" },
|
|
128
|
+
);
|
|
129
|
+
assert.equal(refused, null);
|
|
130
|
+
const still = await repo.get(accountConfigId, msg.outboxMessageId);
|
|
131
|
+
assert.equal(still.status, "failed");
|
|
132
|
+
|
|
133
|
+
await repo.delete(accountConfigId, msg.outboxMessageId);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("cross-tenant: updateIfStatus refuses a foreign accountConfig", async () => {
|
|
137
|
+
const accountConfigId = randomId();
|
|
138
|
+
const other = randomId();
|
|
139
|
+
const msg = await repo.create(makeOutboxInput(randomId(), accountConfigId));
|
|
140
|
+
|
|
141
|
+
const refused = await repo.updateIfStatus(
|
|
142
|
+
other,
|
|
143
|
+
msg.outboxMessageId,
|
|
144
|
+
"queued",
|
|
145
|
+
{ status: "failed" },
|
|
146
|
+
);
|
|
147
|
+
assert.equal(refused, null);
|
|
148
|
+
const still = await repo.get(accountConfigId, msg.outboxMessageId);
|
|
149
|
+
assert.equal(still.status, "queued");
|
|
150
|
+
|
|
151
|
+
await repo.delete(accountConfigId, msg.outboxMessageId);
|
|
152
|
+
});
|
|
153
|
+
|
|
108
154
|
test("markSent clears lastError and lastSmtpCode", async () => {
|
|
109
155
|
const accountConfigId = randomId();
|
|
110
156
|
const msg = await repo.create({
|
|
@@ -129,6 +129,40 @@ export class OutboxMessageRepo implements IOutboxMessageRepository {
|
|
|
129
129
|
outboxMessageId: string,
|
|
130
130
|
input: UpdateOutboxMessageInput,
|
|
131
131
|
): Promise<OutboxMessageItem> {
|
|
132
|
+
const [row] = await this.applyUpdate(
|
|
133
|
+
accountConfigId,
|
|
134
|
+
outboxMessageId,
|
|
135
|
+
input,
|
|
136
|
+
);
|
|
137
|
+
if (!row)
|
|
138
|
+
throw new NotFoundError(`OutboxMessage not found: ${outboxMessageId}`);
|
|
139
|
+
return rowToOutboxMessage(row);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async updateIfStatus(
|
|
143
|
+
accountConfigId: string,
|
|
144
|
+
outboxMessageId: string,
|
|
145
|
+
expected: OutboxMessageItem["status"],
|
|
146
|
+
input: UpdateOutboxMessageInput,
|
|
147
|
+
): Promise<OutboxMessageItem | null> {
|
|
148
|
+
const [row] = await this.applyUpdate(
|
|
149
|
+
accountConfigId,
|
|
150
|
+
outboxMessageId,
|
|
151
|
+
input,
|
|
152
|
+
expected,
|
|
153
|
+
);
|
|
154
|
+
// No row means the status moved or the row went away — both say another
|
|
155
|
+
// writer got there first, which is the caller's answer rather than an
|
|
156
|
+
// error here.
|
|
157
|
+
return row ? rowToOutboxMessage(row) : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private async applyUpdate(
|
|
161
|
+
accountConfigId: string,
|
|
162
|
+
outboxMessageId: string,
|
|
163
|
+
input: UpdateOutboxMessageInput,
|
|
164
|
+
expected?: OutboxMessageItem["status"],
|
|
165
|
+
): Promise<(typeof outboxMessageTable.$inferSelect)[]> {
|
|
132
166
|
const now = Date.now();
|
|
133
167
|
const updates: Partial<typeof outboxMessageTable.$inferInsert> = {
|
|
134
168
|
updatedAt: now,
|
|
@@ -154,19 +188,20 @@ export class OutboxMessageRepo implements IOutboxMessageRepository {
|
|
|
154
188
|
if (input.inReplyTo !== undefined) updates.inReplyTo = input.inReplyTo;
|
|
155
189
|
if (input.references !== undefined) updates.references = input.references;
|
|
156
190
|
|
|
157
|
-
const
|
|
191
|
+
const rows = await this.db
|
|
158
192
|
.update(outboxMessageTable)
|
|
159
193
|
.set(updates)
|
|
160
194
|
.where(
|
|
161
195
|
and(
|
|
162
196
|
eq(outboxMessageTable.accountConfigId, accountConfigId),
|
|
163
197
|
eq(outboxMessageTable.outboxMessageId, outboxMessageId),
|
|
198
|
+
expected === undefined
|
|
199
|
+
? undefined
|
|
200
|
+
: eq(outboxMessageTable.status, expected),
|
|
164
201
|
),
|
|
165
202
|
)
|
|
166
203
|
.returning();
|
|
167
|
-
|
|
168
|
-
throw new NotFoundError(`OutboxMessage not found: ${outboxMessageId}`);
|
|
169
|
-
return rowToOutboxMessage(row);
|
|
204
|
+
return rows;
|
|
170
205
|
}
|
|
171
206
|
|
|
172
207
|
async updateStatus(
|