@remit/mailbox-service 0.0.92 → 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 +3 -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/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -179,8 +179,9 @@ export {
|
|
|
179
179
|
validateMailboxPath,
|
|
180
180
|
} from "./mailbox-management.js";
|
|
181
181
|
export {
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
EVERY_MAILBOX_STATE,
|
|
183
|
+
isFolderMutationInFlight,
|
|
184
|
+
isMailboxMutationInFlight,
|
|
184
185
|
} from "./mailbox-presence.js";
|
|
185
186
|
export {
|
|
186
187
|
type CreateMailboxQueueInput,
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
IMailboxRepository,
|
|
5
|
+
MailboxTransitionIntent,
|
|
6
|
+
} from "@remit/data-ports";
|
|
4
7
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
5
8
|
import {
|
|
6
9
|
MailboxManagementService,
|
|
@@ -99,7 +102,14 @@ const boxStatus = (path: string): ImapBoxStatus => ({
|
|
|
99
102
|
const recordingRepo = () => {
|
|
100
103
|
const updates: Array<{ mailboxId: string; patch: Record<string, unknown> }> =
|
|
101
104
|
[];
|
|
102
|
-
const
|
|
105
|
+
const settles: Array<{
|
|
106
|
+
mailboxId: string;
|
|
107
|
+
intent: MailboxTransitionIntent;
|
|
108
|
+
}> = [];
|
|
109
|
+
const repo: Pick<
|
|
110
|
+
IMailboxRepository,
|
|
111
|
+
"update" | "transition" | "findByPathPrefix"
|
|
112
|
+
> = {
|
|
103
113
|
update: async (
|
|
104
114
|
_accountId: string,
|
|
105
115
|
mailboxId: string,
|
|
@@ -108,9 +118,17 @@ const recordingRepo = () => {
|
|
|
108
118
|
updates.push({ mailboxId, patch });
|
|
109
119
|
return {} as never;
|
|
110
120
|
},
|
|
121
|
+
transition: async (
|
|
122
|
+
_accountId: string,
|
|
123
|
+
mailboxId: string,
|
|
124
|
+
intent: MailboxTransitionIntent,
|
|
125
|
+
) => {
|
|
126
|
+
settles.push({ mailboxId, intent });
|
|
127
|
+
return {} as never;
|
|
128
|
+
},
|
|
111
129
|
findByPathPrefix: async () => [],
|
|
112
130
|
};
|
|
113
|
-
return { repo: repo as IMailboxRepository, updates };
|
|
131
|
+
return { repo: repo as IMailboxRepository, updates, settles };
|
|
114
132
|
};
|
|
115
133
|
|
|
116
134
|
const stubConnection = (
|
|
@@ -143,7 +161,7 @@ const stubConnection = (
|
|
|
143
161
|
|
|
144
162
|
describe("MailboxManagementService.syncCreate — server path normalization", () => {
|
|
145
163
|
it("adopts the server-materialized path as the row's identity when it is prefixed", async () => {
|
|
146
|
-
const { repo,
|
|
164
|
+
const { repo, settles } = recordingRepo();
|
|
147
165
|
const { connection, opened } = stubConnection("INBOX/Notifications", [
|
|
148
166
|
"INBOX/Notifications",
|
|
149
167
|
]);
|
|
@@ -159,28 +177,28 @@ describe("MailboxManagementService.syncCreate — server path normalization", ()
|
|
|
159
177
|
assert.strictEqual(result.success, true);
|
|
160
178
|
// The mailbox is opened at the server's path, not the requested leaf.
|
|
161
179
|
assert.deepStrictEqual(opened, ["INBOX/Notifications"]);
|
|
162
|
-
assert.strictEqual(
|
|
180
|
+
assert.strictEqual(settles.length, 1);
|
|
163
181
|
// The row keeps its id and takes on the prefixed path, so the next reconcile
|
|
164
182
|
// updates it in place instead of insert+delete — every reference survives.
|
|
165
|
-
assert.strictEqual(
|
|
166
|
-
assert.strictEqual(
|
|
167
|
-
assert.strictEqual(
|
|
183
|
+
assert.strictEqual(settles[0].mailboxId, "mbx-1");
|
|
184
|
+
assert.strictEqual(settles[0].intent.set?.fullPath, "INBOX/Notifications");
|
|
185
|
+
assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
|
|
168
186
|
});
|
|
169
187
|
|
|
170
188
|
it("leaves the path untouched when the server keeps it as requested", async () => {
|
|
171
|
-
const { repo,
|
|
189
|
+
const { repo, settles } = recordingRepo();
|
|
172
190
|
const { connection } = stubConnection("Work", ["Work"]);
|
|
173
191
|
const service = new MailboxManagementService(repo);
|
|
174
192
|
|
|
175
193
|
await service.syncCreate("acc-1", "mbx-2", "Work", async () => connection);
|
|
176
194
|
|
|
177
|
-
assert.strictEqual(
|
|
178
|
-
assert.
|
|
179
|
-
assert.strictEqual(
|
|
195
|
+
assert.strictEqual(settles.length, 1);
|
|
196
|
+
assert.deepStrictEqual(settles[0].intent.set, {});
|
|
197
|
+
assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
|
|
180
198
|
});
|
|
181
199
|
|
|
182
200
|
it("still adopts the server path when the fresh list cannot resolve it", async () => {
|
|
183
|
-
const { repo,
|
|
201
|
+
const { repo, settles } = recordingRepo();
|
|
184
202
|
const { connection, opened } = stubConnection("INBOX/Notifications", []);
|
|
185
203
|
const service = new MailboxManagementService(repo);
|
|
186
204
|
|
|
@@ -192,13 +210,13 @@ describe("MailboxManagementService.syncCreate — server path normalization", ()
|
|
|
192
210
|
);
|
|
193
211
|
|
|
194
212
|
assert.deepStrictEqual(opened, []);
|
|
195
|
-
assert.strictEqual(
|
|
196
|
-
assert.strictEqual(
|
|
197
|
-
assert.strictEqual(
|
|
213
|
+
assert.strictEqual(settles.length, 1);
|
|
214
|
+
assert.strictEqual(settles[0].intent.set?.fullPath, "INBOX/Notifications");
|
|
215
|
+
assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
|
|
198
216
|
});
|
|
199
217
|
|
|
200
218
|
it("falls back to the requested path when the create result carries none", async () => {
|
|
201
|
-
const { repo,
|
|
219
|
+
const { repo, settles } = recordingRepo();
|
|
202
220
|
const opened: string[] = [];
|
|
203
221
|
const subscribed: string[] = [];
|
|
204
222
|
const connection = {
|
|
@@ -234,9 +252,9 @@ describe("MailboxManagementService.syncCreate — server path normalization", ()
|
|
|
234
252
|
// A thin result never blanks fullPath — the requested path is used throughout.
|
|
235
253
|
assert.deepStrictEqual(subscribed, ["Archive"]);
|
|
236
254
|
assert.deepStrictEqual(opened, ["Archive"]);
|
|
237
|
-
assert.strictEqual(
|
|
238
|
-
assert.
|
|
239
|
-
assert.strictEqual(
|
|
255
|
+
assert.strictEqual(settles.length, 1);
|
|
256
|
+
assert.deepStrictEqual(settles[0].intent.set, {});
|
|
257
|
+
assert.strictEqual(settles[0].intent.to, MailboxSyncStatus.synced);
|
|
240
258
|
});
|
|
241
259
|
});
|
|
242
260
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { IMailboxRepository } from "@remit/data-ports";
|
|
2
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
2
3
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
3
|
-
import { isNotFoundError } from "./mailbox-presence.js";
|
|
4
|
+
import { EVERY_MAILBOX_STATE, isNotFoundError } from "./mailbox-presence.js";
|
|
4
5
|
import type { IImapConnection } from "./types.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -115,6 +116,30 @@ export class MailboxManagementService {
|
|
|
115
116
|
this.log = logger ?? noopLogger;
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Write the settled state back, as the conditional write that is now the
|
|
121
|
+
* only way to write it at all (D3). The from-set is every state because that
|
|
122
|
+
* is what this settle decides against today — it is the unconditional write
|
|
123
|
+
* it replaces, moved onto the door; #363 narrows it to the state its intent
|
|
124
|
+
* recorded and gives a lost predicate its `already-settled` outcome.
|
|
125
|
+
*
|
|
126
|
+
* A null means the row is gone, which is what the unconditional write raised
|
|
127
|
+
* a NotFoundError for, and what the handlers' #289-class terminal guards
|
|
128
|
+
* classify as the user having deleted the folder mid-sync.
|
|
129
|
+
*/
|
|
130
|
+
private settle = async (
|
|
131
|
+
accountId: string,
|
|
132
|
+
mailboxId: string,
|
|
133
|
+
confirmedPath?: string,
|
|
134
|
+
): Promise<void> => {
|
|
135
|
+
const settled = await this.mailboxService.transition(accountId, mailboxId, {
|
|
136
|
+
from: EVERY_MAILBOX_STATE,
|
|
137
|
+
to: MailboxSyncStatus.synced,
|
|
138
|
+
set: confirmedPath !== undefined ? { fullPath: confirmedPath } : {},
|
|
139
|
+
});
|
|
140
|
+
if (!settled) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
141
|
+
};
|
|
142
|
+
|
|
118
143
|
/**
|
|
119
144
|
* Sync a CREATE operation to IMAP.
|
|
120
145
|
* Called by worker after dequeuing MAILBOX_CREATE event.
|
|
@@ -149,7 +174,7 @@ export class MailboxManagementService {
|
|
|
149
174
|
typeof result.path === "string" && result.path.length > 0
|
|
150
175
|
? result.path
|
|
151
176
|
: path;
|
|
152
|
-
const
|
|
177
|
+
const confirmedPath = serverPath !== path ? serverPath : undefined;
|
|
153
178
|
|
|
154
179
|
this.log.info(
|
|
155
180
|
{ mailboxId, path, serverPath, created: result.created },
|
|
@@ -169,21 +194,24 @@ export class MailboxManagementService {
|
|
|
169
194
|
// Open the mailbox to get UIDVALIDITY and other status info
|
|
170
195
|
const status = await connection.openBox(serverPath, true);
|
|
171
196
|
|
|
197
|
+
// Two writes where there was one, and the settle is deliberately the
|
|
198
|
+
// second: counters are ordinary metadata and stay off the transition,
|
|
199
|
+
// whose whole point is that its write-set is the state (D3). A crash in
|
|
200
|
+
// between leaves the row `pending` with fresh counters, and the row not
|
|
201
|
+
// yet settled is what keeps the queue message unacked — the redelivery
|
|
202
|
+
// re-issues CREATE, the server answers ALREADYEXISTS, and #339's
|
|
203
|
+
// classification settles it. Settling first would be the unsafe order.
|
|
172
204
|
await this.mailboxService.update(accountId, mailboxId, {
|
|
173
|
-
...pathUpdate,
|
|
174
205
|
uidValidity: status.uidvalidity,
|
|
175
206
|
uidNext: status.uidnext,
|
|
176
207
|
messageCount: status.messages.total,
|
|
177
|
-
syncStatus: MailboxSyncStatus.synced,
|
|
178
208
|
});
|
|
209
|
+
await this.settle(accountId, mailboxId, confirmedPath);
|
|
179
210
|
|
|
180
211
|
await connection.closeBox();
|
|
181
212
|
} else {
|
|
182
213
|
// Mark as synced even if we couldn't get full info
|
|
183
|
-
await this.
|
|
184
|
-
...pathUpdate,
|
|
185
|
-
syncStatus: MailboxSyncStatus.synced,
|
|
186
|
-
});
|
|
214
|
+
await this.settle(accountId, mailboxId, confirmedPath);
|
|
187
215
|
}
|
|
188
216
|
|
|
189
217
|
return { success: true };
|
|
@@ -215,11 +243,7 @@ export class MailboxManagementService {
|
|
|
215
243
|
"Renamed mailbox on IMAP server",
|
|
216
244
|
);
|
|
217
245
|
|
|
218
|
-
|
|
219
|
-
await this.mailboxService.update(accountId, mailboxId, {
|
|
220
|
-
oldPath: undefined,
|
|
221
|
-
syncStatus: MailboxSyncStatus.synced,
|
|
222
|
-
});
|
|
246
|
+
await this.settle(accountId, mailboxId);
|
|
223
247
|
|
|
224
248
|
// The server rename is done and the row records it. The settle is repair
|
|
225
249
|
// work on top of that, so nothing it hits — the listing included — may reach
|
|
@@ -268,16 +292,17 @@ export class MailboxManagementService {
|
|
|
268
292
|
);
|
|
269
293
|
|
|
270
294
|
for (const descendant of descendants) {
|
|
271
|
-
if (descendant.syncStatus !== MailboxSyncStatus.pending) continue;
|
|
272
295
|
if (!onServer.has(descendant.fullPath)) continue;
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
296
|
+
// The per-row settle of D15: `pending` is the state that carries the
|
|
297
|
+
// intent, and the predicate is on the UPDATE, so a row another client
|
|
298
|
+
// moved in between is skipped rather than overwritten. A row that is
|
|
299
|
+
// gone is skipped for free — a null is not an error here, which is what
|
|
300
|
+
// keeps a descendant deleted mid-settle out of the handler's
|
|
301
|
+
// whole-chain NotFoundError guard.
|
|
302
|
+
await this.mailboxService.transition(accountId, descendant.mailboxId, {
|
|
303
|
+
from: [MailboxSyncStatus.pending],
|
|
304
|
+
to: MailboxSyncStatus.synced,
|
|
305
|
+
});
|
|
281
306
|
}
|
|
282
307
|
};
|
|
283
308
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { IMailboxRepository } from "@remit/data-ports";
|
|
4
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
5
|
+
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
6
|
+
import {
|
|
7
|
+
isFolderMutationInFlight,
|
|
8
|
+
isMailboxMutationInFlight,
|
|
9
|
+
} from "./mailbox-presence.js";
|
|
10
|
+
|
|
11
|
+
const reader = (
|
|
12
|
+
get: IMailboxRepository["get"],
|
|
13
|
+
): Pick<IMailboxRepository, "get"> => ({ get });
|
|
14
|
+
|
|
15
|
+
describe("isFolderMutationInFlight", () => {
|
|
16
|
+
for (const [syncStatus, inFlight] of [
|
|
17
|
+
[MailboxSyncStatus.synced, false],
|
|
18
|
+
[MailboxSyncStatus.pending, true],
|
|
19
|
+
[MailboxSyncStatus.deleting, true],
|
|
20
|
+
[MailboxSyncStatus.failed, false],
|
|
21
|
+
] as const) {
|
|
22
|
+
it(`${syncStatus} is ${inFlight ? "" : "not "}a mutation in flight`, () => {
|
|
23
|
+
assert.equal(isFolderMutationInFlight({ syncStatus }), inFlight);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
it("does not read failed as a folder to leave alone", () => {
|
|
28
|
+
// `failed` means the folder exists at `fullPath` and the last rename or
|
|
29
|
+
// delete intent did not land — so it holds the user's mail and syncing it
|
|
30
|
+
// is right. D7 upholds that by leaving no row at all for a create the
|
|
31
|
+
// server refused. Reading it as in-flight would stop syncing a live folder
|
|
32
|
+
// permanently and silently.
|
|
33
|
+
assert.equal(
|
|
34
|
+
isFolderMutationInFlight({ syncStatus: MailboxSyncStatus.failed }),
|
|
35
|
+
false,
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("isMailboxMutationInFlight", () => {
|
|
41
|
+
it("reads the row it is given", async () => {
|
|
42
|
+
const service = reader((async () => ({
|
|
43
|
+
syncStatus: MailboxSyncStatus.deleting,
|
|
44
|
+
})) as unknown as IMailboxRepository["get"]);
|
|
45
|
+
|
|
46
|
+
assert.equal(await isMailboxMutationInFlight(service, "acc", "mbx"), true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("treats an absent row as in flight", async () => {
|
|
50
|
+
// The row's absence does not decide it at either end: on the way out the
|
|
51
|
+
// row is removed only after the IMAP folder is, and on the way in the row
|
|
52
|
+
// exists before the folder does.
|
|
53
|
+
const service = reader((async () => {
|
|
54
|
+
throw new NotFoundError("Mailbox not found: mbx");
|
|
55
|
+
}) as unknown as IMailboxRepository["get"]);
|
|
56
|
+
|
|
57
|
+
assert.equal(await isMailboxMutationInFlight(service, "acc", "mbx"), true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("rethrows a read that cannot answer", async () => {
|
|
61
|
+
// The caller uses this to classify a failure in hand; a read that fails
|
|
62
|
+
// for its own reasons must not be reported as "the folder is busy".
|
|
63
|
+
const service = reader((async () => {
|
|
64
|
+
throw new Error("connection reset by peer");
|
|
65
|
+
}) as unknown as IMailboxRepository["get"]);
|
|
66
|
+
|
|
67
|
+
await assert.rejects(
|
|
68
|
+
isMailboxMutationInFlight(service, "acc", "mbx"),
|
|
69
|
+
/connection reset by peer/,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|
package/src/mailbox-presence.ts
CHANGED
|
@@ -5,43 +5,65 @@ export const isNotFoundError = (error: unknown): boolean =>
|
|
|
5
5
|
error instanceof Error && error.name === "NotFoundError";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* for and not finished. Folders are written locally first and reconciled to the
|
|
11
|
-
* server by the worker, so both ends of that lifecycle are states in which
|
|
12
|
-
* syncing the folder can only fail.
|
|
8
|
+
* Every state a mailbox row can carry, for a transition whose caller decided
|
|
9
|
+
* against none of them.
|
|
13
10
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* the
|
|
20
|
-
*
|
|
21
|
-
|
|
11
|
+
* A transition predicate is supposed to name the state the caller read
|
|
12
|
+
* (docs/architecture/folder-rename-and-delete.md D3), and the intent recorders
|
|
13
|
+
* and settles that use this one name nothing yet: they are today's
|
|
14
|
+
* unconditional writes, moved onto the only door that can write a folder state
|
|
15
|
+
* at all. Each is narrowed to the from-set its transition-table row allows when
|
|
16
|
+
* the intents themselves land (#362, #363), which is also where a loser gets
|
|
17
|
+
* its 409.
|
|
18
|
+
*/
|
|
19
|
+
export const EVERY_MAILBOX_STATE = [
|
|
20
|
+
MailboxSyncStatus.synced,
|
|
21
|
+
MailboxSyncStatus.pending,
|
|
22
|
+
MailboxSyncStatus.failed,
|
|
23
|
+
MailboxSyncStatus.deleting,
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether a folder mutation is in flight, so nothing else may touch the folder
|
|
28
|
+
* over IMAP.
|
|
29
|
+
*
|
|
30
|
+
* It used to be spelled as a presence test — is the folder off the server —
|
|
31
|
+
* and that is no longer what it asks. Under D2 `fullPath` is always a path the
|
|
32
|
+
* server holds, rename target and all, so a rename-pending folder *is* on the
|
|
33
|
+
* server; what the two states share is that a worker owns the folder until its
|
|
34
|
+
* mutation settles. `pending` and `deleting` are written by the request itself,
|
|
35
|
+
* before the event that resolves it is enqueued, so neither can be observed
|
|
36
|
+
* unless a create or rename has yet to land or a delete has been asked for.
|
|
37
|
+
*
|
|
38
|
+
* This is the terminal test for a sync event whose folder is not the sync's to
|
|
39
|
+
* touch. The row's absence alone does not decide it, at either end: on the way
|
|
40
|
+
* out the row is removed only after the IMAP folder is, so an event that fails
|
|
41
|
+
* against the server still sees a live row for as long as that write takes; on
|
|
42
|
+
* the way in the row exists before the folder does.
|
|
43
|
+
*
|
|
44
|
+
* `failed` is deliberately not one of them. The invariant is that a `failed`
|
|
45
|
+
* row's folder exists at `fullPath` and the last rename or delete intent did
|
|
46
|
+
* not land — so the folder holds the user's mail and syncing it is right.
|
|
47
|
+
* D7 is what upholds that: a create the server refuses leaves no row at all,
|
|
48
|
+
* rather than a `failed` one standing for a folder that was never made. Reading
|
|
49
|
+
* `failed` as off-server would stop syncing a live folder permanently and
|
|
50
|
+
* silently, which is worse than the bounded stall it would remove.
|
|
22
51
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* and
|
|
26
|
-
* `handleRename` set it on folders that exist and hold the user's mail, and
|
|
27
|
-
* nothing ever clears it — `syncStatus` returns to `synced` only from a
|
|
28
|
-
* subsequent successful create or rename, never from the sweep. Treating it as
|
|
29
|
-
* off-server would stop syncing such a folder permanently and silently, which is
|
|
30
|
-
* worse than the bounded stall it would remove. A `failed` row whose folder is
|
|
31
|
-
* genuinely absent is reaped by the sweep's own cleanup, which runs before the
|
|
32
|
-
* fan-out that would enqueue work for it.
|
|
52
|
+
* A rename-pending folder is skipped even though its `fullPath` is live. That
|
|
53
|
+
* is conservative rather than necessary (D12): it keeps this a single
|
|
54
|
+
* expression and costs a few seconds of sync for one folder.
|
|
33
55
|
*
|
|
34
56
|
* Mailbox management is judged differently — it is what establishes and removes
|
|
35
57
|
* the folder, so it terminates on a not-found row alone.
|
|
36
58
|
*/
|
|
37
|
-
export const
|
|
59
|
+
export const isFolderMutationInFlight = (
|
|
38
60
|
mailbox: Pick<MailboxItem, "syncStatus">,
|
|
39
61
|
): boolean =>
|
|
40
62
|
mailbox.syncStatus === MailboxSyncStatus.pending ||
|
|
41
63
|
mailbox.syncStatus === MailboxSyncStatus.deleting;
|
|
42
64
|
|
|
43
|
-
/** {@link
|
|
44
|
-
export const
|
|
65
|
+
/** {@link isFolderMutationInFlight} for a mailbox that has to be read first, an absent row included. */
|
|
66
|
+
export const isMailboxMutationInFlight = async (
|
|
45
67
|
mailboxService: Pick<IMailboxRepository, "get">,
|
|
46
68
|
accountId: string,
|
|
47
69
|
mailboxId: string,
|
|
@@ -53,5 +75,5 @@ export const isMailboxNotOnServer = async (
|
|
|
53
75
|
throw error;
|
|
54
76
|
});
|
|
55
77
|
if (!mailbox) return true;
|
|
56
|
-
return
|
|
78
|
+
return isFolderMutationInFlight(mailbox);
|
|
57
79
|
};
|
package/src/mailbox-queue.ts
CHANGED
|
@@ -5,8 +5,11 @@ import type {
|
|
|
5
5
|
IMailboxRepository,
|
|
6
6
|
MailboxItem,
|
|
7
7
|
} from "@remit/data-ports";
|
|
8
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
9
|
+
import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
|
|
8
10
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
9
11
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
12
|
+
import { EVERY_MAILBOX_STATE } from "./mailbox-presence.js";
|
|
10
13
|
|
|
11
14
|
/**
|
|
12
15
|
* MAILBOX_CREATE event structure (matches remit-imap-worker/events.ts)
|
|
@@ -163,19 +166,32 @@ export class MailboxQueueService {
|
|
|
163
166
|
const mailbox = await this.mailboxService.get(accountId, mailboxId);
|
|
164
167
|
const oldPath = mailbox.fullPath;
|
|
165
168
|
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
169
|
+
// One intent over the folder and every descendant, in one transaction:
|
|
170
|
+
// IMAP RENAME moves the subtree in one command, so a partially recorded
|
|
171
|
+
// rename is the state that produced today's phantom rows (D6). Each
|
|
172
|
+
// descendant's new path is equally absent from the server until
|
|
173
|
+
// MAILBOX_RENAME lands, so each is `pending` too — otherwise a reconcile
|
|
174
|
+
// in that window reaps it as server-deleted (#290).
|
|
175
|
+
const written = await this.mailboxService.transitionSubtree(
|
|
176
|
+
accountId,
|
|
177
|
+
mailboxId,
|
|
178
|
+
{
|
|
179
|
+
from: EVERY_MAILBOX_STATE,
|
|
180
|
+
to: MailboxSyncStatus.pending,
|
|
181
|
+
rowSet: (row) => {
|
|
182
|
+
const moved = rebaseMailboxPath(
|
|
183
|
+
row.fullPath,
|
|
184
|
+
oldPath,
|
|
185
|
+
newPath,
|
|
186
|
+
mailbox.hierarchyDelimiter,
|
|
187
|
+
);
|
|
188
|
+
return moved === undefined ? {} : { fullPath: moved };
|
|
189
|
+
},
|
|
190
|
+
},
|
|
178
191
|
);
|
|
192
|
+
if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
193
|
+
const updated = written.find((row) => row.mailboxId === mailboxId);
|
|
194
|
+
if (!updated) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
179
195
|
|
|
180
196
|
this.log.info({ mailboxId, oldPath, newPath }, "Renamed mailbox (local)");
|
|
181
197
|
|
|
@@ -208,9 +224,12 @@ export class MailboxQueueService {
|
|
|
208
224
|
const mailbox = await this.mailboxService.get(accountId, mailboxId);
|
|
209
225
|
|
|
210
226
|
// Mark as deleting (soft delete - worker will do actual delete after IMAP sync)
|
|
211
|
-
await this.mailboxService.
|
|
212
|
-
|
|
213
|
-
|
|
227
|
+
const recorded = await this.mailboxService.transition(
|
|
228
|
+
accountId,
|
|
229
|
+
mailboxId,
|
|
230
|
+
{ from: EVERY_MAILBOX_STATE, to: MailboxSyncStatus.deleting },
|
|
231
|
+
);
|
|
232
|
+
if (!recorded) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
214
233
|
|
|
215
234
|
this.log.info(
|
|
216
235
|
{ mailboxId, path: mailbox.fullPath },
|
|
@@ -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
|
|