@remit/imap-worker 0.0.74 → 0.0.75
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
CHANGED
|
@@ -29,10 +29,24 @@ interface Connection {
|
|
|
29
29
|
messages: { total: number };
|
|
30
30
|
}>;
|
|
31
31
|
closeBox: () => Promise<void>;
|
|
32
|
-
renameMailbox: (
|
|
32
|
+
renameMailbox: (
|
|
33
|
+
oldPath: string,
|
|
34
|
+
newPath: string,
|
|
35
|
+
) => Promise<{ path: string; newPath: string }>;
|
|
33
36
|
deleteMailbox: (path: string) => Promise<void>;
|
|
34
37
|
}
|
|
35
38
|
|
|
39
|
+
/** The folder rows the handler reads its guards and its settle row-set from. */
|
|
40
|
+
interface Row {
|
|
41
|
+
mailboxId: string;
|
|
42
|
+
accountId: string;
|
|
43
|
+
fullPath: string;
|
|
44
|
+
hierarchyDelimiter: string;
|
|
45
|
+
namespacePrefix: string;
|
|
46
|
+
syncStatus: string;
|
|
47
|
+
pendingPath?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
36
50
|
interface Harness {
|
|
37
51
|
calls: Call[];
|
|
38
52
|
account: {
|
|
@@ -41,6 +55,7 @@ interface Harness {
|
|
|
41
55
|
deletedAt?: number;
|
|
42
56
|
} | null;
|
|
43
57
|
connection: Connection;
|
|
58
|
+
rows: Map<string, Row>;
|
|
44
59
|
mailboxUpdateError?: Error;
|
|
45
60
|
mailboxRowGone?: boolean;
|
|
46
61
|
disconnectCount: number;
|
|
@@ -48,11 +63,6 @@ interface Harness {
|
|
|
48
63
|
|
|
49
64
|
let h: Harness;
|
|
50
65
|
|
|
51
|
-
const notFoundError = (): Error =>
|
|
52
|
-
Object.assign(new Error("Mailbox not found: mbx-1"), {
|
|
53
|
-
name: "NotFoundError",
|
|
54
|
-
});
|
|
55
|
-
|
|
56
66
|
const record =
|
|
57
67
|
(method: string) =>
|
|
58
68
|
async (...args: unknown[]) => {
|
|
@@ -72,14 +82,37 @@ const buildConnection = (): Connection => ({
|
|
|
72
82
|
messages: { total: 3 },
|
|
73
83
|
}),
|
|
74
84
|
closeBox: record("connection.closeBox"),
|
|
75
|
-
renameMailbox:
|
|
85
|
+
renameMailbox: async (oldPath: string, newPath: string) => {
|
|
86
|
+
h.calls.push({
|
|
87
|
+
method: "connection.renameMailbox",
|
|
88
|
+
args: [oldPath, newPath],
|
|
89
|
+
});
|
|
90
|
+
return { path: oldPath, newPath };
|
|
91
|
+
},
|
|
76
92
|
deleteMailbox: record("connection.deleteMailbox"),
|
|
77
93
|
});
|
|
78
94
|
|
|
95
|
+
const row = (over: Partial<Row> & { mailboxId: string }): Row => ({
|
|
96
|
+
accountId: "acc-1",
|
|
97
|
+
fullPath: "Archive",
|
|
98
|
+
hierarchyDelimiter: "/",
|
|
99
|
+
namespacePrefix: "",
|
|
100
|
+
syncStatus: "pending",
|
|
101
|
+
...over,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/** The server holding neither path is what confirms the folder is gone. */
|
|
105
|
+
const listsNeitherPath = (): void => {
|
|
106
|
+
h.connection.listMailboxes = async () => [{ fullPath: "Iets anders" }];
|
|
107
|
+
};
|
|
108
|
+
|
|
79
109
|
const fresh = (): Harness => ({
|
|
80
110
|
calls: [],
|
|
81
111
|
account: { accountId: "acc-1", accountConfigId: "cfg-1" },
|
|
82
112
|
connection: buildConnection(),
|
|
113
|
+
// A create in flight: `pending` with no recorded rename target, which is
|
|
114
|
+
// what tells it apart from a rename in flight (D3).
|
|
115
|
+
rows: new Map([["mbx-1", row({ mailboxId: "mbx-1" })]]),
|
|
83
116
|
disconnectCount: 0,
|
|
84
117
|
});
|
|
85
118
|
|
|
@@ -92,23 +125,80 @@ const deps = (): MailboxManagementDeps =>
|
|
|
92
125
|
return h.account;
|
|
93
126
|
},
|
|
94
127
|
},
|
|
128
|
+
accountSetting: {
|
|
129
|
+
get: async () => undefined,
|
|
130
|
+
upsert: record("accountSetting.upsert"),
|
|
131
|
+
delete: record("accountSetting.delete"),
|
|
132
|
+
},
|
|
95
133
|
mailbox: {
|
|
134
|
+
get: async (accountId: string, mailboxId: string) => {
|
|
135
|
+
h.calls.push({ method: "mailbox.get", args: [accountId, mailboxId] });
|
|
136
|
+
const found = h.mailboxRowGone
|
|
137
|
+
? undefined
|
|
138
|
+
: h.rows.get(mailboxId as string);
|
|
139
|
+
if (!found) {
|
|
140
|
+
throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
|
|
141
|
+
name: "NotFoundError",
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return found;
|
|
145
|
+
},
|
|
96
146
|
update: async (...args: unknown[]) => {
|
|
97
147
|
h.calls.push({ method: "mailbox.update", args });
|
|
98
148
|
if (h.mailboxUpdateError) throw h.mailboxUpdateError;
|
|
99
149
|
},
|
|
100
|
-
// The conditional write the folder state now goes through
|
|
101
|
-
//
|
|
102
|
-
//
|
|
150
|
+
// The conditional write the folder state now goes through, with the
|
|
151
|
+
// predicate honoured: a row that is gone or that somebody else moved
|
|
152
|
+
// matches nothing, so the loser gets a null rather than a throw and
|
|
153
|
+
// the handler raises the NotFoundError itself.
|
|
103
154
|
transition: async (...args: unknown[]) => {
|
|
104
155
|
h.calls.push({ method: "mailbox.transition", args });
|
|
105
|
-
|
|
156
|
+
if (h.mailboxRowGone) return null;
|
|
157
|
+
const mailboxId = args[1] as string;
|
|
158
|
+
const intent = args[2] as {
|
|
159
|
+
from: readonly string[];
|
|
160
|
+
wherePendingPath?: string | null;
|
|
161
|
+
to: string;
|
|
162
|
+
set?: { fullPath?: string };
|
|
163
|
+
};
|
|
164
|
+
const current = h.rows.get(mailboxId);
|
|
165
|
+
if (!current) return null;
|
|
166
|
+
if (!intent.from.includes(current.syncStatus)) return null;
|
|
167
|
+
if (
|
|
168
|
+
intent.wherePendingPath !== undefined &&
|
|
169
|
+
(intent.wherePendingPath ?? undefined) !== current.pendingPath
|
|
170
|
+
) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
const next: Row = {
|
|
174
|
+
...current,
|
|
175
|
+
...(intent.set?.fullPath !== undefined
|
|
176
|
+
? { fullPath: intent.set.fullPath }
|
|
177
|
+
: {}),
|
|
178
|
+
syncStatus: intent.to,
|
|
179
|
+
};
|
|
180
|
+
if (intent.to !== "pending" && intent.to !== "failed") {
|
|
181
|
+
next.pendingPath = undefined;
|
|
182
|
+
}
|
|
183
|
+
h.rows.set(mailboxId, next);
|
|
184
|
+
return next;
|
|
106
185
|
},
|
|
107
186
|
findByPathPrefix: async (...args: unknown[]) => {
|
|
108
187
|
h.calls.push({ method: "mailbox.findByPathPrefix", args });
|
|
109
188
|
return [];
|
|
110
189
|
},
|
|
190
|
+
findBySyncStatus: async (accountId: string, syncStatus: string) => {
|
|
191
|
+
h.calls.push({
|
|
192
|
+
method: "mailbox.findBySyncStatus",
|
|
193
|
+
args: [accountId, syncStatus],
|
|
194
|
+
});
|
|
195
|
+
if (h.mailboxRowGone) return [];
|
|
196
|
+
return [...h.rows.values()].filter(
|
|
197
|
+
(r) => r.syncStatus === syncStatus,
|
|
198
|
+
);
|
|
199
|
+
},
|
|
111
200
|
delete: record("mailbox.delete"),
|
|
201
|
+
deleteMailboxWithMail: record("mailbox.deleteMailboxWithMail"),
|
|
112
202
|
},
|
|
113
203
|
secrets: {},
|
|
114
204
|
}),
|
|
@@ -155,10 +245,15 @@ const called = (method: string): Call[] =>
|
|
|
155
245
|
const lastUpdate = (): Record<string, unknown> =>
|
|
156
246
|
(called("mailbox.update").at(-1)?.args[2] ?? {}) as Record<string, unknown>;
|
|
157
247
|
|
|
158
|
-
const lastSettle = (): {
|
|
248
|
+
const lastSettle = (): {
|
|
249
|
+
to?: string;
|
|
250
|
+
set?: Record<string, unknown>;
|
|
251
|
+
wherePendingPath?: string | null;
|
|
252
|
+
} =>
|
|
159
253
|
(called("mailbox.transition").at(-1)?.args[2] ?? {}) as {
|
|
160
254
|
to?: string;
|
|
161
255
|
set?: Record<string, unknown>;
|
|
256
|
+
wherePendingPath?: string | null;
|
|
162
257
|
};
|
|
163
258
|
|
|
164
259
|
describe("processMailboxManagement — MAILBOX_CREATE", () => {
|
|
@@ -257,12 +352,24 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
|
|
|
257
352
|
});
|
|
258
353
|
});
|
|
259
354
|
|
|
355
|
+
/**
|
|
356
|
+
* The rename intent a MAILBOX_RENAME is settled against: the folder keeps the
|
|
357
|
+
* path the server holds, and carries the target the intent recorded (D2).
|
|
358
|
+
*/
|
|
359
|
+
const recordRenameIntent = (pendingPath = "Archive 2024"): void => {
|
|
360
|
+
h.rows.set(
|
|
361
|
+
"mbx-1",
|
|
362
|
+
row({ mailboxId: "mbx-1", fullPath: "Archive", pendingPath }),
|
|
363
|
+
);
|
|
364
|
+
};
|
|
365
|
+
|
|
260
366
|
describe("processMailboxManagement — MAILBOX_RENAME", () => {
|
|
261
367
|
beforeEach(() => {
|
|
262
368
|
h = fresh();
|
|
369
|
+
recordRenameIntent();
|
|
263
370
|
});
|
|
264
371
|
|
|
265
|
-
it("renames on the server and settles the row", async () => {
|
|
372
|
+
it("renames on the server and settles the row onto the confirmed path", async () => {
|
|
266
373
|
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
267
374
|
|
|
268
375
|
assert.deepEqual(called("connection.renameMailbox")[0]?.args, [
|
|
@@ -270,31 +377,92 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
|
|
|
270
377
|
"Archive 2024",
|
|
271
378
|
]);
|
|
272
379
|
assert.equal(lastSettle().to, "synced");
|
|
380
|
+
assert.equal(lastSettle().set?.fullPath, "Archive 2024");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("settles onto the path ImapFlow resolved, not the one that was asked for", async () => {
|
|
384
|
+
h.connection.renameMailbox = async (oldPath: string) => {
|
|
385
|
+
h.calls.push({ method: "connection.renameMailbox", args: [oldPath] });
|
|
386
|
+
return { path: oldPath, newPath: "INBOX/Archive 2024" };
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
390
|
+
|
|
391
|
+
assert.equal(lastSettle().set?.fullPath, "INBOX/Archive 2024");
|
|
273
392
|
});
|
|
274
393
|
|
|
275
|
-
it("
|
|
394
|
+
it("takes the folder's mail with it when the source is gone on the server", async () => {
|
|
395
|
+
// The folder was deleted under us. Dropping the row on its own is the
|
|
396
|
+
// orphaning bug: its mail stays keyed to a dead mailboxId, out of every
|
|
397
|
+
// reader and still in the search index (D8).
|
|
398
|
+
listsNeitherPath();
|
|
276
399
|
h.connection.renameMailbox = async () => {
|
|
277
400
|
throw new Error("Mailbox not found");
|
|
278
401
|
};
|
|
279
402
|
|
|
280
403
|
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
281
404
|
|
|
282
|
-
assert.deepEqual(called("mailbox.
|
|
283
|
-
|
|
405
|
+
assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
|
|
406
|
+
"acc-1",
|
|
407
|
+
"mbx-1",
|
|
408
|
+
]);
|
|
409
|
+
assert.equal(called("mailbox.delete").length, 0);
|
|
284
410
|
});
|
|
285
411
|
|
|
286
|
-
it("
|
|
412
|
+
it("takes every descendant's mail with it too, not just the renamed folder's", async () => {
|
|
413
|
+
// The whole subtree recorded this rename's intent (D6); dropping the
|
|
414
|
+
// root alone strands each descendant `pending` at a path the server
|
|
415
|
+
// never held, with nothing left to revisit it.
|
|
416
|
+
h.rows.set(
|
|
417
|
+
"mbx-2",
|
|
418
|
+
row({
|
|
419
|
+
mailboxId: "mbx-2",
|
|
420
|
+
fullPath: "Archive/Sub",
|
|
421
|
+
pendingPath: "Archive 2024/Sub",
|
|
422
|
+
}),
|
|
423
|
+
);
|
|
424
|
+
listsNeitherPath();
|
|
287
425
|
h.connection.renameMailbox = async () => {
|
|
288
|
-
throw new Error("
|
|
426
|
+
throw new Error("Mailbox not found");
|
|
289
427
|
};
|
|
428
|
+
|
|
429
|
+
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
430
|
+
|
|
431
|
+
const deleted = called("mailbox.deleteMailboxWithMail").map(
|
|
432
|
+
(c) => c.args[1],
|
|
433
|
+
);
|
|
434
|
+
assert.deepEqual(new Set(deleted), new Set(["mbx-1", "mbx-2"]));
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("refuses the rename rather than deleting when the source is still on the server", async () => {
|
|
438
|
+
// The server said NONEXISTENT and its own listing still holds the folder,
|
|
439
|
+
// so it was talking about something else. Reading that as an upstream
|
|
440
|
+
// delete would destroy the folder's mail over an answer nothing confirms.
|
|
441
|
+
h.connection.renameMailbox = async () => {
|
|
442
|
+
throw new Error("Mailbox not found");
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
await assert.rejects(
|
|
446
|
+
processMailboxManagement(renameEvent, noopLogger, deps()),
|
|
447
|
+
/Mailbox not found/,
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
|
|
451
|
+
assert.equal(lastSettle().to, "failed");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("acks terminally without connecting when the folder row is gone", async () => {
|
|
290
455
|
h.mailboxRowGone = true;
|
|
291
456
|
|
|
292
457
|
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
293
458
|
|
|
459
|
+
assert.equal(called("connection.renameMailbox").length, 0);
|
|
294
460
|
assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
|
|
295
461
|
});
|
|
296
462
|
|
|
297
|
-
it("
|
|
463
|
+
it("keeps the confirmed path and the target on a refused rename", async () => {
|
|
464
|
+
// T6. `fullPath` was never written, so there is nothing to restore; the
|
|
465
|
+
// target is kept so the client can name what the rename was aiming at.
|
|
298
466
|
h.connection.renameMailbox = async () => {
|
|
299
467
|
throw new Error("server exploded");
|
|
300
468
|
};
|
|
@@ -305,7 +473,126 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
|
|
|
305
473
|
);
|
|
306
474
|
|
|
307
475
|
assert.equal(lastSettle().to, "failed");
|
|
308
|
-
assert.equal(
|
|
476
|
+
assert.equal(
|
|
477
|
+
lastSettle().set?.fullPath,
|
|
478
|
+
undefined,
|
|
479
|
+
"nothing is restored: the row never left the path the server holds",
|
|
480
|
+
);
|
|
481
|
+
assert.equal(lastSettle().wherePendingPath, "Archive 2024");
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("settles instead of deleting when NONEXISTENT means the rename already landed", async () => {
|
|
485
|
+
// A redelivery after a lost settle re-issues RENAME from a path that has
|
|
486
|
+
// moved. Reading that as an upstream delete drops the folder and its mail
|
|
487
|
+
// while the folder is alive at the target.
|
|
488
|
+
h.connection.renameMailbox = async () => {
|
|
489
|
+
throw Object.assign(new Error("Command failed"), {
|
|
490
|
+
serverResponseCode: "NONEXISTENT",
|
|
491
|
+
responseText: "Mailbox doesn't exist: Archive",
|
|
492
|
+
});
|
|
493
|
+
};
|
|
494
|
+
h.connection.listMailboxes = async () => [{ fullPath: "Archive 2024" }];
|
|
495
|
+
|
|
496
|
+
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
497
|
+
|
|
498
|
+
assert.equal(called("mailbox.deleteMailboxWithMail").length, 0);
|
|
499
|
+
assert.equal(lastSettle().to, "synced");
|
|
500
|
+
assert.equal(lastSettle().set?.fullPath, "Archive 2024");
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("rethrows a settle failure without marking the rename refused", async () => {
|
|
504
|
+
// The server executed the rename. Marking the rows failed would offer a
|
|
505
|
+
// retry of work already done, on a path the server no longer holds.
|
|
506
|
+
let settles = 0;
|
|
507
|
+
const broken = deps();
|
|
508
|
+
const inner = broken.getClient as unknown as () => Promise<
|
|
509
|
+
Record<string, unknown>
|
|
510
|
+
>;
|
|
511
|
+
broken.getClient = (async () => {
|
|
512
|
+
const client = await inner();
|
|
513
|
+
return {
|
|
514
|
+
...client,
|
|
515
|
+
mailbox: {
|
|
516
|
+
...(client.mailbox as Record<string, unknown>),
|
|
517
|
+
transition: async () => {
|
|
518
|
+
settles += 1;
|
|
519
|
+
throw new Error("database is locked");
|
|
520
|
+
},
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
}) as unknown as MailboxManagementDeps["getClient"];
|
|
524
|
+
|
|
525
|
+
await assert.rejects(
|
|
526
|
+
processMailboxManagement(renameEvent, noopLogger, broken),
|
|
527
|
+
/local settle did not finish/,
|
|
528
|
+
);
|
|
529
|
+
assert.equal(settles, 1, "the refusal path never ran a second write");
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("issues no RENAME when the row has already settled", async () => {
|
|
533
|
+
h.rows.set(
|
|
534
|
+
"mbx-1",
|
|
535
|
+
row({
|
|
536
|
+
mailboxId: "mbx-1",
|
|
537
|
+
fullPath: "Archive 2024",
|
|
538
|
+
syncStatus: "synced",
|
|
539
|
+
}),
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
543
|
+
|
|
544
|
+
assert.equal(called("connection.renameMailbox").length, 0);
|
|
545
|
+
assert.equal(called("mailbox.transition").length, 0);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("issues no RENAME when the row is pending for a different target", async () => {
|
|
549
|
+
h.rows.set("mbx-1", row({ mailboxId: "mbx-1", pendingPath: "Archief" }));
|
|
550
|
+
|
|
551
|
+
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
552
|
+
|
|
553
|
+
assert.equal(called("connection.renameMailbox").length, 0);
|
|
554
|
+
assert.equal(called("mailbox.transition").length, 0);
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
describe("processMailboxManagement — the seventh state (#363, D3)", () => {
|
|
559
|
+
beforeEach(() => {
|
|
560
|
+
h = fresh();
|
|
561
|
+
recordRenameIntent();
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* A settled create whose acknowledgement was lost, redelivered against a row
|
|
566
|
+
* this rename has since claimed. A `pending`-only guard passes it; CREATE
|
|
567
|
+
* collides and ImapFlow reports `{created: false}`, which #346 correctly
|
|
568
|
+
* reads as success; the settle then writes `synced` with the rename target
|
|
569
|
+
* still on the row — and the rename's own settle no longer matches, so the
|
|
570
|
+
* RENAME never runs and nothing is ever marked failed.
|
|
571
|
+
*/
|
|
572
|
+
it("lets a redelivered create settle nothing on a row a rename has claimed", async () => {
|
|
573
|
+
await processMailboxManagement(createEvent, noopLogger, deps());
|
|
574
|
+
|
|
575
|
+
const settle = called("mailbox.transition").at(-1)?.args[2] as {
|
|
576
|
+
wherePendingPath?: string | null;
|
|
577
|
+
to?: string;
|
|
578
|
+
};
|
|
579
|
+
assert.equal(
|
|
580
|
+
settle?.wherePendingPath,
|
|
581
|
+
null,
|
|
582
|
+
"the create settle requires the row to carry no rename target",
|
|
583
|
+
);
|
|
584
|
+
assert.equal(h.rows.get("mbx-1")?.pendingPath, "Archive 2024");
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it("then settles the rename normally", async () => {
|
|
588
|
+
await processMailboxManagement(createEvent, noopLogger, deps());
|
|
589
|
+
h.calls = [];
|
|
590
|
+
|
|
591
|
+
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
592
|
+
|
|
593
|
+
assert.equal(called("connection.renameMailbox").length, 1);
|
|
594
|
+
assert.equal(lastSettle().to, "synced");
|
|
595
|
+
assert.equal(lastSettle().set?.fullPath, "Archive 2024");
|
|
309
596
|
});
|
|
310
597
|
});
|
|
311
598
|
|
|
@@ -443,7 +730,9 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
443
730
|
assert.equal(lastSettle().to, "failed");
|
|
444
731
|
});
|
|
445
732
|
|
|
446
|
-
it("reads NONEXISTENT on a RENAME as the source folder being gone", async () => {
|
|
733
|
+
it("reads NONEXISTENT on a RENAME as the source folder being gone, mail and all", async () => {
|
|
734
|
+
recordRenameIntent();
|
|
735
|
+
listsNeitherPath();
|
|
447
736
|
h.connection.renameMailbox = async () => {
|
|
448
737
|
throw Object.assign(new Error("Command failed"), {
|
|
449
738
|
serverResponseCode: "NONEXISTENT",
|
|
@@ -454,10 +743,14 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
454
743
|
await assert.doesNotReject(
|
|
455
744
|
processMailboxManagement(renameEvent, noopLogger, deps()),
|
|
456
745
|
);
|
|
457
|
-
assert.deepEqual(called("mailbox.
|
|
746
|
+
assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
|
|
747
|
+
"acc-1",
|
|
748
|
+
"mbx-1",
|
|
749
|
+
]);
|
|
458
750
|
});
|
|
459
751
|
|
|
460
|
-
it("still
|
|
752
|
+
it("still refuses the intent and rethrows when a RENAME fails for any other reason", async () => {
|
|
753
|
+
recordRenameIntent();
|
|
461
754
|
h.connection.renameMailbox = async () => {
|
|
462
755
|
throw Object.assign(new Error("Command failed"), {
|
|
463
756
|
serverResponseCode: "SERVERBUG",
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
|
+
import { refreshFolderAppointmentLabels } from "@remit/backend/folder-role-labels";
|
|
2
3
|
import type { IMailboxRepository } from "@remit/data-ports";
|
|
3
4
|
import { isNotFoundError, NotFoundError } from "@remit/data-ports/errors";
|
|
4
5
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
5
6
|
import type { Logger } from "@remit/logger-lambda";
|
|
6
7
|
import {
|
|
7
8
|
EVERY_MAILBOX_STATE,
|
|
9
|
+
FolderGoneUpstreamError,
|
|
10
|
+
FolderRenameSettleError,
|
|
11
|
+
isMailboxAbsentUpstream,
|
|
12
|
+
isMailboxPresentUpstream,
|
|
8
13
|
MailboxManagementService,
|
|
9
14
|
} from "@remit/mailbox-service";
|
|
10
15
|
import { isAccountDeleted } from "../account-check.js";
|
|
@@ -32,72 +37,50 @@ const defaultDeps: MailboxManagementDeps = {
|
|
|
32
37
|
createConnectionScope: createConnectionScopeWithCredentials,
|
|
33
38
|
};
|
|
34
39
|
|
|
35
|
-
const stringField = (value: unknown, key: string): string => {
|
|
36
|
-
if (!(value instanceof Object)) return "";
|
|
37
|
-
const field = Reflect.get(value, key);
|
|
38
|
-
return typeof field === "string" ? field : "";
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Read a tagged-NO outcome out of an IMAP failure.
|
|
43
|
-
*
|
|
44
|
-
* The two outcomes below are each a folder operation finding the server already
|
|
45
|
-
* in the state it was asked for — the operation having happened, not failing.
|
|
46
|
-
* Reading them as failures marks the row `failed` and rethrows, and since folder
|
|
47
|
-
* management shares the account's per-account FIFO group with mailbox sync, that
|
|
48
|
-
* un-acked rethrow holds back every later sync for the account.
|
|
49
|
-
*
|
|
50
|
-
* Both places the server can say so are read. `message` carries it when the
|
|
51
|
-
* client raises the error itself; RFC 5530's response code and its text carry it
|
|
52
|
-
* when the server does. ImapFlow surfaces a tagged NO as a bare "Command failed"
|
|
53
|
-
* with the code on the error, which is why matching the message alone never
|
|
54
|
-
* caught a real Dovecot answer.
|
|
55
|
-
*/
|
|
56
|
-
const saidByServer = (error: Error): string =>
|
|
57
|
-
`${error.message} ${stringField(error, "responseText")}`;
|
|
58
|
-
|
|
59
|
-
/** The folder is not on the server: a delete has nothing left to do. */
|
|
60
|
-
const isMailboxAbsentUpstream = (error: unknown): boolean => {
|
|
61
|
-
if (!(error instanceof Error)) return false;
|
|
62
|
-
if (stringField(error, "serverResponseCode") === "NONEXISTENT") return true;
|
|
63
|
-
return /not found|does ?n.?t exist/i.test(saidByServer(error));
|
|
64
|
-
};
|
|
65
|
-
|
|
66
40
|
/**
|
|
67
41
|
* Write the outcome of a folder operation back, as the conditional write that
|
|
68
42
|
* is the only way to write a folder's state
|
|
69
43
|
* (docs/architecture/folder-rename-and-delete.md D3).
|
|
70
44
|
*
|
|
71
|
-
* The from-set is every state because that is what
|
|
72
|
-
* against today —
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* deleted the folder mid-sync.
|
|
45
|
+
* The from-set is every state because that is what this write-back decides
|
|
46
|
+
* against today — it is the unconditional write it replaces, moved onto the
|
|
47
|
+
* door. #362 narrows the delete's to the state its intent recorded. A null
|
|
48
|
+
* means the row is gone, which is the NotFoundError the whole-chain guards
|
|
49
|
+
* below classify as the user having deleted the folder mid-sync.
|
|
77
50
|
*/
|
|
78
51
|
const recordOutcome = async (
|
|
79
52
|
mailboxService: Pick<IMailboxRepository, "transition">,
|
|
80
53
|
accountId: string,
|
|
81
54
|
mailboxId: string,
|
|
82
55
|
to: (typeof MailboxSyncStatus)[keyof typeof MailboxSyncStatus],
|
|
83
|
-
confirmedPath?: string,
|
|
84
56
|
): Promise<void> => {
|
|
85
57
|
const written = await mailboxService.transition(accountId, mailboxId, {
|
|
86
58
|
from: EVERY_MAILBOX_STATE,
|
|
87
59
|
to,
|
|
88
|
-
|
|
89
|
-
// name what the rename was aiming at (T6); every other outcome drops it,
|
|
90
|
-
// which the transition does on its own.
|
|
91
|
-
set: confirmedPath !== undefined ? { fullPath: confirmedPath } : {},
|
|
60
|
+
set: {},
|
|
92
61
|
});
|
|
93
62
|
if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
94
63
|
};
|
|
95
64
|
|
|
96
|
-
/**
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
65
|
+
/**
|
|
66
|
+
* The outcome of a create, written against the state a create in flight is:
|
|
67
|
+
* `pending` with no recorded rename target (D3, D10). Without the absence
|
|
68
|
+
* check, a create redelivered after a lost acknowledgement settles a row a
|
|
69
|
+
* rename has since claimed and kills the rename silently.
|
|
70
|
+
*/
|
|
71
|
+
const recordCreateOutcome = async (
|
|
72
|
+
mailboxService: Pick<IMailboxRepository, "transition">,
|
|
73
|
+
accountId: string,
|
|
74
|
+
mailboxId: string,
|
|
75
|
+
to: (typeof MailboxSyncStatus)[keyof typeof MailboxSyncStatus],
|
|
76
|
+
): Promise<void> => {
|
|
77
|
+
const written = await mailboxService.transition(accountId, mailboxId, {
|
|
78
|
+
from: [MailboxSyncStatus.pending],
|
|
79
|
+
wherePendingPath: null,
|
|
80
|
+
to,
|
|
81
|
+
set: {},
|
|
82
|
+
});
|
|
83
|
+
if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
101
84
|
};
|
|
102
85
|
|
|
103
86
|
/**
|
|
@@ -110,12 +93,13 @@ const isMailboxPresentUpstream = (error: unknown): boolean => {
|
|
|
110
93
|
* never an unrelated missing entity that should have been retried.
|
|
111
94
|
*
|
|
112
95
|
* `syncCreate` and `syncDelete` hold to that by touching nothing but the target
|
|
113
|
-
* row. `syncRename` also writes
|
|
114
|
-
* the invariant by
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
96
|
+
* row. `syncRename` also writes every other row that recorded the same intent,
|
|
97
|
+
* and keeps the invariant by treating a lost predicate as an outcome rather than
|
|
98
|
+
* an error: a descendant deleted mid-settle is skipped, never raised. The
|
|
99
|
+
* appointment-label refresh writes `account_setting` rows, whose reads answer
|
|
100
|
+
* absent rather than throwing. Any sync method that reads or writes a second
|
|
101
|
+
* entity owes the same, or these catches must be narrowed (match the mailboxId /
|
|
102
|
+
* re-check existence) before a NotFoundError from elsewhere is silently acked.
|
|
119
103
|
*/
|
|
120
104
|
|
|
121
105
|
/**
|
|
@@ -192,14 +176,14 @@ const handleCreate = async (
|
|
|
192
176
|
{ accountId, mailboxId, path },
|
|
193
177
|
"Mailbox already exists, marking as synced",
|
|
194
178
|
);
|
|
195
|
-
await
|
|
179
|
+
await recordCreateOutcome(
|
|
196
180
|
mailboxService,
|
|
197
181
|
accountId,
|
|
198
182
|
mailboxId,
|
|
199
183
|
MailboxSyncStatus.synced,
|
|
200
184
|
);
|
|
201
185
|
} else {
|
|
202
|
-
await
|
|
186
|
+
await recordCreateOutcome(
|
|
203
187
|
mailboxService,
|
|
204
188
|
accountId,
|
|
205
189
|
mailboxId,
|
|
@@ -246,6 +230,7 @@ const handleRename = async (
|
|
|
246
230
|
|
|
247
231
|
const {
|
|
248
232
|
account: accountService,
|
|
233
|
+
accountSetting: accountSettingService,
|
|
249
234
|
mailbox: mailboxService,
|
|
250
235
|
secrets,
|
|
251
236
|
} = await getClient();
|
|
@@ -286,38 +271,91 @@ const handleRename = async (
|
|
|
286
271
|
newPath,
|
|
287
272
|
scope.getConnection,
|
|
288
273
|
)
|
|
289
|
-
.then((result) => {
|
|
290
|
-
if (result.success) {
|
|
291
|
-
log.info(
|
|
292
|
-
{ accountId, mailboxId, oldPath, newPath },
|
|
293
|
-
"Mailbox renamed on IMAP",
|
|
294
|
-
);
|
|
295
|
-
} else {
|
|
274
|
+
.then(async (result) => {
|
|
275
|
+
if (!result.success) {
|
|
296
276
|
log.error(
|
|
297
277
|
{ accountId, mailboxId, oldPath, newPath, error: result.error },
|
|
298
278
|
"Failed to rename mailbox on IMAP",
|
|
299
279
|
);
|
|
280
|
+
return;
|
|
300
281
|
}
|
|
282
|
+
log.info(
|
|
283
|
+
{ accountId, mailboxId, oldPath, newPath },
|
|
284
|
+
"Mailbox renamed on IMAP",
|
|
285
|
+
);
|
|
286
|
+
if (!result.renamed) return;
|
|
287
|
+
// The path recorded beside each role appointment (#887) moves with
|
|
288
|
+
// the settle, because under D2 that is the first moment the new
|
|
289
|
+
// path is one the server holds.
|
|
290
|
+
//
|
|
291
|
+
// A label is display-only and this runs after the server rename
|
|
292
|
+
// landed, so letting it fail would take the job down the failure
|
|
293
|
+
// path and mark a completed rename refused. Logged and dropped;
|
|
294
|
+
// the next appointment write records the folder's current path
|
|
295
|
+
// anyway.
|
|
296
|
+
await refreshFolderAppointmentLabels(
|
|
297
|
+
accountSettingService,
|
|
298
|
+
account.accountConfigId,
|
|
299
|
+
accountId,
|
|
300
|
+
{
|
|
301
|
+
mailboxId,
|
|
302
|
+
oldPath: result.renamed.oldPath,
|
|
303
|
+
newPath: result.renamed.newPath,
|
|
304
|
+
},
|
|
305
|
+
result.renamed.delimiter,
|
|
306
|
+
).catch((error: unknown) => {
|
|
307
|
+
log.error(
|
|
308
|
+
{ accountId, mailboxId, oldPath, newPath, error },
|
|
309
|
+
"Renamed folder settled, but its role-appointment labels did not move",
|
|
310
|
+
);
|
|
311
|
+
});
|
|
301
312
|
})
|
|
302
313
|
.catch(async (error) => {
|
|
303
|
-
//
|
|
304
|
-
|
|
314
|
+
// The server executed the rename and only the local settle did not
|
|
315
|
+
// finish. That is not a refused rename: marking the rows `failed`
|
|
316
|
+
// would offer a retry of work already done and leave `fullPath`
|
|
317
|
+
// naming a path the server no longer holds. Rethrow, so the
|
|
318
|
+
// redelivery finishes the settle against an intent still standing.
|
|
319
|
+
if (error instanceof FolderRenameSettleError) {
|
|
320
|
+
log.error(
|
|
321
|
+
{ accountId, mailboxId, oldPath, newPath, error },
|
|
322
|
+
"Rename landed on the server; the settle will finish on redelivery",
|
|
323
|
+
);
|
|
324
|
+
throw error;
|
|
325
|
+
}
|
|
326
|
+
// The folder the rename was to move is confirmed gone: the
|
|
327
|
+
// server refused the RENAME as non-existent and its own listing
|
|
328
|
+
// held neither path. The rename decides that, once, where the
|
|
329
|
+
// evidence is — re-reading the response code here would make
|
|
330
|
+
// every `NONEXISTENT` this code cannot classify destroy the
|
|
331
|
+
// folder's mail. Removing the root row alone is the orphaning
|
|
332
|
+
// bug: every descendant recorded the same intent (D6) and would
|
|
333
|
+
// be left `pending` at a path that does not exist, with its mail
|
|
334
|
+
// stranded out of every reader and still in the search index
|
|
335
|
+
// (D8).
|
|
336
|
+
if (error instanceof FolderGoneUpstreamError) {
|
|
305
337
|
log.info(
|
|
306
|
-
{ accountId, mailboxId, oldPath },
|
|
307
|
-
"Source mailbox not found, deleting local",
|
|
338
|
+
{ accountId, mailboxId, oldPath, intent: "rename" },
|
|
339
|
+
"Source mailbox not found, deleting local folder and its mail",
|
|
308
340
|
);
|
|
309
|
-
await
|
|
310
|
-
} else {
|
|
311
|
-
// Rollback local rename by restoring old path
|
|
312
|
-
await recordOutcome(
|
|
313
|
-
mailboxService,
|
|
341
|
+
await managementService.abandonRenameSubtree(
|
|
314
342
|
accountId,
|
|
315
343
|
mailboxId,
|
|
316
|
-
MailboxSyncStatus.failed,
|
|
317
344
|
oldPath,
|
|
345
|
+
newPath,
|
|
318
346
|
);
|
|
319
|
-
|
|
347
|
+
return;
|
|
320
348
|
}
|
|
349
|
+
// T6. Nothing is restored — `fullPath` was never written, and
|
|
350
|
+
// each intent-carrying row keeps its target so the client can
|
|
351
|
+
// name what the rename was aiming at and offer a retry.
|
|
352
|
+
await managementService.failRename(
|
|
353
|
+
accountId,
|
|
354
|
+
mailboxId,
|
|
355
|
+
oldPath,
|
|
356
|
+
newPath,
|
|
357
|
+
);
|
|
358
|
+
throw error;
|
|
321
359
|
})
|
|
322
360
|
.finally(() => scope.disconnect());
|
|
323
361
|
} catch (error) {
|