@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
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,
|
|
@@ -193,6 +194,7 @@ export {
|
|
|
193
194
|
type SyncAccountInfo,
|
|
194
195
|
} from "./mailbox-sync.js";
|
|
195
196
|
export {
|
|
197
|
+
type DeleteMessagesOutcome,
|
|
196
198
|
type DeleteOptions,
|
|
197
199
|
type MessageMoveConfig,
|
|
198
200
|
type MessageMoveLogger,
|
|
@@ -267,6 +269,12 @@ export {
|
|
|
267
269
|
resolveMailboxRole,
|
|
268
270
|
shapeFromMessageData,
|
|
269
271
|
} from "./quarantine.js";
|
|
272
|
+
export {
|
|
273
|
+
type RestoreSourcePlacementDeps,
|
|
274
|
+
type RestoreSourcePlacementInput,
|
|
275
|
+
type RestoreSourcePlacementOutcome,
|
|
276
|
+
restoreSourcePlacement,
|
|
277
|
+
} from "./restore-source-placement.js";
|
|
270
278
|
export {
|
|
271
279
|
extractSnippetFromEmail,
|
|
272
280
|
generateSnippet,
|
|
@@ -286,6 +294,12 @@ export {
|
|
|
286
294
|
type StaleMessageReconcileDeps,
|
|
287
295
|
type StaleMessageReconcileResult,
|
|
288
296
|
} from "./stale-message-reconcile.js";
|
|
297
|
+
export {
|
|
298
|
+
buildThreadMessageMoveRevert,
|
|
299
|
+
buildThreadMessageTrashUpdate,
|
|
300
|
+
buildThreadMessageUndelete,
|
|
301
|
+
type ThreadMessageRowState,
|
|
302
|
+
} from "./thread-message-rows.js";
|
|
289
303
|
export type {
|
|
290
304
|
FlatMailboxInfo,
|
|
291
305
|
IImapConnection,
|
|
@@ -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 },
|