@remit/mailbox-service 0.0.91 → 0.0.93
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/index.ts +16 -2
- package/src/mailbox-management.test.ts +38 -20
- package/src/mailbox-management.ts +47 -22
- package/src/mailbox-presence.test.ts +72 -0
- package/src/mailbox-presence.ts +49 -27
- package/src/mailbox-queue.ts +34 -15
- package/src/mailbox-rename-intent.test.ts +143 -0
- package/src/mailbox-rename-subtree.test.ts +22 -1
- package/src/mailbox-sync.test.ts +170 -28
- package/src/mailbox-sync.ts +57 -18
- package/src/message-delete-junk-reconcile.test.ts +5 -2
- package/src/message-delete-no-trash.test.ts +5 -1
- package/src/message-delete-unsettled-placement.test.ts +25 -17
- package/src/message-move-copy.test.ts +28 -1
- package/src/message-move-empty-trash.test.ts +48 -1
- package/src/message-move-same-mailbox.test.ts +5 -2
- package/src/message-move-unsettled-placement.test.ts +56 -17
- package/src/message-move.ts +189 -62
- package/src/message-sync-repoint.test.ts +22 -2
- package/src/message-sync.ts +9 -10
- package/src/placement-move-terminal.test.ts +198 -133
- package/src/placement-move-terminal.ts +47 -14
- package/src/placement-move-unsettled.test.ts +18 -4
- package/src/placement-move.ts +65 -9
- package/src/placement-settled.test.ts +36 -6
- package/src/placement-settled.ts +28 -25
- package/src/placement-transition-race.test.ts +482 -0
- package/src/restore-source-placement.ts +157 -0
- package/src/spam-report.test.ts +13 -10
- package/src/spam-report.ts +28 -2
- package/src/thread-message-rows.ts +77 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
|
|
4
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
5
|
+
import { MailboxQueueService } from "./mailbox-queue.js";
|
|
6
|
+
|
|
7
|
+
const row = (mailboxId: string, fullPath: string): MailboxItem =>
|
|
8
|
+
({
|
|
9
|
+
mailboxId,
|
|
10
|
+
accountId: "acc-1",
|
|
11
|
+
fullPath,
|
|
12
|
+
hierarchyDelimiter: "/",
|
|
13
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
14
|
+
}) as MailboxItem;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A repository that resolves the subtree and applies `rowSet` the way the real
|
|
18
|
+
* one does, so the paths the intent records are the paths under test.
|
|
19
|
+
*/
|
|
20
|
+
const store = (rows: MailboxItem[]) => {
|
|
21
|
+
const byId = new Map(rows.map((r) => [r.mailboxId, { ...r }]));
|
|
22
|
+
|
|
23
|
+
const repo: Pick<IMailboxRepository, "get" | "transitionSubtree"> = {
|
|
24
|
+
get: (async (_accountId: string, mailboxId: string) => {
|
|
25
|
+
const found = byId.get(mailboxId as string);
|
|
26
|
+
if (!found) {
|
|
27
|
+
throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
|
|
28
|
+
name: "NotFoundError",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return found;
|
|
32
|
+
}) as IMailboxRepository["get"],
|
|
33
|
+
transitionSubtree: async (_accountId, mailboxId, intent) => {
|
|
34
|
+
const root = byId.get(mailboxId);
|
|
35
|
+
if (!root) return null;
|
|
36
|
+
const prefix = `${root.fullPath}${root.hierarchyDelimiter}`;
|
|
37
|
+
const subtree = [
|
|
38
|
+
root,
|
|
39
|
+
...[...byId.values()].filter(
|
|
40
|
+
(r) => r !== root && r.fullPath.startsWith(prefix),
|
|
41
|
+
),
|
|
42
|
+
];
|
|
43
|
+
const written: MailboxItem[] = [];
|
|
44
|
+
for (const candidate of subtree) {
|
|
45
|
+
if (!intent.from.includes(candidate.syncStatus)) return null;
|
|
46
|
+
const next = {
|
|
47
|
+
...candidate,
|
|
48
|
+
...intent.rowSet(candidate),
|
|
49
|
+
syncStatus: intent.to,
|
|
50
|
+
} as MailboxItem;
|
|
51
|
+
byId.set(candidate.mailboxId, next);
|
|
52
|
+
written.push(next);
|
|
53
|
+
}
|
|
54
|
+
// The port promises no ordering beyond "the named folder first", so
|
|
55
|
+
// hand the caller the least convenient order it is allowed to return.
|
|
56
|
+
return written.reverse();
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
repo: repo as IMailboxRepository,
|
|
62
|
+
pathOf: (id: string) => byId.get(id)?.fullPath,
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const queueOver = (repo: IMailboxRepository) => {
|
|
67
|
+
const sent: string[] = [];
|
|
68
|
+
const service = new MailboxQueueService({
|
|
69
|
+
mailboxService: repo,
|
|
70
|
+
sqsQueueUrl: "http://localhost:4566/000000000000/mailbox",
|
|
71
|
+
});
|
|
72
|
+
(
|
|
73
|
+
service as unknown as {
|
|
74
|
+
sqs: {
|
|
75
|
+
send: (command: { input: { MessageBody: string } }) => Promise<void>;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
).sqs = {
|
|
79
|
+
send: async (command) => {
|
|
80
|
+
sent.push(command.input.MessageBody);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
return { service, sent };
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
describe("MailboxQueueService.renameMailbox — the recorded intent", () => {
|
|
87
|
+
it("records the path the RENAME will carry, `$` patterns and all", async () => {
|
|
88
|
+
// `String.prototype.replace` expands `$&`, "$`", `$'` and `$$` in the
|
|
89
|
+
// replacement, so a folder renamed to a name containing one of them was
|
|
90
|
+
// stored under a different path from the one the event carries to the
|
|
91
|
+
// server. The row and the server then disagree, and the next sweep finds
|
|
92
|
+
// no row for the real path and inserts a second one.
|
|
93
|
+
const { repo, pathOf } = store([
|
|
94
|
+
row("mbx-parent", "Work"),
|
|
95
|
+
row("mbx-child", "Work/2026"),
|
|
96
|
+
]);
|
|
97
|
+
const { service, sent } = queueOver(repo);
|
|
98
|
+
|
|
99
|
+
const renamed = await service.renameMailbox(
|
|
100
|
+
"mbx-parent",
|
|
101
|
+
"Q1$&Q2",
|
|
102
|
+
"acc-1",
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
assert.equal(renamed.fullPath, "Q1$&Q2");
|
|
106
|
+
assert.equal(pathOf("mbx-parent"), "Q1$&Q2");
|
|
107
|
+
assert.equal(pathOf("mbx-child"), "Q1$&Q2/2026");
|
|
108
|
+
assert.equal(
|
|
109
|
+
(JSON.parse(sent[0]) as { newPath: string }).newPath,
|
|
110
|
+
"Q1$&Q2",
|
|
111
|
+
"the event and the row name the same folder",
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("moves only what is under the renamed branch", async () => {
|
|
116
|
+
const { repo, pathOf } = store([
|
|
117
|
+
row("mbx-parent", "Work"),
|
|
118
|
+
row("mbx-lookalike", "Workshop"),
|
|
119
|
+
]);
|
|
120
|
+
const { service } = queueOver(repo);
|
|
121
|
+
|
|
122
|
+
await service.renameMailbox("mbx-parent", "Projects", "acc-1");
|
|
123
|
+
|
|
124
|
+
assert.equal(pathOf("mbx-parent"), "Projects");
|
|
125
|
+
assert.equal(pathOf("mbx-lookalike"), "Workshop");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns the folder that was asked for, not whichever row came back first", async () => {
|
|
129
|
+
const { repo } = store([
|
|
130
|
+
row("mbx-parent", "Work"),
|
|
131
|
+
row("mbx-child", "Work/2026"),
|
|
132
|
+
]);
|
|
133
|
+
const { service } = queueOver(repo);
|
|
134
|
+
|
|
135
|
+
const renamed = await service.renameMailbox(
|
|
136
|
+
"mbx-parent",
|
|
137
|
+
"Projects",
|
|
138
|
+
"acc-1",
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
assert.equal(renamed.mailboxId, "mbx-parent");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -21,7 +21,10 @@ const row = (
|
|
|
21
21
|
const store = (rows: MailboxItem[], vanishAfterSweep: string[] = []) => {
|
|
22
22
|
const byId = new Map(rows.map((r) => [r.mailboxId, { ...r }]));
|
|
23
23
|
|
|
24
|
-
const repo: Pick<
|
|
24
|
+
const repo: Pick<
|
|
25
|
+
IMailboxRepository,
|
|
26
|
+
"update" | "transition" | "findByPathPrefix"
|
|
27
|
+
> = {
|
|
25
28
|
update: async (_accountId, mailboxId, patch) => {
|
|
26
29
|
const existing = byId.get(mailboxId);
|
|
27
30
|
if (!existing) {
|
|
@@ -33,6 +36,24 @@ const store = (rows: MailboxItem[], vanishAfterSweep: string[] = []) => {
|
|
|
33
36
|
byId.set(mailboxId, next);
|
|
34
37
|
return next;
|
|
35
38
|
},
|
|
39
|
+
transition: async (_accountId, mailboxId, intent) => {
|
|
40
|
+
const existing = byId.get(mailboxId);
|
|
41
|
+
if (!existing) return null;
|
|
42
|
+
if (!intent.from.includes(existing.syncStatus)) return null;
|
|
43
|
+
if (
|
|
44
|
+
intent.wherePendingPath !== undefined &&
|
|
45
|
+
(intent.wherePendingPath ?? undefined) !== existing.pendingPath
|
|
46
|
+
) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const next = {
|
|
50
|
+
...existing,
|
|
51
|
+
...intent.set,
|
|
52
|
+
syncStatus: intent.to,
|
|
53
|
+
} as MailboxItem;
|
|
54
|
+
byId.set(mailboxId, next);
|
|
55
|
+
return next;
|
|
56
|
+
},
|
|
36
57
|
findByPathPrefix: async (_accountId, pathPrefix, delimiter = "/") => {
|
|
37
58
|
const prefix = `${pathPrefix}${delimiter}`;
|
|
38
59
|
const found = [...byId.values()].filter((r) =>
|
package/src/mailbox-sync.test.ts
CHANGED
|
@@ -216,19 +216,23 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
216
216
|
shared: [],
|
|
217
217
|
};
|
|
218
218
|
|
|
219
|
-
// The server lists only INBOX
|
|
220
|
-
|
|
219
|
+
// The server lists only INBOX unless a test says otherwise: neither user
|
|
220
|
+
// folder is on it yet.
|
|
221
|
+
const serverConnection = (
|
|
222
|
+
folders: Array<{ fullPath: string; attributes?: string[] }> = [
|
|
223
|
+
{ fullPath: "INBOX" },
|
|
224
|
+
],
|
|
225
|
+
): IImapConnection =>
|
|
221
226
|
({
|
|
222
227
|
getNamespaces: async () => namespaces,
|
|
223
|
-
listMailboxes: async () =>
|
|
224
|
-
{
|
|
225
|
-
fullPath:
|
|
226
|
-
name: "
|
|
228
|
+
listMailboxes: async () =>
|
|
229
|
+
folders.map((folder) => ({
|
|
230
|
+
fullPath: folder.fullPath,
|
|
231
|
+
name: folder.fullPath.split("/").pop() ?? folder.fullPath,
|
|
227
232
|
delimiter: "/",
|
|
228
|
-
attributes: [],
|
|
233
|
+
attributes: folder.attributes ?? [],
|
|
229
234
|
parentPath: null,
|
|
230
|
-
},
|
|
231
|
-
],
|
|
235
|
+
})),
|
|
232
236
|
getMailboxStatus: async () => ({
|
|
233
237
|
messages: 0,
|
|
234
238
|
recent: 0,
|
|
@@ -238,6 +242,12 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
238
242
|
highestModseq: "0",
|
|
239
243
|
deletedCount: 0,
|
|
240
244
|
}),
|
|
245
|
+
openBox: async () => ({
|
|
246
|
+
uidvalidity: 1,
|
|
247
|
+
uidnext: 1,
|
|
248
|
+
messages: { total: 0 },
|
|
249
|
+
}),
|
|
250
|
+
closeBox: async () => undefined,
|
|
241
251
|
}) as unknown as IImapConnection;
|
|
242
252
|
|
|
243
253
|
const buildServices = (
|
|
@@ -245,9 +255,11 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
245
255
|
mailboxId: string;
|
|
246
256
|
fullPath: string;
|
|
247
257
|
syncStatus?: string;
|
|
258
|
+
pendingPath?: string;
|
|
248
259
|
}>,
|
|
249
260
|
) => {
|
|
250
261
|
const deleted: string[] = [];
|
|
262
|
+
const created: Array<Record<string, unknown>> = [];
|
|
251
263
|
const mailboxService = {
|
|
252
264
|
listByAccount: async () => ({
|
|
253
265
|
items: existing.map((m) => ({
|
|
@@ -261,6 +273,7 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
261
273
|
highestModseq: "0",
|
|
262
274
|
specialUse: undefined,
|
|
263
275
|
syncStatus: m.syncStatus,
|
|
276
|
+
pendingPath: m.pendingPath,
|
|
264
277
|
})),
|
|
265
278
|
continuationToken: undefined,
|
|
266
279
|
}),
|
|
@@ -268,7 +281,10 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
268
281
|
delete: async (_accountId: string, mailboxId: string) => {
|
|
269
282
|
deleted.push(mailboxId);
|
|
270
283
|
},
|
|
271
|
-
create: async () =>
|
|
284
|
+
create: async (input: Record<string, unknown>) => {
|
|
285
|
+
created.push(input);
|
|
286
|
+
return { ...input, mailboxId: `new-${created.length}` };
|
|
287
|
+
},
|
|
272
288
|
} as unknown as IMailboxRepository;
|
|
273
289
|
|
|
274
290
|
const specialUseService = {
|
|
@@ -277,23 +293,126 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
277
293
|
createMany: async () => undefined,
|
|
278
294
|
} as unknown as IMailboxSpecialUseRepository;
|
|
279
295
|
|
|
280
|
-
return { mailboxService, specialUseService, deleted };
|
|
296
|
+
return { mailboxService, specialUseService, deleted, created };
|
|
281
297
|
};
|
|
282
298
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
299
|
+
for (const syncStatus of [
|
|
300
|
+
MailboxSyncStatus.pending,
|
|
301
|
+
MailboxSyncStatus.deleting,
|
|
302
|
+
]) {
|
|
303
|
+
it(`keeps a \`${syncStatus}\` folder the server has not listed`, async () => {
|
|
304
|
+
// `pending`: the folder was just created locally and MAILBOX_CREATE has
|
|
305
|
+
// not reached the server, so the LIST omits it — deleting the row races
|
|
306
|
+
// the create and wedges the account's FIFO group for a visibility
|
|
307
|
+
// window (#290).
|
|
308
|
+
//
|
|
309
|
+
// `deleting`: the delete has landed on the server and the worker has not
|
|
310
|
+
// written back. Hard-deleting the row removes it under the client
|
|
311
|
+
// holding its id — the "Mailbox not found" of #333 — and removes it
|
|
312
|
+
// without the folder's mail, which the worker owns (D8).
|
|
313
|
+
const { mailboxService, specialUseService, deleted } = buildServices([
|
|
314
|
+
{
|
|
315
|
+
mailboxId: "inbox",
|
|
316
|
+
fullPath: "INBOX",
|
|
317
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
mailboxId: "in-flight",
|
|
321
|
+
fullPath: "New Folder",
|
|
322
|
+
syncStatus,
|
|
323
|
+
},
|
|
324
|
+
]);
|
|
325
|
+
const service = new MailboxSyncService(
|
|
326
|
+
mailboxService,
|
|
327
|
+
specialUseService,
|
|
328
|
+
silentLogger,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const result = await service.syncMailboxes(
|
|
332
|
+
{ accountId: "acc-1" },
|
|
333
|
+
serverConnection(),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
assert.deepEqual(deleted, []);
|
|
337
|
+
assert.equal(result.deleted, 0);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it(`keeps a \`${syncStatus}\` folder the server lists as \\Noselect`, async () => {
|
|
341
|
+
// A folder mid-create can transiently LIST as `\Noselect`, and the
|
|
342
|
+
// non-selectable branch deleted the row before looking at its state.
|
|
343
|
+
const { mailboxService, specialUseService, deleted } = buildServices([
|
|
344
|
+
{
|
|
345
|
+
mailboxId: "in-flight",
|
|
346
|
+
fullPath: "New Folder",
|
|
347
|
+
syncStatus,
|
|
348
|
+
},
|
|
349
|
+
]);
|
|
350
|
+
const service = new MailboxSyncService(
|
|
351
|
+
mailboxService,
|
|
352
|
+
specialUseService,
|
|
353
|
+
silentLogger,
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
await service.syncMailboxes(
|
|
357
|
+
{ accountId: "acc-1" },
|
|
358
|
+
serverConnection([
|
|
359
|
+
{ fullPath: "INBOX" },
|
|
360
|
+
{ fullPath: "New Folder", attributes: ["\\Noselect"] },
|
|
361
|
+
]),
|
|
362
|
+
);
|
|
363
|
+
|
|
364
|
+
assert.deepEqual(deleted, []);
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
it("does not insert a second row for a path a recorded rename claims", async () => {
|
|
369
|
+
// D14. `fullPath` stays the path the server held when the intent was
|
|
370
|
+
// recorded, so once the server executes RENAME the LIST returns a path no
|
|
371
|
+
// row is keyed to. Without the pendingPath index the insert branch makes a
|
|
372
|
+
// second row under a fresh mailboxId and initial-syncs the folder's mail
|
|
373
|
+
// into it, leaving the original as a permanent phantom.
|
|
374
|
+
const { mailboxService, specialUseService, deleted, created } =
|
|
375
|
+
buildServices([
|
|
376
|
+
{
|
|
377
|
+
mailboxId: "inbox",
|
|
378
|
+
fullPath: "INBOX",
|
|
379
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
mailboxId: "renaming",
|
|
383
|
+
fullPath: "Archive/2025",
|
|
384
|
+
syncStatus: MailboxSyncStatus.pending,
|
|
385
|
+
pendingPath: "Records/2025",
|
|
386
|
+
},
|
|
387
|
+
]);
|
|
388
|
+
const service = new MailboxSyncService(
|
|
389
|
+
mailboxService,
|
|
390
|
+
specialUseService,
|
|
391
|
+
silentLogger,
|
|
392
|
+
);
|
|
393
|
+
|
|
394
|
+
const result = await service.syncMailboxes(
|
|
395
|
+
{ accountId: "acc-1" },
|
|
396
|
+
serverConnection([{ fullPath: "INBOX" }, { fullPath: "Records/2025" }]),
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
assert.deepEqual(created, []);
|
|
400
|
+
assert.equal(result.created, 0);
|
|
401
|
+
assert.deepEqual(deleted, []);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("lets a failed rename's target be discovered as a folder of its own", async () => {
|
|
405
|
+
// A failed rename keeps its target so the UI can name what it was aiming
|
|
406
|
+
// at (T6), and nothing clears it until the user retries or dismisses. If
|
|
407
|
+
// the guard honoured that claim, one stuck folder would block the sweep
|
|
408
|
+
// from ever seeing a real folder another client later created at that
|
|
409
|
+
// path — permanently, and silently.
|
|
410
|
+
const { mailboxService, specialUseService, created } = buildServices([
|
|
293
411
|
{
|
|
294
|
-
mailboxId: "
|
|
295
|
-
fullPath: "
|
|
296
|
-
syncStatus: MailboxSyncStatus.
|
|
412
|
+
mailboxId: "stuck",
|
|
413
|
+
fullPath: "Archive/2025",
|
|
414
|
+
syncStatus: MailboxSyncStatus.failed,
|
|
415
|
+
pendingPath: "Records/2025",
|
|
297
416
|
},
|
|
298
417
|
]);
|
|
299
418
|
const service = new MailboxSyncService(
|
|
@@ -302,13 +421,36 @@ describe("MailboxSyncService.syncMailboxes — reconcile does not delete pending
|
|
|
302
421
|
silentLogger,
|
|
303
422
|
);
|
|
304
423
|
|
|
305
|
-
|
|
424
|
+
await service.syncMailboxes(
|
|
306
425
|
{ accountId: "acc-1" },
|
|
307
|
-
serverConnection(
|
|
426
|
+
serverConnection([
|
|
427
|
+
{ fullPath: "INBOX" },
|
|
428
|
+
{ fullPath: "Archive/2025" },
|
|
429
|
+
{ fullPath: "Records/2025" },
|
|
430
|
+
]),
|
|
308
431
|
);
|
|
309
432
|
|
|
310
|
-
assert.deepEqual(
|
|
311
|
-
|
|
433
|
+
assert.deepEqual(
|
|
434
|
+
created.map((input) => input.fullPath),
|
|
435
|
+
["INBOX", "Records/2025"],
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("writes syncStatus synced on the row it discovers", async () => {
|
|
440
|
+
const { mailboxService, specialUseService, created } = buildServices([]);
|
|
441
|
+
const service = new MailboxSyncService(
|
|
442
|
+
mailboxService,
|
|
443
|
+
specialUseService,
|
|
444
|
+
silentLogger,
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
await service.syncMailboxes(
|
|
448
|
+
{ accountId: "acc-1" },
|
|
449
|
+
serverConnection([{ fullPath: "INBOX" }]),
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
assert.equal(created.length, 1);
|
|
453
|
+
assert.equal(created[0].syncStatus, MailboxSyncStatus.synced);
|
|
312
454
|
});
|
|
313
455
|
|
|
314
456
|
it("still deletes a synced folder that has left the server", async () => {
|
package/src/mailbox-sync.ts
CHANGED
|
@@ -25,7 +25,10 @@ import {
|
|
|
25
25
|
import pMap from "p-map";
|
|
26
26
|
import { isNoSelect, parseImapAttributes } from "./attribute-mapper.js";
|
|
27
27
|
import { isCursorRebuildNeeded } from "./mailbox-cursor.js";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
isFolderMutationInFlight,
|
|
30
|
+
isMailboxMutationInFlight,
|
|
31
|
+
} from "./mailbox-presence.js";
|
|
29
32
|
import type {
|
|
30
33
|
FlatMailboxInfo,
|
|
31
34
|
IImapConnection,
|
|
@@ -149,6 +152,28 @@ export class MailboxSyncService {
|
|
|
149
152
|
const existingByPath = new Map(
|
|
150
153
|
existingMailboxes.map((m) => [m.fullPath, m]),
|
|
151
154
|
);
|
|
155
|
+
// The paths a rename is on its way to (D14). Under D2 `fullPath` stays the
|
|
156
|
+
// path the server held when the intent was recorded, so between the server
|
|
157
|
+
// executing RENAME and the settle, LIST returns a path no row is keyed to
|
|
158
|
+
// — and the insert branch below has no state guard at all. Without this it
|
|
159
|
+
// inserts a second row under a fresh mailboxId and initial-syncs the
|
|
160
|
+
// folder's mail into it, leaving the original as a permanent phantom still
|
|
161
|
+
// holding that folder's messages, filters and role appointments. No unique
|
|
162
|
+
// index on (accountId, fullPath) exists to catch it; #386 adds one.
|
|
163
|
+
//
|
|
164
|
+
// `pending` only. A failed rename keeps its target so the UI can name what
|
|
165
|
+
// it was aiming at (T6), and nothing clears it until the user retries or
|
|
166
|
+
// dismisses — so honouring a `failed` row's claim would let one stuck
|
|
167
|
+
// folder block the sweep from ever discovering a real folder another
|
|
168
|
+
// client later creates at that path.
|
|
169
|
+
const claimedByPendingRename = new Set(
|
|
170
|
+
existingMailboxes.flatMap((m) =>
|
|
171
|
+
m.syncStatus === MailboxSyncStatus.pending &&
|
|
172
|
+
m.pendingPath !== undefined
|
|
173
|
+
? [m.pendingPath]
|
|
174
|
+
: [],
|
|
175
|
+
),
|
|
176
|
+
);
|
|
152
177
|
|
|
153
178
|
// Get namespaces and mailboxes from IMAP
|
|
154
179
|
const namespaces = await connection.getNamespaces();
|
|
@@ -177,7 +202,15 @@ export class MailboxSyncService {
|
|
|
177
202
|
seenPaths.add(mailboxInfo.fullPath);
|
|
178
203
|
// If this mailbox exists in DB, delete it
|
|
179
204
|
const existing = existingByPath.get(mailboxInfo.fullPath);
|
|
180
|
-
|
|
205
|
+
// A folder mid-create can transiently LIST as `\Noselect`, and
|
|
206
|
+
// deleting the row then races the create exactly as #290
|
|
207
|
+
// documented. The state check goes before the delete, through the
|
|
208
|
+
// same predicate the rest of the sweep asks.
|
|
209
|
+
if (existing && !isFolderMutationInFlight(existing)) {
|
|
210
|
+
// The duplicate-special-use branch below clears the special-use
|
|
211
|
+
// entries too; this one skipped them, orphaning a row per
|
|
212
|
+
// deleted mailbox.
|
|
213
|
+
await this.specialUseService.deleteByMailboxId(existing.mailboxId);
|
|
181
214
|
await this.mailboxService.delete(
|
|
182
215
|
account.accountId,
|
|
183
216
|
existing.mailboxId,
|
|
@@ -199,7 +232,7 @@ export class MailboxSyncService {
|
|
|
199
232
|
if (this.isDuplicateSpecialUse(mailboxInfo, claimedSpecialUse)) {
|
|
200
233
|
seenPaths.add(mailboxInfo.fullPath);
|
|
201
234
|
const existing = existingByPath.get(mailboxInfo.fullPath);
|
|
202
|
-
if (existing) {
|
|
235
|
+
if (existing && !isFolderMutationInFlight(existing)) {
|
|
203
236
|
await this.specialUseService.deleteByMailboxId(existing.mailboxId);
|
|
204
237
|
await this.mailboxService.delete(
|
|
205
238
|
account.accountId,
|
|
@@ -226,10 +259,7 @@ export class MailboxSyncService {
|
|
|
226
259
|
// that establishes or removes it writes its own identity back, and
|
|
227
260
|
// reading its status meanwhile is work whose only possible outcome is
|
|
228
261
|
// a failure that fails this whole account's fan-out with it.
|
|
229
|
-
if (
|
|
230
|
-
existing.syncStatus === MailboxSyncStatus.pending ||
|
|
231
|
-
existing.syncStatus === MailboxSyncStatus.deleting
|
|
232
|
-
) {
|
|
262
|
+
if (isFolderMutationInFlight(existing)) {
|
|
233
263
|
return;
|
|
234
264
|
}
|
|
235
265
|
// The folder set can change under this sweep. A delete that lands
|
|
@@ -247,7 +277,7 @@ export class MailboxSyncService {
|
|
|
247
277
|
).catch(async (error: unknown) => {
|
|
248
278
|
// The read only classifies the failure in hand; one that cannot
|
|
249
279
|
// answer must not replace it.
|
|
250
|
-
const gone = await
|
|
280
|
+
const gone = await isMailboxMutationInFlight(
|
|
251
281
|
this.mailboxService,
|
|
252
282
|
account.accountId,
|
|
253
283
|
existing.mailboxId,
|
|
@@ -258,7 +288,7 @@ export class MailboxSyncService {
|
|
|
258
288
|
if (updated) {
|
|
259
289
|
result.updated++;
|
|
260
290
|
}
|
|
261
|
-
} else {
|
|
291
|
+
} else if (!claimedByPendingRename.has(mailboxInfo.fullPath)) {
|
|
262
292
|
await this.createMailbox(
|
|
263
293
|
account.accountId,
|
|
264
294
|
mailboxInfo,
|
|
@@ -274,15 +304,20 @@ export class MailboxSyncService {
|
|
|
274
304
|
// Handle deleted mailboxes (exist in DB but not on server)
|
|
275
305
|
for (const existing of existingMailboxes) {
|
|
276
306
|
if (seenPaths.has(existing.fullPath)) continue;
|
|
277
|
-
// A
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
|
|
307
|
+
// A row with a mutation in flight is a folder whose absence from the
|
|
308
|
+
// LIST is expected, not a server-side deletion. Deleting it races the
|
|
309
|
+
// worker: the row vanishes, then MAILBOX_CREATE fails with NotFoundError
|
|
310
|
+
// trying to mark it synced, and — sharing this account's mailboxes FIFO
|
|
311
|
+
// group — that un-acked failure stalls every later mailbox sync for the
|
|
312
|
+
// queue's whole visibility window (#290).
|
|
313
|
+
//
|
|
314
|
+
// `deleting` is here for a second reason. That window is exactly when a
|
|
315
|
+
// delete has landed on the server and the worker has not written back,
|
|
316
|
+
// so hard-deleting the row removes it under the client holding its id —
|
|
317
|
+
// the "Mailbox not found: <old id>" of #333 — and removes it without the
|
|
318
|
+
// folder's mail, which is the worker's job (D8). Leave both to the flow
|
|
319
|
+
// that owns them.
|
|
320
|
+
if (isFolderMutationInFlight(existing)) continue;
|
|
286
321
|
await this.mailboxService.delete(account.accountId, existing.mailboxId);
|
|
287
322
|
this.log.info(
|
|
288
323
|
{
|
|
@@ -456,6 +491,10 @@ export class MailboxSyncService {
|
|
|
456
491
|
highWaterMarkUid: 0,
|
|
457
492
|
lastMessageSyncAt: 0,
|
|
458
493
|
specialUse: parsed.specialUse.length > 0 ? parsed.specialUse : undefined,
|
|
494
|
+
// A folder the server just told us about is confirmed. The column
|
|
495
|
+
// defaults to this, so the row is right either way; the field is total
|
|
496
|
+
// now (D1) and the insert says what it means.
|
|
497
|
+
syncStatus: MailboxSyncStatus.synced,
|
|
459
498
|
// parentMailboxId would need to be resolved from parentPath
|
|
460
499
|
};
|
|
461
500
|
|
|
@@ -30,8 +30,11 @@ const buildWorld = (trashExists: boolean) => {
|
|
|
30
30
|
get: async () => [message],
|
|
31
31
|
update: async (_id: string, patch: Record<string, unknown>) =>
|
|
32
32
|
Object.assign(message, patch),
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
transitionPlacement: async (
|
|
34
|
+
_id: string,
|
|
35
|
+
_expected: Record<string, unknown>,
|
|
36
|
+
patch: Record<string, unknown>,
|
|
37
|
+
) => Object.assign(message, patch),
|
|
35
38
|
} as unknown as IMessageRepository;
|
|
36
39
|
|
|
37
40
|
const threadMessageService = {
|
|
@@ -52,7 +52,11 @@ const buildWorld = (
|
|
|
52
52
|
patches.push(patch);
|
|
53
53
|
return Object.assign(message, patch);
|
|
54
54
|
},
|
|
55
|
-
|
|
55
|
+
transitionPlacement: async (
|
|
56
|
+
_id: string,
|
|
57
|
+
_expected: Record<string, unknown>,
|
|
58
|
+
patch: Record<string, unknown>,
|
|
59
|
+
) => {
|
|
56
60
|
patches.push(patch);
|
|
57
61
|
return Object.assign(message, patch);
|
|
58
62
|
},
|
|
@@ -68,11 +68,20 @@ const movingRow = () => ({
|
|
|
68
68
|
originalUid: INBOX_UID,
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* A row a delete gave up on. R3 defines the give-up as a hand-back: the row
|
|
73
|
+
* goes back on the placement the mail server confirmed, and only then carries
|
|
74
|
+
* `abandoned`. The pair is honest, so nothing downstream refuses it — the
|
|
75
|
+
* failure is a statement the reading pane makes, never a gate.
|
|
76
|
+
*/
|
|
72
77
|
const strandedRow = () => ({
|
|
73
|
-
...movingRow(),
|
|
74
78
|
messageId: STRANDED_ID,
|
|
75
|
-
|
|
79
|
+
mailboxId: INBOX,
|
|
80
|
+
uid: INBOX_UID,
|
|
81
|
+
status: "active",
|
|
82
|
+
syncStatus: "abandoned",
|
|
83
|
+
abandonedMutation: "delete",
|
|
84
|
+
originalMailboxId: INBOX,
|
|
76
85
|
});
|
|
77
86
|
|
|
78
87
|
/** An ordinary settled row. */
|
|
@@ -127,7 +136,11 @@ const buildWorld = (seed: Array<Record<string, unknown>>) => {
|
|
|
127
136
|
patches.push({ messageId: id, patch });
|
|
128
137
|
return Object.assign(rows.get(id) ?? {}, patch);
|
|
129
138
|
},
|
|
130
|
-
|
|
139
|
+
transitionPlacement: async (
|
|
140
|
+
id: string,
|
|
141
|
+
_expected: Record<string, unknown>,
|
|
142
|
+
patch: Record<string, unknown>,
|
|
143
|
+
) => {
|
|
131
144
|
patches.push({ messageId: id, patch });
|
|
132
145
|
return Object.assign(rows.get(id) ?? {}, patch);
|
|
133
146
|
},
|
|
@@ -329,23 +342,18 @@ describe("the gate refuses only a uid that names somebody else (#845.3)", () =>
|
|
|
329
342
|
assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
|
|
330
343
|
});
|
|
331
344
|
|
|
332
|
-
it("
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
345
|
+
it("acts on a row a delete gave up on, rather than refusing it", async () => {
|
|
346
|
+
// A give-up hands the row back to the placement the server confirmed
|
|
347
|
+
// before it stops (R3), so the "Delete again" the reading pane offers is
|
|
348
|
+
// an ordinary delete. Refusing it — which is what a give-up left `moving`
|
|
349
|
+
// used to earn — made that button a dead end.
|
|
337
350
|
const { service, events } = buildWorld([strandedRow()]);
|
|
338
351
|
|
|
339
352
|
const startedAt = Date.now();
|
|
340
|
-
await
|
|
341
|
-
() => service.deleteMessages(ACCOUNT_CONFIG, [STRANDED_ID], ACCOUNT),
|
|
342
|
-
(error: unknown) =>
|
|
343
|
-
error instanceof MessagePlacementUnsettledError &&
|
|
344
|
-
error.publicApiError?.details?.reason === "unverified",
|
|
345
|
-
);
|
|
353
|
+
await service.deleteMessages(ACCOUNT_CONFIG, [STRANDED_ID], ACCOUNT);
|
|
346
354
|
|
|
347
|
-
assert.
|
|
348
|
-
assert.ok(Date.now() - startedAt < 100, "
|
|
355
|
+
assert.equal(events.length, 1);
|
|
356
|
+
assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
|
|
349
357
|
});
|
|
350
358
|
});
|
|
351
359
|
|