@remit/mailbox-service 0.0.96 → 0.0.98
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/imapflow-connection.ts +9 -0
- package/src/index.ts +6 -0
- package/src/mailbox-delete-intent.test.ts +246 -0
- package/src/mailbox-intent.ts +109 -0
- package/src/mailbox-management.test.ts +28 -2
- package/src/mailbox-management.ts +544 -72
- package/src/mailbox-presence.ts +6 -5
- package/src/mailbox-queue.ts +157 -21
- package/src/mailbox-rename-intent.test.ts +271 -25
- package/src/mailbox-rename-subtree.test.ts +494 -106
- package/src/mailbox-sync.ts +1 -27
- package/src/mailbox-upstream.ts +83 -0
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
import type { IMailboxRepository } from "@remit/data-ports";
|
|
1
|
+
import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
|
|
2
2
|
import { NotFoundError } from "@remit/data-ports/errors";
|
|
3
|
+
import { rebaseMailboxPath } from "@remit/data-ports/mailbox-name";
|
|
3
4
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
5
|
+
import { recordedByRename } from "./mailbox-intent.js";
|
|
6
|
+
import { isNotFoundError } from "./mailbox-presence.js";
|
|
7
|
+
import {
|
|
8
|
+
FolderGoneUpstreamError,
|
|
9
|
+
FolderRenameSettleError,
|
|
10
|
+
isMailboxAbsentUpstream,
|
|
11
|
+
} from "./mailbox-upstream.js";
|
|
12
|
+
import type { FlatMailboxInfo, IImapConnection } from "./types.js";
|
|
6
13
|
|
|
7
14
|
/**
|
|
8
15
|
* Input for creating a mailbox
|
|
@@ -35,6 +42,12 @@ export interface DeleteMailboxInput {
|
|
|
35
42
|
export interface MailboxManagementSyncResult {
|
|
36
43
|
success: boolean;
|
|
37
44
|
error?: string;
|
|
45
|
+
/**
|
|
46
|
+
* Where the folder ended up, on a rename the server confirmed. Absent when
|
|
47
|
+
* the recorded intent had already moved on, so no RENAME was issued —
|
|
48
|
+
* which is how a caller tells the two apart without a second read.
|
|
49
|
+
*/
|
|
50
|
+
renamed?: { oldPath: string; newPath: string; delimiter: string };
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
/**
|
|
@@ -93,9 +106,38 @@ export const validateMailboxOperation = (
|
|
|
93
106
|
operation: "delete" | "rename",
|
|
94
107
|
path: string,
|
|
95
108
|
): void => {
|
|
96
|
-
if (path.toUpperCase()
|
|
97
|
-
|
|
98
|
-
|
|
109
|
+
if (path.toUpperCase() !== "INBOX") return;
|
|
110
|
+
// A RENAME of INBOX moves its mail to the new name and leaves an empty INBOX
|
|
111
|
+
// behind — a bulk move wearing a rename's clothes, which strands the
|
|
112
|
+
// account's inbox appointment and voids the row's UID cursor (D5). The API
|
|
113
|
+
// refuses it; this is the backstop for anything that reaches the worker
|
|
114
|
+
// another way.
|
|
115
|
+
throw new Error(
|
|
116
|
+
operation === "delete" ? "Cannot delete INBOX" : "Cannot rename INBOX",
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Whether the server's own listing holds a path this code named.
|
|
122
|
+
*
|
|
123
|
+
* The requested string and the path the server keeps are not the same thing.
|
|
124
|
+
* Under a namespace prefix a Dovecot INBOX namespace stores `Projects` as
|
|
125
|
+
* `INBOX.Projects`, which is why the settle adopts the path ImapFlow resolved
|
|
126
|
+
* rather than the one that was asked for (D2) — and a probe comparing the raw
|
|
127
|
+
* request against the listing would read a folder that is plainly there as
|
|
128
|
+
* absent. There is no resolved path to adopt on the failure path, so the
|
|
129
|
+
* account's own prefix stands in for the normalization.
|
|
130
|
+
*/
|
|
131
|
+
const holdsPath = (
|
|
132
|
+
onServer: ReadonlySet<string>,
|
|
133
|
+
mailbox: Pick<MailboxItem, "namespacePrefix">,
|
|
134
|
+
path: string,
|
|
135
|
+
): boolean => {
|
|
136
|
+
if (onServer.has(path)) return true;
|
|
137
|
+
const { namespacePrefix } = mailbox;
|
|
138
|
+
if (namespacePrefix.length === 0) return false;
|
|
139
|
+
if (path.startsWith(namespacePrefix)) return false;
|
|
140
|
+
return onServer.has(`${namespacePrefix}${path}`);
|
|
99
141
|
};
|
|
100
142
|
|
|
101
143
|
/**
|
|
@@ -117,27 +159,103 @@ export class MailboxManagementService {
|
|
|
117
159
|
}
|
|
118
160
|
|
|
119
161
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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.
|
|
162
|
+
* Settle a create: `pending` with **no** recorded rename target, which is the
|
|
163
|
+
* state a create in flight is (D3, D10).
|
|
125
164
|
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
165
|
+
* The absence check is what the whole `wherePendingPath` predicate exists
|
|
166
|
+
* for. A create whose acknowledgement was lost, redelivered against a row a
|
|
167
|
+
* rename has since claimed, passes a `pending`-only guard; the CREATE
|
|
168
|
+
* collides, ImapFlow reports the collision as `{created: false}` rather than
|
|
169
|
+
* an error, and this write then strands the row `synced` with a rename
|
|
170
|
+
* target on it — the seventh combination — leaving the rename's own settle
|
|
171
|
+
* with nothing to match and the RENAME never running.
|
|
172
|
+
*
|
|
173
|
+
* A null means the row is gone or the intent moved on. The handlers'
|
|
174
|
+
* #289-class terminal guards classify the NotFoundError as the user having
|
|
175
|
+
* deleted the folder mid-sync.
|
|
129
176
|
*/
|
|
130
|
-
private
|
|
177
|
+
private settleCreate = async (
|
|
131
178
|
accountId: string,
|
|
132
179
|
mailboxId: string,
|
|
133
180
|
confirmedPath?: string,
|
|
134
181
|
): Promise<void> => {
|
|
135
182
|
const settled = await this.mailboxService.transition(accountId, mailboxId, {
|
|
136
|
-
from:
|
|
183
|
+
from: [MailboxSyncStatus.pending],
|
|
184
|
+
wherePendingPath: null,
|
|
137
185
|
to: MailboxSyncStatus.synced,
|
|
138
186
|
set: confirmedPath !== undefined ? { fullPath: confirmedPath } : {},
|
|
139
187
|
});
|
|
140
|
-
if (
|
|
188
|
+
if (settled) return;
|
|
189
|
+
// Either the row is gone, or a rename claimed it while this create was in
|
|
190
|
+
// flight. Both resolve the job: the folder exists, and the rename is the
|
|
191
|
+
// live intent.
|
|
192
|
+
const current = await this.mailboxService
|
|
193
|
+
.get(accountId, mailboxId)
|
|
194
|
+
.catch((error: unknown) => {
|
|
195
|
+
if (isNotFoundError(error)) return undefined;
|
|
196
|
+
throw error;
|
|
197
|
+
});
|
|
198
|
+
if (!current) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
199
|
+
this.log.info(
|
|
200
|
+
{
|
|
201
|
+
accountId,
|
|
202
|
+
mailboxId,
|
|
203
|
+
intent: "create",
|
|
204
|
+
from: current.syncStatus,
|
|
205
|
+
outcome: "superseded",
|
|
206
|
+
},
|
|
207
|
+
"Folder create superseded",
|
|
208
|
+
);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The rows one rename intent recorded, each with the path it settles to.
|
|
213
|
+
*
|
|
214
|
+
* The settle deliberately does not re-resolve the subtree (D15): membership
|
|
215
|
+
* changes between the intent and the settle — the sweep inserts a row,
|
|
216
|
+
* another client deletes a descendant — and an all-or-nothing settle then
|
|
217
|
+
* either throws, poisoning the account's FIFO group (#339), or resolves
|
|
218
|
+
* leaving every row `pending` forever with no route out, because `failed` is
|
|
219
|
+
* only reachable from a settle that ran.
|
|
220
|
+
*
|
|
221
|
+
* So the rows identify themselves, by the pair `recordedByRename` tests: a
|
|
222
|
+
* target at or under the rename's target is not on its own enough, because a
|
|
223
|
+
* second folder renamed under the same branch would be claimed by a rename
|
|
224
|
+
* that never touched it. A row that appeared afterwards carries no such pair
|
|
225
|
+
* and is not touched.
|
|
226
|
+
*
|
|
227
|
+
* `confirmedPath` re-prefixes each recorded target off the path the rename
|
|
228
|
+
* actually resolved to, so a normalization the requested string did not carry
|
|
229
|
+
* moves the whole branch rather than the folder alone.
|
|
230
|
+
*
|
|
231
|
+
* `delimiter` is the renamed folder's own, read from the row the caller
|
|
232
|
+
* already holds — deriving one from whatever the listing happens to contain
|
|
233
|
+
* strands every descendant when that row has gone.
|
|
234
|
+
*/
|
|
235
|
+
private intentCarryingRows = async (
|
|
236
|
+
accountId: string,
|
|
237
|
+
oldPath: string,
|
|
238
|
+
target: string,
|
|
239
|
+
confirmed: string,
|
|
240
|
+
delimiter: string,
|
|
241
|
+
): Promise<{ row: MailboxItem; confirmedPath: string }[]> => {
|
|
242
|
+
const pending = await this.mailboxService.findBySyncStatus(
|
|
243
|
+
accountId,
|
|
244
|
+
MailboxSyncStatus.pending,
|
|
245
|
+
);
|
|
246
|
+
const carrying: { row: MailboxItem; confirmedPath: string }[] = [];
|
|
247
|
+
for (const row of pending) {
|
|
248
|
+
if (!recordedByRename(row, oldPath, target, delimiter)) continue;
|
|
249
|
+
const confirmedPath = rebaseMailboxPath(
|
|
250
|
+
row.pendingPath as string,
|
|
251
|
+
target,
|
|
252
|
+
confirmed,
|
|
253
|
+
delimiter,
|
|
254
|
+
);
|
|
255
|
+
if (confirmedPath === undefined) continue;
|
|
256
|
+
carrying.push({ row, confirmedPath });
|
|
257
|
+
}
|
|
258
|
+
return carrying;
|
|
141
259
|
};
|
|
142
260
|
|
|
143
261
|
/**
|
|
@@ -206,25 +324,30 @@ export class MailboxManagementService {
|
|
|
206
324
|
uidNext: status.uidnext,
|
|
207
325
|
messageCount: status.messages.total,
|
|
208
326
|
});
|
|
209
|
-
await this.
|
|
327
|
+
await this.settleCreate(accountId, mailboxId, confirmedPath);
|
|
210
328
|
|
|
211
329
|
await connection.closeBox();
|
|
212
330
|
} else {
|
|
213
331
|
// Mark as synced even if we couldn't get full info
|
|
214
|
-
await this.
|
|
332
|
+
await this.settleCreate(accountId, mailboxId, confirmedPath);
|
|
215
333
|
}
|
|
216
334
|
|
|
217
335
|
return { success: true };
|
|
218
336
|
};
|
|
219
337
|
|
|
220
338
|
/**
|
|
221
|
-
* Sync a RENAME operation to IMAP.
|
|
339
|
+
* Sync a RENAME operation to IMAP and settle the rows that recorded it (T5).
|
|
222
340
|
* Called by worker after dequeuing MAILBOX_RENAME event.
|
|
223
341
|
*
|
|
342
|
+
* Guarded on the intent, not on the event (D10): the row must still be
|
|
343
|
+
* `pending` with this event's target recorded, or the rename resolves with no
|
|
344
|
+
* IMAP call at all. That is what stops a redelivered MAILBOX_RENAME re-issuing
|
|
345
|
+
* a RENAME from a path that has already moved.
|
|
346
|
+
*
|
|
224
347
|
* @param accountId - Account that owns the mailbox (tenant scope)
|
|
225
348
|
* @param mailboxId - ID of the mailbox to rename
|
|
226
|
-
* @param oldPath -
|
|
227
|
-
* @param newPath -
|
|
349
|
+
* @param oldPath - Path the intent was recorded against
|
|
350
|
+
* @param newPath - Target the intent recorded
|
|
228
351
|
* @param getConnection - Factory to get IMAP connection
|
|
229
352
|
*/
|
|
230
353
|
syncRename = async (
|
|
@@ -234,75 +357,336 @@ export class MailboxManagementService {
|
|
|
234
357
|
newPath: string,
|
|
235
358
|
getConnection: () => Promise<IImapConnection>,
|
|
236
359
|
): Promise<MailboxManagementSyncResult> => {
|
|
360
|
+
const standing = await this.renameIntentStanding(
|
|
361
|
+
accountId,
|
|
362
|
+
mailboxId,
|
|
363
|
+
newPath,
|
|
364
|
+
);
|
|
365
|
+
if (!standing) return { success: true };
|
|
366
|
+
|
|
237
367
|
const connection = await getConnection();
|
|
368
|
+
const delimiter = standing.hierarchyDelimiter;
|
|
238
369
|
|
|
239
|
-
await
|
|
370
|
+
const confirmed = await this.issueRename(
|
|
371
|
+
connection,
|
|
372
|
+
standing,
|
|
373
|
+
accountId,
|
|
374
|
+
mailboxId,
|
|
375
|
+
oldPath,
|
|
376
|
+
newPath,
|
|
377
|
+
);
|
|
240
378
|
|
|
241
379
|
this.log.info(
|
|
242
|
-
{ mailboxId, oldPath, newPath },
|
|
380
|
+
{ accountId, mailboxId, intent: "rename", oldPath, newPath, confirmed },
|
|
243
381
|
"Renamed mailbox on IMAP server",
|
|
244
382
|
);
|
|
245
383
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
// the caller
|
|
251
|
-
//
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
384
|
+
// The server has executed the rename. Nothing after this point may reach
|
|
385
|
+
// the caller's failure path: `failRename` would mark the rows refused for
|
|
386
|
+
// a rename that landed, and the folder would sit at `failed` offering a
|
|
387
|
+
// retry of something already done. A settle that cannot finish is wrapped
|
|
388
|
+
// so the caller can tell the two apart, and rethrown so SQS redelivers —
|
|
389
|
+
// the rows are still `pending` with their targets, so the redelivery's
|
|
390
|
+
// guard passes and the settle runs again.
|
|
391
|
+
await this.settleRenameIntent(
|
|
392
|
+
accountId,
|
|
393
|
+
mailboxId,
|
|
394
|
+
oldPath,
|
|
395
|
+
newPath,
|
|
396
|
+
confirmed,
|
|
397
|
+
delimiter,
|
|
398
|
+
).catch((error: unknown) => {
|
|
399
|
+
throw new FolderRenameSettleError(mailboxId, confirmed, error);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
success: true,
|
|
404
|
+
renamed: { oldPath, newPath: confirmed, delimiter },
|
|
405
|
+
};
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Issue the RENAME and read back the path it resolved to.
|
|
410
|
+
*
|
|
411
|
+
* A `NONEXISTENT` here has three readings, and only one of them may destroy
|
|
412
|
+
* anything. Either another client deleted the folder; or this rename already
|
|
413
|
+
* landed and only its settle was lost, in which case the folder is alive at
|
|
414
|
+
* the target; or the server is saying something this code cannot classify.
|
|
415
|
+
* The listing separates the first two, and everything it cannot answer is a
|
|
416
|
+
* refused rename (T6) — a rename that failed costs the user a retry, while
|
|
417
|
+
* reading an unclear answer as a delete costs them the folder's mail.
|
|
418
|
+
*
|
|
419
|
+
* So the caller is told which of the two it is by the *kind* of error, never
|
|
420
|
+
* by re-reading the server's code: only `FolderGoneUpstreamError` removes a
|
|
421
|
+
* folder, and it is raised exactly where the listing held neither path.
|
|
422
|
+
*/
|
|
423
|
+
private issueRename = async (
|
|
424
|
+
connection: IImapConnection,
|
|
425
|
+
standing: MailboxItem,
|
|
426
|
+
accountId: string,
|
|
427
|
+
mailboxId: string,
|
|
428
|
+
oldPath: string,
|
|
429
|
+
newPath: string,
|
|
430
|
+
): Promise<string> => {
|
|
431
|
+
const result = await connection
|
|
432
|
+
.renameMailbox(oldPath, newPath)
|
|
433
|
+
.catch(async (error: unknown) => {
|
|
434
|
+
if (!isMailboxAbsentUpstream(error)) throw error;
|
|
435
|
+
const listed = await connection
|
|
436
|
+
.listMailboxes()
|
|
437
|
+
.catch((): FlatMailboxInfo[] | undefined => undefined);
|
|
438
|
+
// A listing that cannot be read answers nothing, so it decides
|
|
439
|
+
// nothing: the rename is refused and the folder is left alone.
|
|
440
|
+
if (!listed) throw error;
|
|
441
|
+
|
|
442
|
+
const onServer = new Set(listed.map((mailbox) => mailbox.fullPath));
|
|
443
|
+
if (holdsPath(onServer, standing, newPath)) {
|
|
444
|
+
this.log.info(
|
|
445
|
+
{ accountId, mailboxId, intent: "rename", oldPath, newPath },
|
|
446
|
+
"Source folder gone and the target is on the server: this rename already landed",
|
|
447
|
+
);
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
450
|
+
if (holdsPath(onServer, standing, oldPath)) throw error;
|
|
451
|
+
|
|
452
|
+
throw new FolderGoneUpstreamError(mailboxId, oldPath, error);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// ImapFlow's own normalization of the requested path — namespace prefix
|
|
456
|
+
// applied, special names resolved, delimiter joined. IMAP's RENAME reply
|
|
457
|
+
// carries no path, so this is not the server echoing a name back and it
|
|
458
|
+
// cannot detect a server that stored a different one; adopting it is still
|
|
459
|
+
// right, because it fixes the prefix and delimiter drift the requested
|
|
460
|
+
// string carries (D2).
|
|
461
|
+
return typeof result?.newPath === "string" && result.newPath.length > 0
|
|
462
|
+
? result.newPath
|
|
463
|
+
: newPath;
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Whether the rename this event was enqueued for is still the folder's live
|
|
468
|
+
* intent. `pending` alone cannot answer it — that state covers a create in
|
|
469
|
+
* flight too — so the recorded target is half the predicate, and it is the
|
|
470
|
+
* same predicate the settling write carries, so the guard and the write
|
|
471
|
+
* cannot disagree between the read and the write.
|
|
472
|
+
*/
|
|
473
|
+
private renameIntentStanding = async (
|
|
474
|
+
accountId: string,
|
|
475
|
+
mailboxId: string,
|
|
476
|
+
newPath: string,
|
|
477
|
+
): Promise<MailboxItem | undefined> => {
|
|
478
|
+
const row = await this.mailboxService
|
|
479
|
+
.get(accountId, mailboxId)
|
|
480
|
+
.catch((error: unknown) => {
|
|
481
|
+
if (isNotFoundError(error)) return undefined;
|
|
482
|
+
throw error;
|
|
483
|
+
});
|
|
484
|
+
if (
|
|
485
|
+
row &&
|
|
486
|
+
row.syncStatus === MailboxSyncStatus.pending &&
|
|
487
|
+
row.pendingPath === newPath
|
|
488
|
+
) {
|
|
489
|
+
return row;
|
|
490
|
+
}
|
|
491
|
+
this.log.info(
|
|
492
|
+
{
|
|
493
|
+
accountId,
|
|
494
|
+
mailboxId,
|
|
495
|
+
intent: "rename",
|
|
496
|
+
from: row?.syncStatus,
|
|
497
|
+
newPath,
|
|
498
|
+
outcome: row ? "superseded" : "already-settled",
|
|
258
499
|
},
|
|
500
|
+
"Skipping MAILBOX_RENAME: the recorded intent has moved on",
|
|
259
501
|
);
|
|
260
|
-
|
|
261
|
-
return { success: true };
|
|
502
|
+
return undefined;
|
|
262
503
|
};
|
|
263
504
|
|
|
264
505
|
/**
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
* its sync events are terminally acked and its mail never arrives.
|
|
506
|
+
* T5, per row (D15): each intent-carrying row adopts its own confirmed path,
|
|
507
|
+
* drops the recorded target and settles `synced`, on its own conditional
|
|
508
|
+
* write. A row another client moved in between fails its predicate and is
|
|
509
|
+
* skipped rather than overwritten; a row that is gone is skipped for free.
|
|
270
510
|
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
511
|
+
* **The named folder is written last, and that ordering is load-bearing.**
|
|
512
|
+
* The redelivery's guard reads that row and nothing else, so it is the
|
|
513
|
+
* witness for the whole settle: while it still carries the intent, a
|
|
514
|
+
* redelivery re-enters and finishes whatever the last attempt left. Settling
|
|
515
|
+
* it first would ack a run that died part-way through its descendants, and
|
|
516
|
+
* those rows would stay `pending` forever — invisible to message sync, and
|
|
517
|
+
* with no route out, because an intent may only be recorded from `synced` or
|
|
518
|
+
* `failed`, so neither a retry nor a dismissal can reach them.
|
|
275
519
|
*/
|
|
276
|
-
private
|
|
520
|
+
private settleRenameIntent = async (
|
|
277
521
|
accountId: string,
|
|
522
|
+
mailboxId: string,
|
|
523
|
+
oldPath: string,
|
|
524
|
+
target: string,
|
|
525
|
+
confirmed: string,
|
|
526
|
+
delimiter: string,
|
|
527
|
+
): Promise<void> => {
|
|
528
|
+
const carrying = await this.intentCarryingRows(
|
|
529
|
+
accountId,
|
|
530
|
+
oldPath,
|
|
531
|
+
target,
|
|
532
|
+
confirmed,
|
|
533
|
+
delimiter,
|
|
534
|
+
);
|
|
535
|
+
const ordered = [
|
|
536
|
+
...carrying.filter((entry) => entry.row.mailboxId !== mailboxId),
|
|
537
|
+
...carrying.filter((entry) => entry.row.mailboxId === mailboxId),
|
|
538
|
+
];
|
|
539
|
+
for (const { row, confirmedPath } of ordered) {
|
|
540
|
+
const settled = await this.mailboxService.transition(
|
|
541
|
+
accountId,
|
|
542
|
+
row.mailboxId,
|
|
543
|
+
{
|
|
544
|
+
from: [MailboxSyncStatus.pending],
|
|
545
|
+
wherePendingPath: row.pendingPath,
|
|
546
|
+
to: MailboxSyncStatus.synced,
|
|
547
|
+
set: { fullPath: confirmedPath },
|
|
548
|
+
},
|
|
549
|
+
);
|
|
550
|
+
this.log.info(
|
|
551
|
+
{
|
|
552
|
+
accountId,
|
|
553
|
+
mailboxId: row.mailboxId,
|
|
554
|
+
intent: "rename",
|
|
555
|
+
from: MailboxSyncStatus.pending,
|
|
556
|
+
to: MailboxSyncStatus.synced,
|
|
557
|
+
fullPath: confirmedPath,
|
|
558
|
+
outcome: settled ? "settled" : "superseded",
|
|
559
|
+
},
|
|
560
|
+
"Settled renamed folder",
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* T6: the rename did not land, so every row that recorded it goes to
|
|
567
|
+
* `failed`, keeping its target so the client can name what the rename was
|
|
568
|
+
* aiming at and offer a retry. Nothing is restored — `fullPath` was never
|
|
569
|
+
* written, which is the whole point of recording the target instead (D2).
|
|
570
|
+
*/
|
|
571
|
+
failRename = async (
|
|
572
|
+
accountId: string,
|
|
573
|
+
mailboxId: string,
|
|
574
|
+
oldPath: string,
|
|
278
575
|
newPath: string,
|
|
279
|
-
connection: IImapConnection,
|
|
280
576
|
): Promise<void> => {
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
577
|
+
const root = await this.mailboxService
|
|
578
|
+
.get(accountId, mailboxId)
|
|
579
|
+
.catch((error: unknown) => {
|
|
580
|
+
if (isNotFoundError(error)) return undefined;
|
|
581
|
+
throw error;
|
|
582
|
+
});
|
|
583
|
+
if (!root) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
584
|
+
|
|
585
|
+
const carrying = await this.intentCarryingRows(
|
|
289
586
|
accountId,
|
|
587
|
+
oldPath,
|
|
290
588
|
newPath,
|
|
291
|
-
|
|
589
|
+
newPath,
|
|
590
|
+
root.hierarchyDelimiter,
|
|
292
591
|
);
|
|
592
|
+
// The row is there and no row carries the intent, so this refusal has
|
|
593
|
+
// nothing to record. It must not be reported as a missing mailbox: the
|
|
594
|
+
// caller's #289-class guard reads a NotFoundError as the user having
|
|
595
|
+
// deleted the folder and acks the message, which would swallow the IMAP
|
|
596
|
+
// error that brought us here and leave the subtree `pending`. Say so and
|
|
597
|
+
// let the caller rethrow what actually failed.
|
|
598
|
+
if (carrying.length === 0) {
|
|
599
|
+
this.log.error(
|
|
600
|
+
{
|
|
601
|
+
accountId,
|
|
602
|
+
mailboxId,
|
|
603
|
+
intent: "rename",
|
|
604
|
+
from: root.syncStatus,
|
|
605
|
+
oldPath,
|
|
606
|
+
newPath,
|
|
607
|
+
outcome: "superseded",
|
|
608
|
+
},
|
|
609
|
+
"Refused rename recorded nothing: no row carries this intent",
|
|
610
|
+
);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
for (const { row } of carrying) {
|
|
614
|
+
const failed = await this.mailboxService.transition(
|
|
615
|
+
accountId,
|
|
616
|
+
row.mailboxId,
|
|
617
|
+
{
|
|
618
|
+
from: [MailboxSyncStatus.pending],
|
|
619
|
+
wherePendingPath: row.pendingPath,
|
|
620
|
+
to: MailboxSyncStatus.failed,
|
|
621
|
+
},
|
|
622
|
+
);
|
|
623
|
+
this.log.info(
|
|
624
|
+
{
|
|
625
|
+
accountId,
|
|
626
|
+
mailboxId: row.mailboxId,
|
|
627
|
+
intent: "rename",
|
|
628
|
+
from: MailboxSyncStatus.pending,
|
|
629
|
+
to: MailboxSyncStatus.failed,
|
|
630
|
+
outcome: failed ? "refused" : "superseded",
|
|
631
|
+
},
|
|
632
|
+
"Recorded a refused rename",
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
};
|
|
293
636
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
637
|
+
/**
|
|
638
|
+
* The folder the rename was to move was found gone from the server, and
|
|
639
|
+
* the target was not there either, so it was deleted by another client
|
|
640
|
+
* mid-rename (D8). Every row that recorded this rename's intent — the
|
|
641
|
+
* root and each descendant — goes with its mail, the same walk `failRename`
|
|
642
|
+
* uses: deleting the root alone strands every descendant `pending` with
|
|
643
|
+
* nothing left to revisit it, which is the orphaning bug this closes.
|
|
644
|
+
*/
|
|
645
|
+
abandonRenameSubtree = async (
|
|
646
|
+
accountId: string,
|
|
647
|
+
mailboxId: string,
|
|
648
|
+
oldPath: string,
|
|
649
|
+
newPath: string,
|
|
650
|
+
): Promise<void> => {
|
|
651
|
+
const root = await this.mailboxService
|
|
652
|
+
.get(accountId, mailboxId)
|
|
653
|
+
.catch((error: unknown) => {
|
|
654
|
+
if (isNotFoundError(error)) return undefined;
|
|
655
|
+
throw error;
|
|
305
656
|
});
|
|
657
|
+
if (!root) {
|
|
658
|
+
this.log.info(
|
|
659
|
+
{ accountId, mailboxId, intent: "rename", outcome: "already-settled" },
|
|
660
|
+
"Nothing to abandon: the folder row is already gone",
|
|
661
|
+
);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const carrying = await this.intentCarryingRows(
|
|
666
|
+
accountId,
|
|
667
|
+
oldPath,
|
|
668
|
+
newPath,
|
|
669
|
+
newPath,
|
|
670
|
+
root.hierarchyDelimiter,
|
|
671
|
+
);
|
|
672
|
+
// A removal that throws part-way abandons the rest into a redelivery, and
|
|
673
|
+
// that is safe rather than merely tolerable: `deleteMailboxWithMail` is
|
|
674
|
+
// idempotent — it returns without touching anything when the row is gone,
|
|
675
|
+
// and resumes from whatever is left when it is not, because the mailbox
|
|
676
|
+
// row is removed last. The rows still standing carry the intent, so the
|
|
677
|
+
// next attempt resolves the same set and finishes it.
|
|
678
|
+
for (const { row } of carrying) {
|
|
679
|
+
await this.mailboxService.deleteMailboxWithMail(accountId, row.mailboxId);
|
|
680
|
+
this.log.info(
|
|
681
|
+
{
|
|
682
|
+
accountId,
|
|
683
|
+
mailboxId: row.mailboxId,
|
|
684
|
+
intent: "rename",
|
|
685
|
+
from: MailboxSyncStatus.pending,
|
|
686
|
+
to: "deleted",
|
|
687
|
+
},
|
|
688
|
+
"Source mailbox not found, deleted local folder and its mail",
|
|
689
|
+
);
|
|
306
690
|
}
|
|
307
691
|
};
|
|
308
692
|
|
|
@@ -321,15 +705,103 @@ export class MailboxManagementService {
|
|
|
321
705
|
path: string,
|
|
322
706
|
getConnection: () => Promise<IImapConnection>,
|
|
323
707
|
): Promise<MailboxManagementSyncResult> => {
|
|
708
|
+
if (!(await this.deleteIntentStanding(accountId, mailboxId))) {
|
|
709
|
+
return { success: true };
|
|
710
|
+
}
|
|
711
|
+
|
|
324
712
|
const connection = await getConnection();
|
|
325
713
|
|
|
326
714
|
await connection.deleteMailbox(path);
|
|
327
715
|
|
|
328
|
-
this.log.info(
|
|
716
|
+
this.log.info(
|
|
717
|
+
{ accountId, mailboxId, intent: "delete", path },
|
|
718
|
+
"Deleted mailbox on IMAP server",
|
|
719
|
+
);
|
|
329
720
|
|
|
330
|
-
|
|
331
|
-
await this.mailboxService.delete(accountId, mailboxId);
|
|
721
|
+
await this.settleDelete(accountId, mailboxId);
|
|
332
722
|
|
|
333
723
|
return { success: true };
|
|
334
724
|
};
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Whether the delete this event was enqueued for is still the folder's live
|
|
728
|
+
* intent (D10). Anything else resolves the job with no IMAP call — never a
|
|
729
|
+
* throw, which would hold back every later job in the account's FIFO group
|
|
730
|
+
* (#339).
|
|
731
|
+
*/
|
|
732
|
+
private deleteIntentStanding = async (
|
|
733
|
+
accountId: string,
|
|
734
|
+
mailboxId: string,
|
|
735
|
+
): Promise<boolean> => {
|
|
736
|
+
const row = await this.mailboxService
|
|
737
|
+
.get(accountId, mailboxId)
|
|
738
|
+
.catch((error: unknown) => {
|
|
739
|
+
if (isNotFoundError(error)) return undefined;
|
|
740
|
+
throw error;
|
|
741
|
+
});
|
|
742
|
+
if (row?.syncStatus === MailboxSyncStatus.deleting) return true;
|
|
743
|
+
this.log.info(
|
|
744
|
+
{
|
|
745
|
+
accountId,
|
|
746
|
+
mailboxId,
|
|
747
|
+
intent: "delete",
|
|
748
|
+
from: row?.syncStatus,
|
|
749
|
+
outcome: row ? "superseded" : "already-settled",
|
|
750
|
+
},
|
|
751
|
+
"Skipping MAILBOX_DELETE: the recorded intent has moved on",
|
|
752
|
+
);
|
|
753
|
+
return false;
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* T8: the confirmed delete takes the folder's local mail with it (D8).
|
|
758
|
+
* Removing the row alone leaves every `message` and `thread_message` row
|
|
759
|
+
* keyed to a dead mailboxId — out of every reader, and still in the search
|
|
760
|
+
* index, because nothing writes the `message.removed` outbox event.
|
|
761
|
+
*
|
|
762
|
+
* The removal is batched and the mailbox row goes last, so the row stays
|
|
763
|
+
* `deleting` throughout and an SQS redelivery re-enters the handler, passes
|
|
764
|
+
* the guard above, and resumes where it stopped.
|
|
765
|
+
*/
|
|
766
|
+
settleDelete = async (
|
|
767
|
+
accountId: string,
|
|
768
|
+
mailboxId: string,
|
|
769
|
+
): Promise<void> => {
|
|
770
|
+
await this.mailboxService.deleteMailboxWithMail(accountId, mailboxId);
|
|
771
|
+
this.log.info(
|
|
772
|
+
{
|
|
773
|
+
accountId,
|
|
774
|
+
mailboxId,
|
|
775
|
+
intent: "delete",
|
|
776
|
+
from: MailboxSyncStatus.deleting,
|
|
777
|
+
outcome: "settled",
|
|
778
|
+
},
|
|
779
|
+
"Removed folder and its mail",
|
|
780
|
+
);
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* T9: the server refused the delete. Nothing is unwound — DELETE is
|
|
785
|
+
* all-or-nothing, so `fullPath` is still what the server holds, and no rename
|
|
786
|
+
* target is written, which is how the client tells a failed delete from a
|
|
787
|
+
* failed rename.
|
|
788
|
+
*/
|
|
789
|
+
failDelete = async (accountId: string, mailboxId: string): Promise<void> => {
|
|
790
|
+
const failed = await this.mailboxService.transition(accountId, mailboxId, {
|
|
791
|
+
from: [MailboxSyncStatus.deleting],
|
|
792
|
+
to: MailboxSyncStatus.failed,
|
|
793
|
+
});
|
|
794
|
+
if (!failed) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
795
|
+
this.log.info(
|
|
796
|
+
{
|
|
797
|
+
accountId,
|
|
798
|
+
mailboxId,
|
|
799
|
+
intent: "delete",
|
|
800
|
+
from: MailboxSyncStatus.deleting,
|
|
801
|
+
to: MailboxSyncStatus.failed,
|
|
802
|
+
outcome: "refused",
|
|
803
|
+
},
|
|
804
|
+
"Recorded a refused delete",
|
|
805
|
+
);
|
|
806
|
+
};
|
|
335
807
|
}
|