@remit/imap-worker 0.0.73 → 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,17 +55,14 @@ interface Harness {
|
|
|
41
55
|
deletedAt?: number;
|
|
42
56
|
} | null;
|
|
43
57
|
connection: Connection;
|
|
58
|
+
rows: Map<string, Row>;
|
|
44
59
|
mailboxUpdateError?: Error;
|
|
60
|
+
mailboxRowGone?: boolean;
|
|
45
61
|
disconnectCount: number;
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
let h: Harness;
|
|
49
65
|
|
|
50
|
-
const notFoundError = (): Error =>
|
|
51
|
-
Object.assign(new Error("Mailbox not found: mbx-1"), {
|
|
52
|
-
name: "NotFoundError",
|
|
53
|
-
});
|
|
54
|
-
|
|
55
66
|
const record =
|
|
56
67
|
(method: string) =>
|
|
57
68
|
async (...args: unknown[]) => {
|
|
@@ -71,14 +82,37 @@ const buildConnection = (): Connection => ({
|
|
|
71
82
|
messages: { total: 3 },
|
|
72
83
|
}),
|
|
73
84
|
closeBox: record("connection.closeBox"),
|
|
74
|
-
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
|
+
},
|
|
75
92
|
deleteMailbox: record("connection.deleteMailbox"),
|
|
76
93
|
});
|
|
77
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
|
+
|
|
78
109
|
const fresh = (): Harness => ({
|
|
79
110
|
calls: [],
|
|
80
111
|
account: { accountId: "acc-1", accountConfigId: "cfg-1" },
|
|
81
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" })]]),
|
|
82
116
|
disconnectCount: 0,
|
|
83
117
|
});
|
|
84
118
|
|
|
@@ -91,16 +125,80 @@ const deps = (): MailboxManagementDeps =>
|
|
|
91
125
|
return h.account;
|
|
92
126
|
},
|
|
93
127
|
},
|
|
128
|
+
accountSetting: {
|
|
129
|
+
get: async () => undefined,
|
|
130
|
+
upsert: record("accountSetting.upsert"),
|
|
131
|
+
delete: record("accountSetting.delete"),
|
|
132
|
+
},
|
|
94
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
|
+
},
|
|
95
146
|
update: async (...args: unknown[]) => {
|
|
96
147
|
h.calls.push({ method: "mailbox.update", args });
|
|
97
148
|
if (h.mailboxUpdateError) throw h.mailboxUpdateError;
|
|
98
149
|
},
|
|
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.
|
|
154
|
+
transition: async (...args: unknown[]) => {
|
|
155
|
+
h.calls.push({ method: "mailbox.transition", args });
|
|
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;
|
|
185
|
+
},
|
|
99
186
|
findByPathPrefix: async (...args: unknown[]) => {
|
|
100
187
|
h.calls.push({ method: "mailbox.findByPathPrefix", args });
|
|
101
188
|
return [];
|
|
102
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
|
+
},
|
|
103
200
|
delete: record("mailbox.delete"),
|
|
201
|
+
deleteMailboxWithMail: record("mailbox.deleteMailboxWithMail"),
|
|
104
202
|
},
|
|
105
203
|
secrets: {},
|
|
106
204
|
}),
|
|
@@ -147,6 +245,17 @@ const called = (method: string): Call[] =>
|
|
|
147
245
|
const lastUpdate = (): Record<string, unknown> =>
|
|
148
246
|
(called("mailbox.update").at(-1)?.args[2] ?? {}) as Record<string, unknown>;
|
|
149
247
|
|
|
248
|
+
const lastSettle = (): {
|
|
249
|
+
to?: string;
|
|
250
|
+
set?: Record<string, unknown>;
|
|
251
|
+
wherePendingPath?: string | null;
|
|
252
|
+
} =>
|
|
253
|
+
(called("mailbox.transition").at(-1)?.args[2] ?? {}) as {
|
|
254
|
+
to?: string;
|
|
255
|
+
set?: Record<string, unknown>;
|
|
256
|
+
wherePendingPath?: string | null;
|
|
257
|
+
};
|
|
258
|
+
|
|
150
259
|
describe("processMailboxManagement — MAILBOX_CREATE", () => {
|
|
151
260
|
beforeEach(() => {
|
|
152
261
|
h = fresh();
|
|
@@ -160,8 +269,8 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
|
|
|
160
269
|
uidValidity: 7,
|
|
161
270
|
uidNext: 42,
|
|
162
271
|
messageCount: 3,
|
|
163
|
-
syncStatus: "synced",
|
|
164
272
|
});
|
|
273
|
+
assert.equal(lastSettle().to, "synced");
|
|
165
274
|
assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
|
|
166
275
|
});
|
|
167
276
|
|
|
@@ -183,7 +292,8 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
|
|
|
183
292
|
|
|
184
293
|
await processMailboxManagement(createEvent, noopLogger, deps());
|
|
185
294
|
|
|
186
|
-
assert.
|
|
295
|
+
assert.equal(called("mailbox.update").length, 0);
|
|
296
|
+
assert.equal(lastSettle().to, "synced");
|
|
187
297
|
});
|
|
188
298
|
|
|
189
299
|
it("treats an already-existing folder as success rather than a failure", async () => {
|
|
@@ -193,7 +303,7 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
|
|
|
193
303
|
|
|
194
304
|
await processMailboxManagement(createEvent, noopLogger, deps());
|
|
195
305
|
|
|
196
|
-
assert.
|
|
306
|
+
assert.equal(lastSettle().to, "synced");
|
|
197
307
|
});
|
|
198
308
|
|
|
199
309
|
it("marks the mailbox failed and rethrows on any other create error", async () => {
|
|
@@ -206,14 +316,14 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
|
|
|
206
316
|
/server exploded/,
|
|
207
317
|
);
|
|
208
318
|
|
|
209
|
-
assert.
|
|
319
|
+
assert.equal(lastSettle().to, "failed");
|
|
210
320
|
assert.equal(h.disconnectCount, 1);
|
|
211
321
|
});
|
|
212
322
|
|
|
213
323
|
it("acks terminally without rethrowing when the mailbox row was deleted mid-create (#289)", async () => {
|
|
214
|
-
// The
|
|
215
|
-
//
|
|
216
|
-
h.
|
|
324
|
+
// The settle matches no row because the row is gone; the create is moot
|
|
325
|
+
// and must not poison the account's FIFO.
|
|
326
|
+
h.mailboxRowGone = true;
|
|
217
327
|
|
|
218
328
|
await processMailboxManagement(createEvent, noopLogger, deps());
|
|
219
329
|
|
|
@@ -242,47 +352,117 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
|
|
|
242
352
|
});
|
|
243
353
|
});
|
|
244
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
|
+
|
|
245
366
|
describe("processMailboxManagement — MAILBOX_RENAME", () => {
|
|
246
367
|
beforeEach(() => {
|
|
247
368
|
h = fresh();
|
|
369
|
+
recordRenameIntent();
|
|
248
370
|
});
|
|
249
371
|
|
|
250
|
-
it("renames on the server and
|
|
372
|
+
it("renames on the server and settles the row onto the confirmed path", async () => {
|
|
251
373
|
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
252
374
|
|
|
253
375
|
assert.deepEqual(called("connection.renameMailbox")[0]?.args, [
|
|
254
376
|
"Archive",
|
|
255
377
|
"Archive 2024",
|
|
256
378
|
]);
|
|
257
|
-
assert.
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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");
|
|
261
392
|
});
|
|
262
393
|
|
|
263
|
-
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();
|
|
264
399
|
h.connection.renameMailbox = async () => {
|
|
265
400
|
throw new Error("Mailbox not found");
|
|
266
401
|
};
|
|
267
402
|
|
|
268
403
|
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
269
404
|
|
|
270
|
-
assert.deepEqual(called("mailbox.
|
|
271
|
-
|
|
405
|
+
assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
|
|
406
|
+
"acc-1",
|
|
407
|
+
"mbx-1",
|
|
408
|
+
]);
|
|
409
|
+
assert.equal(called("mailbox.delete").length, 0);
|
|
272
410
|
});
|
|
273
411
|
|
|
274
|
-
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();
|
|
275
425
|
h.connection.renameMailbox = async () => {
|
|
276
|
-
throw new Error("
|
|
426
|
+
throw new Error("Mailbox not found");
|
|
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");
|
|
277
443
|
};
|
|
278
|
-
|
|
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 () => {
|
|
455
|
+
h.mailboxRowGone = true;
|
|
279
456
|
|
|
280
457
|
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
281
458
|
|
|
459
|
+
assert.equal(called("connection.renameMailbox").length, 0);
|
|
282
460
|
assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
|
|
283
461
|
});
|
|
284
462
|
|
|
285
|
-
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.
|
|
286
466
|
h.connection.renameMailbox = async () => {
|
|
287
467
|
throw new Error("server exploded");
|
|
288
468
|
};
|
|
@@ -292,11 +472,127 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
|
|
|
292
472
|
/server exploded/,
|
|
293
473
|
);
|
|
294
474
|
|
|
295
|
-
assert.
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
475
|
+
assert.equal(lastSettle().to, "failed");
|
|
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");
|
|
300
596
|
});
|
|
301
597
|
});
|
|
302
598
|
|
|
@@ -329,7 +625,7 @@ describe("processMailboxManagement — MAILBOX_DELETE", () => {
|
|
|
329
625
|
|
|
330
626
|
await processMailboxManagement(deleteEvent, noopLogger, deps());
|
|
331
627
|
|
|
332
|
-
assert.
|
|
628
|
+
assert.equal(lastSettle().to, "synced");
|
|
333
629
|
assert.equal(called("mailbox.delete").length, 0);
|
|
334
630
|
});
|
|
335
631
|
|
|
@@ -343,14 +639,14 @@ describe("processMailboxManagement — MAILBOX_DELETE", () => {
|
|
|
343
639
|
/server exploded/,
|
|
344
640
|
);
|
|
345
641
|
|
|
346
|
-
assert.
|
|
642
|
+
assert.equal(lastSettle().to, "failed");
|
|
347
643
|
});
|
|
348
644
|
|
|
349
645
|
it("acks terminally without rethrowing when the rollback write finds the row gone", async () => {
|
|
350
646
|
h.connection.deleteMailbox = async () => {
|
|
351
647
|
throw new Error("server exploded");
|
|
352
648
|
};
|
|
353
|
-
h.
|
|
649
|
+
h.mailboxRowGone = true;
|
|
354
650
|
|
|
355
651
|
await processMailboxManagement(deleteEvent, noopLogger, deps());
|
|
356
652
|
|
|
@@ -381,7 +677,7 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
381
677
|
processMailboxManagement(deleteEvent, noopLogger, deps()),
|
|
382
678
|
);
|
|
383
679
|
assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
|
|
384
|
-
assert.equal(called("mailbox.
|
|
680
|
+
assert.equal(called("mailbox.transition").length, 0);
|
|
385
681
|
});
|
|
386
682
|
|
|
387
683
|
it("still marks failed and rethrows when the server fails for any other reason", async () => {
|
|
@@ -396,7 +692,7 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
396
692
|
processMailboxManagement(deleteEvent, noopLogger, deps()),
|
|
397
693
|
/Command failed/,
|
|
398
694
|
);
|
|
399
|
-
assert.
|
|
695
|
+
assert.equal(lastSettle().to, "failed");
|
|
400
696
|
});
|
|
401
697
|
|
|
402
698
|
/**
|
|
@@ -416,7 +712,7 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
416
712
|
await assert.doesNotReject(
|
|
417
713
|
processMailboxManagement(createEvent, noopLogger, deps()),
|
|
418
714
|
);
|
|
419
|
-
assert.
|
|
715
|
+
assert.equal(lastSettle().to, "synced");
|
|
420
716
|
});
|
|
421
717
|
|
|
422
718
|
it("still marks failed and rethrows when a CREATE fails for any other reason", async () => {
|
|
@@ -431,10 +727,12 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
431
727
|
processMailboxManagement(createEvent, noopLogger, deps()),
|
|
432
728
|
/Command failed/,
|
|
433
729
|
);
|
|
434
|
-
assert.
|
|
730
|
+
assert.equal(lastSettle().to, "failed");
|
|
435
731
|
});
|
|
436
732
|
|
|
437
|
-
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();
|
|
438
736
|
h.connection.renameMailbox = async () => {
|
|
439
737
|
throw Object.assign(new Error("Command failed"), {
|
|
440
738
|
serverResponseCode: "NONEXISTENT",
|
|
@@ -445,10 +743,14 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
445
743
|
await assert.doesNotReject(
|
|
446
744
|
processMailboxManagement(renameEvent, noopLogger, deps()),
|
|
447
745
|
);
|
|
448
|
-
assert.deepEqual(called("mailbox.
|
|
746
|
+
assert.deepEqual(called("mailbox.deleteMailboxWithMail")[0]?.args, [
|
|
747
|
+
"acc-1",
|
|
748
|
+
"mbx-1",
|
|
749
|
+
]);
|
|
449
750
|
});
|
|
450
751
|
|
|
451
|
-
it("still
|
|
752
|
+
it("still refuses the intent and rethrows when a RENAME fails for any other reason", async () => {
|
|
753
|
+
recordRenameIntent();
|
|
452
754
|
h.connection.renameMailbox = async () => {
|
|
453
755
|
throw Object.assign(new Error("Command failed"), {
|
|
454
756
|
serverResponseCode: "SERVERBUG",
|
|
@@ -460,6 +762,6 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
460
762
|
processMailboxManagement(renameEvent, noopLogger, deps()),
|
|
461
763
|
/Command failed/,
|
|
462
764
|
);
|
|
463
|
-
assert.equal(
|
|
765
|
+
assert.equal(lastSettle().to, "failed");
|
|
464
766
|
});
|
|
465
767
|
});
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
|
-
import {
|
|
2
|
+
import { refreshFolderAppointmentLabels } from "@remit/backend/folder-role-labels";
|
|
3
|
+
import type { IMailboxRepository } from "@remit/data-ports";
|
|
4
|
+
import { isNotFoundError, NotFoundError } from "@remit/data-ports/errors";
|
|
3
5
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
4
6
|
import type { Logger } from "@remit/logger-lambda";
|
|
5
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
EVERY_MAILBOX_STATE,
|
|
9
|
+
FolderGoneUpstreamError,
|
|
10
|
+
FolderRenameSettleError,
|
|
11
|
+
isMailboxAbsentUpstream,
|
|
12
|
+
isMailboxPresentUpstream,
|
|
13
|
+
MailboxManagementService,
|
|
14
|
+
} from "@remit/mailbox-service";
|
|
6
15
|
import { isAccountDeleted } from "../account-check.js";
|
|
7
16
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
8
17
|
import type {
|
|
@@ -28,42 +37,50 @@ const defaultDeps: MailboxManagementDeps = {
|
|
|
28
37
|
createConnectionScope: createConnectionScopeWithCredentials,
|
|
29
38
|
};
|
|
30
39
|
|
|
31
|
-
const stringField = (value: unknown, key: string): string => {
|
|
32
|
-
if (!(value instanceof Object)) return "";
|
|
33
|
-
const field = Reflect.get(value, key);
|
|
34
|
-
return typeof field === "string" ? field : "";
|
|
35
|
-
};
|
|
36
|
-
|
|
37
40
|
/**
|
|
38
|
-
*
|
|
41
|
+
* Write the outcome of a folder operation back, as the conditional write that
|
|
42
|
+
* is the only way to write a folder's state
|
|
43
|
+
* (docs/architecture/folder-rename-and-delete.md D3).
|
|
39
44
|
*
|
|
40
|
-
* The
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* Both places the server can say so are read. `message` carries it when the
|
|
47
|
-
* client raises the error itself; RFC 5530's response code and its text carry it
|
|
48
|
-
* when the server does. ImapFlow surfaces a tagged NO as a bare "Command failed"
|
|
49
|
-
* with the code on the error, which is why matching the message alone never
|
|
50
|
-
* caught a real Dovecot answer.
|
|
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.
|
|
51
50
|
*/
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
51
|
+
const recordOutcome = async (
|
|
52
|
+
mailboxService: Pick<IMailboxRepository, "transition">,
|
|
53
|
+
accountId: string,
|
|
54
|
+
mailboxId: string,
|
|
55
|
+
to: (typeof MailboxSyncStatus)[keyof typeof MailboxSyncStatus],
|
|
56
|
+
): Promise<void> => {
|
|
57
|
+
const written = await mailboxService.transition(accountId, mailboxId, {
|
|
58
|
+
from: EVERY_MAILBOX_STATE,
|
|
59
|
+
to,
|
|
60
|
+
set: {},
|
|
61
|
+
});
|
|
62
|
+
if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
60
63
|
};
|
|
61
64
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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}`);
|
|
67
84
|
};
|
|
68
85
|
|
|
69
86
|
/**
|
|
@@ -76,12 +93,13 @@ const isMailboxPresentUpstream = (error: unknown): boolean => {
|
|
|
76
93
|
* never an unrelated missing entity that should have been retried.
|
|
77
94
|
*
|
|
78
95
|
* `syncCreate` and `syncDelete` hold to that by touching nothing but the target
|
|
79
|
-
* row. `syncRename` also writes
|
|
80
|
-
* the invariant by
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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.
|
|
85
103
|
*/
|
|
86
104
|
|
|
87
105
|
/**
|
|
@@ -158,13 +176,19 @@ const handleCreate = async (
|
|
|
158
176
|
{ accountId, mailboxId, path },
|
|
159
177
|
"Mailbox already exists, marking as synced",
|
|
160
178
|
);
|
|
161
|
-
await
|
|
162
|
-
|
|
163
|
-
|
|
179
|
+
await recordCreateOutcome(
|
|
180
|
+
mailboxService,
|
|
181
|
+
accountId,
|
|
182
|
+
mailboxId,
|
|
183
|
+
MailboxSyncStatus.synced,
|
|
184
|
+
);
|
|
164
185
|
} else {
|
|
165
|
-
await
|
|
166
|
-
|
|
167
|
-
|
|
186
|
+
await recordCreateOutcome(
|
|
187
|
+
mailboxService,
|
|
188
|
+
accountId,
|
|
189
|
+
mailboxId,
|
|
190
|
+
MailboxSyncStatus.failed,
|
|
191
|
+
);
|
|
168
192
|
throw error;
|
|
169
193
|
}
|
|
170
194
|
})
|
|
@@ -206,6 +230,7 @@ const handleRename = async (
|
|
|
206
230
|
|
|
207
231
|
const {
|
|
208
232
|
account: accountService,
|
|
233
|
+
accountSetting: accountSettingService,
|
|
209
234
|
mailbox: mailboxService,
|
|
210
235
|
secrets,
|
|
211
236
|
} = await getClient();
|
|
@@ -246,36 +271,91 @@ const handleRename = async (
|
|
|
246
271
|
newPath,
|
|
247
272
|
scope.getConnection,
|
|
248
273
|
)
|
|
249
|
-
.then((result) => {
|
|
250
|
-
if (result.success) {
|
|
251
|
-
log.info(
|
|
252
|
-
{ accountId, mailboxId, oldPath, newPath },
|
|
253
|
-
"Mailbox renamed on IMAP",
|
|
254
|
-
);
|
|
255
|
-
} else {
|
|
274
|
+
.then(async (result) => {
|
|
275
|
+
if (!result.success) {
|
|
256
276
|
log.error(
|
|
257
277
|
{ accountId, mailboxId, oldPath, newPath, error: result.error },
|
|
258
278
|
"Failed to rename mailbox on IMAP",
|
|
259
279
|
);
|
|
280
|
+
return;
|
|
260
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
|
+
});
|
|
261
312
|
})
|
|
262
313
|
.catch(async (error) => {
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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",
|
|
268
323
|
);
|
|
269
|
-
await mailboxService.delete(accountId, mailboxId);
|
|
270
|
-
} else {
|
|
271
|
-
// Rollback local rename by restoring old path
|
|
272
|
-
await mailboxService.update(accountId, mailboxId, {
|
|
273
|
-
fullPath: oldPath,
|
|
274
|
-
oldPath: undefined,
|
|
275
|
-
syncStatus: MailboxSyncStatus.failed,
|
|
276
|
-
});
|
|
277
324
|
throw error;
|
|
278
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) {
|
|
337
|
+
log.info(
|
|
338
|
+
{ accountId, mailboxId, oldPath, intent: "rename" },
|
|
339
|
+
"Source mailbox not found, deleting local folder and its mail",
|
|
340
|
+
);
|
|
341
|
+
await managementService.abandonRenameSubtree(
|
|
342
|
+
accountId,
|
|
343
|
+
mailboxId,
|
|
344
|
+
oldPath,
|
|
345
|
+
newPath,
|
|
346
|
+
);
|
|
347
|
+
return;
|
|
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;
|
|
279
359
|
})
|
|
280
360
|
.finally(() => scope.disconnect());
|
|
281
361
|
} catch (error) {
|
|
@@ -370,9 +450,12 @@ const handleDelete = async (
|
|
|
370
450
|
error.message.includes("Cannot delete INBOX")
|
|
371
451
|
) {
|
|
372
452
|
// Restore the mailbox
|
|
373
|
-
await
|
|
374
|
-
|
|
375
|
-
|
|
453
|
+
await recordOutcome(
|
|
454
|
+
mailboxService,
|
|
455
|
+
accountId,
|
|
456
|
+
mailboxId,
|
|
457
|
+
MailboxSyncStatus.synced,
|
|
458
|
+
);
|
|
376
459
|
log.error(
|
|
377
460
|
{ accountId, mailboxId, path },
|
|
378
461
|
"Cannot delete INBOX, restoring mailbox",
|
|
@@ -380,9 +463,12 @@ const handleDelete = async (
|
|
|
380
463
|
// Don't rethrow - this is an expected error
|
|
381
464
|
} else {
|
|
382
465
|
// Restore the mailbox on other errors
|
|
383
|
-
await
|
|
384
|
-
|
|
385
|
-
|
|
466
|
+
await recordOutcome(
|
|
467
|
+
mailboxService,
|
|
468
|
+
accountId,
|
|
469
|
+
mailboxId,
|
|
470
|
+
MailboxSyncStatus.failed,
|
|
471
|
+
);
|
|
386
472
|
throw error;
|
|
387
473
|
}
|
|
388
474
|
})
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
BodySyncService,
|
|
7
7
|
guardConnectionCursor,
|
|
8
8
|
isCursorRebuildNeeded,
|
|
9
|
-
|
|
9
|
+
isFolderMutationInFlight,
|
|
10
10
|
MailboxCursorPausedError,
|
|
11
11
|
PlacementMoveService,
|
|
12
12
|
QuarantineService,
|
|
@@ -179,10 +179,10 @@ export const syncMessageBody = async (
|
|
|
179
179
|
// A folder still `pending` is terminal for the same reason: the batch
|
|
180
180
|
// was cut before the folder reached the server, so there is nothing
|
|
181
181
|
// there to fetch from and no retry that changes it.
|
|
182
|
-
if (!mailbox ||
|
|
182
|
+
if (!mailbox || isFolderMutationInFlight(mailbox)) {
|
|
183
183
|
log.warn(
|
|
184
184
|
{ accountId, mailboxId, eventId: event.eventId },
|
|
185
|
-
"Skipping SYNC_MESSAGE_BODY:
|
|
185
|
+
"Skipping SYNC_MESSAGE_BODY: a folder mutation is in flight",
|
|
186
186
|
);
|
|
187
187
|
return;
|
|
188
188
|
}
|
|
@@ -320,7 +320,7 @@ describe("syncMessages — terminal handling of events for a deleted mailbox (is
|
|
|
320
320
|
"a deleted mailbox must short-circuit before the OAuth/connection lifecycle",
|
|
321
321
|
);
|
|
322
322
|
const skip = warns.find((w) =>
|
|
323
|
-
w.msg.includes("
|
|
323
|
+
w.msg.includes("a folder mutation is in flight"),
|
|
324
324
|
);
|
|
325
325
|
assert.ok(skip, "expected a WARN naming the skipped deleted mailbox");
|
|
326
326
|
assert.equal(skip.fields.accountId, "acc-1");
|
|
@@ -371,9 +371,7 @@ describe("syncMessages — terminal handling of events for a deleted mailbox (is
|
|
|
371
371
|
|
|
372
372
|
assert.equal(harness.lifecycleCalls, 0);
|
|
373
373
|
assert.ok(
|
|
374
|
-
warns.find((w) =>
|
|
375
|
-
w.msg.includes("the server does not hold this folder"),
|
|
376
|
-
),
|
|
374
|
+
warns.find((w) => w.msg.includes("a folder mutation is in flight")),
|
|
377
375
|
);
|
|
378
376
|
});
|
|
379
377
|
}
|
|
@@ -19,7 +19,7 @@ import { type Logger, recordImapFailure } from "@remit/logger-lambda";
|
|
|
19
19
|
import { RefreshTokenError } from "@remit/mail-oauth-service";
|
|
20
20
|
import {
|
|
21
21
|
createManagedConnectionFactory,
|
|
22
|
-
|
|
22
|
+
isMailboxMutationInFlight,
|
|
23
23
|
MailConnectionError,
|
|
24
24
|
type MailCredentials,
|
|
25
25
|
MessageSyncService,
|
|
@@ -133,17 +133,23 @@ export const syncMessages = async (
|
|
|
133
133
|
return;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
// A SYNC_MESSAGES trigger can address a folder
|
|
137
|
-
// fan-out enqueues one for a folder whose own create has not landed yet,
|
|
138
|
-
//
|
|
139
|
-
// deleted
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
// event with
|
|
136
|
+
// A SYNC_MESSAGES trigger can address a folder a mutation still owns: the
|
|
137
|
+
// fan-out enqueues one for a folder whose own create has not landed yet, a
|
|
138
|
+
// `hasMore` next-batch or a periodic tick outlives a folder the user
|
|
139
|
+
// deleted, and a rename in flight leaves the folder on the server but under
|
|
140
|
+
// the worker that is moving it. Such an event can never succeed on retry,
|
|
141
|
+
// and the account's per-group FIFO ordering lets that one poison message
|
|
142
|
+
// stall the whole account's message pipeline for the full visibility window
|
|
143
|
+
// (issue #287, #339). A folder with a create, rename or delete outstanding
|
|
144
|
+
// is an expected terminal outcome, not an infra failure: ack the event with
|
|
145
|
+
// a WARN. The rename case is conservative rather than necessary — syncing it
|
|
146
|
+
// would be safe — and keeps the guard a single expression (D12).
|
|
145
147
|
if (
|
|
146
|
-
await
|
|
148
|
+
await isMailboxMutationInFlight(
|
|
149
|
+
mailboxService,
|
|
150
|
+
event.accountId,
|
|
151
|
+
event.mailboxId,
|
|
152
|
+
)
|
|
147
153
|
) {
|
|
148
154
|
log.warn(
|
|
149
155
|
{
|
|
@@ -152,7 +158,7 @@ export const syncMessages = async (
|
|
|
152
158
|
eventId: event.eventId,
|
|
153
159
|
event: event.type,
|
|
154
160
|
},
|
|
155
|
-
"Skipping SYNC_MESSAGES:
|
|
161
|
+
"Skipping SYNC_MESSAGES: a folder mutation is in flight",
|
|
156
162
|
);
|
|
157
163
|
return;
|
|
158
164
|
}
|
|
@@ -214,7 +220,7 @@ export const syncMessages = async (
|
|
|
214
220
|
// never become the failure that is reported. A read that cannot answer
|
|
215
221
|
// leaves the round on the loud path below, with the real error and the
|
|
216
222
|
// state write that goes with it intact.
|
|
217
|
-
const folderIsGone = await
|
|
223
|
+
const folderIsGone = await isMailboxMutationInFlight(
|
|
218
224
|
mailboxService,
|
|
219
225
|
event.accountId,
|
|
220
226
|
event.mailboxId,
|