@remit/imap-worker 0.0.23 → 0.0.24
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
|
@@ -363,3 +363,109 @@ describe("processMailboxManagement — MAILBOX_DELETE", () => {
|
|
|
363
363
|
assert.equal(h.disconnectCount, 1, "the scope is still disconnected");
|
|
364
364
|
});
|
|
365
365
|
});
|
|
366
|
+
|
|
367
|
+
describe("processMailboxManagement — a tagged NO the server means as success (#339)", () => {
|
|
368
|
+
beforeEach(() => {
|
|
369
|
+
h = fresh();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Dovecot answers `NO [NONEXISTENT] Mailbox doesn't exist`, which ImapFlow
|
|
374
|
+
* raises as a bare "Command failed" carrying the code on the error. Reading
|
|
375
|
+
* only the message treated an already-absent folder as a failure: the row was
|
|
376
|
+
* marked failed and the event left poisoning the account's queue.
|
|
377
|
+
*/
|
|
378
|
+
it("treats it as the delete already having happened, and drops the local row", async () => {
|
|
379
|
+
h.connection.deleteMailbox = async () => {
|
|
380
|
+
throw Object.assign(new Error("Command failed"), {
|
|
381
|
+
serverResponseCode: "NONEXISTENT",
|
|
382
|
+
responseText: "Mailbox doesn't exist: Archive",
|
|
383
|
+
});
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
await assert.doesNotReject(
|
|
387
|
+
processMailboxManagement(deleteEvent, noopLog, deps()),
|
|
388
|
+
);
|
|
389
|
+
assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
|
|
390
|
+
assert.equal(called("mailbox.update").length, 0);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("still marks failed and rethrows when the server fails for any other reason", async () => {
|
|
394
|
+
h.connection.deleteMailbox = async () => {
|
|
395
|
+
throw Object.assign(new Error("Command failed"), {
|
|
396
|
+
serverResponseCode: "SERVERBUG",
|
|
397
|
+
responseText: "Internal error",
|
|
398
|
+
});
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
await assert.rejects(
|
|
402
|
+
processMailboxManagement(deleteEvent, noopLog, deps()),
|
|
403
|
+
/Command failed/,
|
|
404
|
+
);
|
|
405
|
+
assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Dovecot answers `NO [ALREADYEXISTS]` when the folder is already there — a
|
|
410
|
+
* folder another client made, or a redelivered create. That is the create
|
|
411
|
+
* having happened. Reading it as a failure marked the row `failed` and
|
|
412
|
+
* rethrew, holding back every later sync on the account's FIFO group.
|
|
413
|
+
*/
|
|
414
|
+
it("reads ALREADYEXISTS on a CREATE as the folder already being there", async () => {
|
|
415
|
+
h.connection.createMailbox = async () => {
|
|
416
|
+
throw Object.assign(new Error("Command failed"), {
|
|
417
|
+
serverResponseCode: "ALREADYEXISTS",
|
|
418
|
+
responseText: "Mailbox already exists: Archive",
|
|
419
|
+
});
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
await assert.doesNotReject(
|
|
423
|
+
processMailboxManagement(createEvent, noopLog, deps()),
|
|
424
|
+
);
|
|
425
|
+
assert.deepEqual(lastUpdate(), { syncStatus: "synced" });
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("still marks failed and rethrows when a CREATE fails for any other reason", async () => {
|
|
429
|
+
h.connection.createMailbox = async () => {
|
|
430
|
+
throw Object.assign(new Error("Command failed"), {
|
|
431
|
+
serverResponseCode: "SERVERBUG",
|
|
432
|
+
responseText: "Internal error",
|
|
433
|
+
});
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
await assert.rejects(
|
|
437
|
+
processMailboxManagement(createEvent, noopLog, deps()),
|
|
438
|
+
/Command failed/,
|
|
439
|
+
);
|
|
440
|
+
assert.deepEqual(lastUpdate(), { syncStatus: "failed" });
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it("reads NONEXISTENT on a RENAME as the source folder being gone", async () => {
|
|
444
|
+
h.connection.renameMailbox = async () => {
|
|
445
|
+
throw Object.assign(new Error("Command failed"), {
|
|
446
|
+
serverResponseCode: "NONEXISTENT",
|
|
447
|
+
responseText: "Mailbox doesn't exist: Archive",
|
|
448
|
+
});
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
await assert.doesNotReject(
|
|
452
|
+
processMailboxManagement(renameEvent, noopLog, deps()),
|
|
453
|
+
);
|
|
454
|
+
assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("still rolls back and rethrows when a RENAME fails for any other reason", async () => {
|
|
458
|
+
h.connection.renameMailbox = async () => {
|
|
459
|
+
throw Object.assign(new Error("Command failed"), {
|
|
460
|
+
serverResponseCode: "SERVERBUG",
|
|
461
|
+
responseText: "Internal error",
|
|
462
|
+
});
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
await assert.rejects(
|
|
466
|
+
processMailboxManagement(renameEvent, noopLog, deps()),
|
|
467
|
+
/Command failed/,
|
|
468
|
+
);
|
|
469
|
+
assert.equal(lastUpdate()?.syncStatus, "failed");
|
|
470
|
+
});
|
|
471
|
+
});
|
|
@@ -28,6 +28,44 @@ const defaultDeps: MailboxManagementDeps = {
|
|
|
28
28
|
createConnectionScope: createConnectionScopeWithCredentials,
|
|
29
29
|
};
|
|
30
30
|
|
|
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
|
+
/**
|
|
38
|
+
* Read a tagged-NO outcome out of an IMAP failure.
|
|
39
|
+
*
|
|
40
|
+
* The two outcomes below are each a folder operation finding the server already
|
|
41
|
+
* in the state it was asked for — the operation having happened, not failing.
|
|
42
|
+
* Reading them as failures marks the row `failed` and rethrows, and since folder
|
|
43
|
+
* management shares the account's per-account FIFO group with mailbox sync, that
|
|
44
|
+
* un-acked rethrow holds back every later sync for the account.
|
|
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.
|
|
51
|
+
*/
|
|
52
|
+
const saidByServer = (error: Error): string =>
|
|
53
|
+
`${error.message} ${stringField(error, "responseText")}`;
|
|
54
|
+
|
|
55
|
+
/** The folder is not on the server: a delete has nothing left to do. */
|
|
56
|
+
const isMailboxAbsentUpstream = (error: unknown): boolean => {
|
|
57
|
+
if (!(error instanceof Error)) return false;
|
|
58
|
+
if (stringField(error, "serverResponseCode") === "NONEXISTENT") return true;
|
|
59
|
+
return /not found|does ?n.?t exist/i.test(saidByServer(error));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The folder is already on the server: a create has nothing left to do. */
|
|
63
|
+
const isMailboxPresentUpstream = (error: unknown): boolean => {
|
|
64
|
+
if (!(error instanceof Error)) return false;
|
|
65
|
+
if (stringField(error, "serverResponseCode") === "ALREADYEXISTS") return true;
|
|
66
|
+
return /already exists/i.test(saidByServer(error));
|
|
67
|
+
};
|
|
68
|
+
|
|
31
69
|
/**
|
|
32
70
|
* Pinned invariant for the whole-chain terminal guards below.
|
|
33
71
|
*
|
|
@@ -112,10 +150,7 @@ const handleCreate = async (
|
|
|
112
150
|
})
|
|
113
151
|
.catch(async (error) => {
|
|
114
152
|
// Check if mailbox already exists (idempotent)
|
|
115
|
-
if (
|
|
116
|
-
error instanceof Error &&
|
|
117
|
-
error.message.includes("already exists")
|
|
118
|
-
) {
|
|
153
|
+
if (isMailboxPresentUpstream(error)) {
|
|
119
154
|
log.info(
|
|
120
155
|
{ accountId, mailboxId, path },
|
|
121
156
|
"Mailbox already exists, marking as synced",
|
|
@@ -223,7 +258,7 @@ const handleRename = async (
|
|
|
223
258
|
})
|
|
224
259
|
.catch(async (error) => {
|
|
225
260
|
// If source not found, delete local mailbox
|
|
226
|
-
if (error
|
|
261
|
+
if (isMailboxAbsentUpstream(error)) {
|
|
227
262
|
log.info(
|
|
228
263
|
{ accountId, mailboxId, oldPath },
|
|
229
264
|
"Source mailbox not found, deleting local",
|
|
@@ -321,7 +356,7 @@ const handleDelete = async (
|
|
|
321
356
|
})
|
|
322
357
|
.catch(async (error) => {
|
|
323
358
|
// If mailbox not found, it's already deleted (idempotent)
|
|
324
|
-
if (error
|
|
359
|
+
if (isMailboxAbsentUpstream(error)) {
|
|
325
360
|
log.info(
|
|
326
361
|
{ accountId, mailboxId, path },
|
|
327
362
|
"Mailbox not found on IMAP, deleting local",
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
BodySyncService,
|
|
6
6
|
guardConnectionCursor,
|
|
7
7
|
isCursorRebuildNeeded,
|
|
8
|
+
isFolderOffServer,
|
|
8
9
|
MailboxCursorPausedError,
|
|
9
10
|
PlacementMoveService,
|
|
10
11
|
QuarantineService,
|
|
@@ -197,10 +198,13 @@ export const syncMessageBody = async (
|
|
|
197
198
|
if (isNotFoundError(error)) return null;
|
|
198
199
|
throw error;
|
|
199
200
|
});
|
|
200
|
-
|
|
201
|
+
// A folder still `pending` is terminal for the same reason: the batch
|
|
202
|
+
// was cut before the folder reached the server, so there is nothing
|
|
203
|
+
// there to fetch from and no retry that changes it.
|
|
204
|
+
if (!mailbox || isFolderOffServer(mailbox)) {
|
|
201
205
|
log.warn(
|
|
202
206
|
{ accountId, mailboxId, eventId: event.eventId },
|
|
203
|
-
"Skipping SYNC_MESSAGE_BODY:
|
|
207
|
+
"Skipping SYNC_MESSAGE_BODY: the server does not hold this folder",
|
|
204
208
|
);
|
|
205
209
|
return;
|
|
206
210
|
}
|
|
@@ -319,7 +319,9 @@ describe("syncMessages — terminal handling of events for a deleted mailbox (is
|
|
|
319
319
|
0,
|
|
320
320
|
"a deleted mailbox must short-circuit before the OAuth/connection lifecycle",
|
|
321
321
|
);
|
|
322
|
-
const skip = warns.find((w) =>
|
|
322
|
+
const skip = warns.find((w) =>
|
|
323
|
+
w.msg.includes("the server does not hold this folder"),
|
|
324
|
+
);
|
|
323
325
|
assert.ok(skip, "expected a WARN naming the skipped deleted mailbox");
|
|
324
326
|
assert.equal(skip.fields.accountId, "acc-1");
|
|
325
327
|
assert.equal(skip.fields.mailboxId, "mbx-gone");
|
|
@@ -352,6 +354,30 @@ describe("syncMessages — terminal handling of events for a deleted mailbox (is
|
|
|
352
354
|
assert.equal(harness.lifecycleCalls, 1);
|
|
353
355
|
});
|
|
354
356
|
|
|
357
|
+
for (const syncStatus of ["pending", "deleting"]) {
|
|
358
|
+
it(`acks a SYNC_MESSAGES event for a \`${syncStatus}\` mailbox — the row exists, the server folder does not`, async () => {
|
|
359
|
+
const { log, warns } = buildLogger();
|
|
360
|
+
const harness = buildSyncDeps({
|
|
361
|
+
mailboxGet: async () => ({
|
|
362
|
+
mailboxId: "mbx-unsettled",
|
|
363
|
+
fullPath: "Archive",
|
|
364
|
+
syncStatus,
|
|
365
|
+
}),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
await assert.doesNotReject(
|
|
369
|
+
syncMessages(syncEvent("mbx-unsettled"), log, harness.deps),
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
assert.equal(harness.lifecycleCalls, 0);
|
|
373
|
+
assert.ok(
|
|
374
|
+
warns.find((w) =>
|
|
375
|
+
w.msg.includes("the server does not hold this folder"),
|
|
376
|
+
),
|
|
377
|
+
);
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
355
381
|
it("a deleted-mailbox event does not stall the group — a following live-mailbox event still processes", async () => {
|
|
356
382
|
const { log } = buildLogger();
|
|
357
383
|
const gone = buildSyncDeps({
|
|
@@ -370,3 +396,151 @@ describe("syncMessages — terminal handling of events for a deleted mailbox (is
|
|
|
370
396
|
assert.equal(live.lifecycleCalls, 1);
|
|
371
397
|
});
|
|
372
398
|
});
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* A deps factory that runs the lifecycle and the mailbox lock for real, so the
|
|
402
|
+
* sync body — and the failure handling around it — is what gets exercised.
|
|
403
|
+
* `mailboxGet` is called per lookup, which is how a delete landing mid-round is
|
|
404
|
+
* expressed: the guard's read succeeds, a later one does not.
|
|
405
|
+
*/
|
|
406
|
+
const buildRunningSyncDeps = (
|
|
407
|
+
mailboxGet: (call: number) => Promise<unknown>,
|
|
408
|
+
): {
|
|
409
|
+
deps: SyncMessagesDeps;
|
|
410
|
+
accountUpdates: Array<Record<string, unknown>>;
|
|
411
|
+
} => {
|
|
412
|
+
const accountUpdates: Array<Record<string, unknown>> = [];
|
|
413
|
+
let calls = 0;
|
|
414
|
+
const deps = {
|
|
415
|
+
getClient: async () => ({
|
|
416
|
+
account: {
|
|
417
|
+
get: async () => liveAccount,
|
|
418
|
+
update: async (_id: string, input: Record<string, unknown>) => {
|
|
419
|
+
accountUpdates.push(input);
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
mailbox: {
|
|
423
|
+
get: async () => {
|
|
424
|
+
calls += 1;
|
|
425
|
+
return mailboxGet(calls);
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
mailboxLock: {
|
|
429
|
+
withMailboxLock: async (
|
|
430
|
+
_mailboxId: string,
|
|
431
|
+
_operation: string,
|
|
432
|
+
_accountId: string,
|
|
433
|
+
run: () => Promise<void>,
|
|
434
|
+
) => {
|
|
435
|
+
await run();
|
|
436
|
+
return { executed: true };
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
flagPush: { listByMailboxId: async () => [] },
|
|
440
|
+
secrets: {},
|
|
441
|
+
}),
|
|
442
|
+
buildLifecycleDeps: () => ({}),
|
|
443
|
+
withOAuthLifecycle: async (
|
|
444
|
+
_lifecycleDeps: unknown,
|
|
445
|
+
_account: AccountItem,
|
|
446
|
+
_log: Logger,
|
|
447
|
+
run: (credentials: unknown) => Promise<void>,
|
|
448
|
+
) => {
|
|
449
|
+
await run({ type: "password", password: "pw" });
|
|
450
|
+
},
|
|
451
|
+
} as unknown as SyncMessagesDeps;
|
|
452
|
+
return { deps, accountUpdates };
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
describe("syncMessages — a delete that lands mid-round (issue #339)", () => {
|
|
456
|
+
it("resolves terminally when the mailbox went away while the sync was in flight, and records no error phase", async () => {
|
|
457
|
+
const { log, warns } = buildLogger();
|
|
458
|
+
const harness = buildRunningSyncDeps(async (call) => {
|
|
459
|
+
if (call === 1) return { mailboxId: "mbx-1", fullPath: "Archive" };
|
|
460
|
+
throw new NotFoundError("Mailbox not found: mbx-1");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
await assert.doesNotReject(
|
|
464
|
+
syncMessages(syncEvent("mbx-1"), log, harness.deps),
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
const resolved = warns.find((w) =>
|
|
468
|
+
w.msg.includes("left the server while the sync was in flight"),
|
|
469
|
+
);
|
|
470
|
+
assert.ok(
|
|
471
|
+
resolved,
|
|
472
|
+
"expected a WARN naming the folder that left mid-round",
|
|
473
|
+
);
|
|
474
|
+
assert.equal(resolved.fields.mailboxId, "mbx-1");
|
|
475
|
+
assert.deepEqual(
|
|
476
|
+
harness.accountUpdates,
|
|
477
|
+
[],
|
|
478
|
+
"one folder leaving must not put the account into an error phase",
|
|
479
|
+
);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it("resolves terminally when the failure is on the server and the row is only marked deleting", async () => {
|
|
483
|
+
const { log, warns } = buildLogger();
|
|
484
|
+
const harness = buildRunningSyncDeps(async (call) => {
|
|
485
|
+
if (call === 1) return { mailboxId: "mbx-1", fullPath: "Archive" };
|
|
486
|
+
if (call === 2) throw new Error("IMAP SEARCH failed in mailbox null");
|
|
487
|
+
return {
|
|
488
|
+
mailboxId: "mbx-1",
|
|
489
|
+
fullPath: "Archive",
|
|
490
|
+
syncStatus: "deleting",
|
|
491
|
+
};
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
await assert.doesNotReject(
|
|
495
|
+
syncMessages(syncEvent("mbx-1"), log, harness.deps),
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
assert.ok(
|
|
499
|
+
warns.find((w) =>
|
|
500
|
+
w.msg.includes("left the server while the sync was in flight"),
|
|
501
|
+
),
|
|
502
|
+
);
|
|
503
|
+
assert.deepEqual(harness.accountUpdates, []);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* The classifying read must not become the reported failure. A throttled or
|
|
508
|
+
* unreachable repository during the catch previously propagated in place of
|
|
509
|
+
* the real IMAP error, and the `syncPhase: error` write below it never ran —
|
|
510
|
+
* the let-it-crash contract lost both the diagnosis and the state.
|
|
511
|
+
*/
|
|
512
|
+
it("reports the real failure, and still records the error phase, when the classifying read itself fails", async () => {
|
|
513
|
+
const { log } = buildLogger();
|
|
514
|
+
const harness = buildRunningSyncDeps(async (call) => {
|
|
515
|
+
if (call === 1) return { mailboxId: "mbx-1", fullPath: "Archive" };
|
|
516
|
+
if (call === 2) throw new Error("IMAP SEARCH failed in mailbox null");
|
|
517
|
+
throw new Error("dynamodb throttled");
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
await assert.rejects(
|
|
521
|
+
syncMessages(syncEvent("mbx-1"), log, harness.deps),
|
|
522
|
+
/IMAP SEARCH failed in mailbox null/,
|
|
523
|
+
);
|
|
524
|
+
assert.equal(harness.accountUpdates.length, 1);
|
|
525
|
+
assert.equal(harness.accountUpdates[0]?.syncPhase, "error");
|
|
526
|
+
assert.match(
|
|
527
|
+
String(harness.accountUpdates[0]?.lastError),
|
|
528
|
+
/IMAP SEARCH failed in mailbox null/,
|
|
529
|
+
);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it("still fails loudly when the mailbox is live — the failure is the account's, not a deletion", async () => {
|
|
533
|
+
const { log } = buildLogger();
|
|
534
|
+
const harness = buildRunningSyncDeps(async (call) => {
|
|
535
|
+
if (call === 2) throw new Error("connection reset by peer");
|
|
536
|
+
return { mailboxId: "mbx-1", fullPath: "Archive" };
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
await assert.rejects(
|
|
540
|
+
syncMessages(syncEvent("mbx-1"), log, harness.deps),
|
|
541
|
+
/connection reset by peer/,
|
|
542
|
+
);
|
|
543
|
+
assert.equal(harness.accountUpdates.length, 1);
|
|
544
|
+
assert.equal(harness.accountUpdates[0]?.syncPhase, "error");
|
|
545
|
+
});
|
|
546
|
+
});
|
|
@@ -18,6 +18,7 @@ import { type Logger, MetricUnit, metrics } from "@remit/logger-lambda";
|
|
|
18
18
|
import { RefreshTokenError } from "@remit/mail-oauth-service";
|
|
19
19
|
import {
|
|
20
20
|
createManagedConnectionFactory,
|
|
21
|
+
isMailboxNotOnServer,
|
|
21
22
|
MailConnectionError,
|
|
22
23
|
type MailCredentials,
|
|
23
24
|
MessageSyncService,
|
|
@@ -132,23 +133,18 @@ export const syncMessages = async (
|
|
|
132
133
|
return;
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
// A SYNC_MESSAGES trigger can
|
|
136
|
-
//
|
|
137
|
-
// next-batch
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
// the
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
.catch((err) => {
|
|
148
|
-
if (isNotFoundError(err)) return false;
|
|
149
|
-
throw err;
|
|
150
|
-
});
|
|
151
|
-
if (!mailboxExists) {
|
|
136
|
+
// A SYNC_MESSAGES trigger can address a folder the server does not hold: the
|
|
137
|
+
// fan-out enqueues one for a folder whose own create has not landed yet, and
|
|
138
|
+
// a `hasMore` next-batch or a periodic tick outlives a folder the user
|
|
139
|
+
// deleted. Such an event can never succeed on retry, and the account's
|
|
140
|
+
// per-group FIFO ordering lets that one poison message stall the
|
|
141
|
+
// whole account's message pipeline for the full visibility window (issue
|
|
142
|
+
// #287, #339). A folder the server does not hold — not yet created, or being
|
|
143
|
+
// deleted — is an expected terminal outcome, not an infra failure: ack the
|
|
144
|
+
// event with a WARN.
|
|
145
|
+
if (
|
|
146
|
+
await isMailboxNotOnServer(mailboxService, event.accountId, event.mailboxId)
|
|
147
|
+
) {
|
|
152
148
|
log.warn(
|
|
153
149
|
{
|
|
154
150
|
accountId: event.accountId,
|
|
@@ -156,7 +152,7 @@ export const syncMessages = async (
|
|
|
156
152
|
eventId: event.eventId,
|
|
157
153
|
event: event.type,
|
|
158
154
|
},
|
|
159
|
-
"Skipping SYNC_MESSAGES:
|
|
155
|
+
"Skipping SYNC_MESSAGES: the server does not hold this folder",
|
|
160
156
|
);
|
|
161
157
|
return;
|
|
162
158
|
}
|
|
@@ -203,6 +199,39 @@ export const syncMessages = async (
|
|
|
203
199
|
) {
|
|
204
200
|
throw err;
|
|
205
201
|
}
|
|
202
|
+
// The guard above is a check-then-act: a delete asked for while
|
|
203
|
+
// this round was in flight lands inside it, and the round then
|
|
204
|
+
// fails somewhere deeper — on the row (NotFoundError from the
|
|
205
|
+
// service's own re-read) or on the server (the SELECTed folder
|
|
206
|
+
// vanished, so the next command runs against no mailbox). Both are
|
|
207
|
+
// the same expected outcome as an event that arrived after the
|
|
208
|
+
// delete, so they resolve the same way rather than head-of-line
|
|
209
|
+
// blocking the account's FIFO group (issue #339). Recording an
|
|
210
|
+
// error phase is skipped too: the account is healthy, one of its
|
|
211
|
+
// folders is not there.
|
|
212
|
+
//
|
|
213
|
+
// This read only classifies the failure already in hand, so it must
|
|
214
|
+
// never become the failure that is reported. A read that cannot answer
|
|
215
|
+
// leaves the round on the loud path below, with the real error and the
|
|
216
|
+
// state write that goes with it intact.
|
|
217
|
+
const folderIsGone = await isMailboxNotOnServer(
|
|
218
|
+
mailboxService,
|
|
219
|
+
event.accountId,
|
|
220
|
+
event.mailboxId,
|
|
221
|
+
).catch(() => false);
|
|
222
|
+
if (folderIsGone) {
|
|
223
|
+
log.warn(
|
|
224
|
+
{
|
|
225
|
+
accountId: event.accountId,
|
|
226
|
+
mailboxId: event.mailboxId,
|
|
227
|
+
eventId: event.eventId,
|
|
228
|
+
event: event.type,
|
|
229
|
+
error: err instanceof Error ? err.message : String(err),
|
|
230
|
+
},
|
|
231
|
+
"Resolving SYNC_MESSAGES: the folder left the server while the sync was in flight",
|
|
232
|
+
);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
206
235
|
// Record the terminal error phase before crashing (let-it-crash:
|
|
207
236
|
// record state, then rethrow so the event is retried/DLQ'd).
|
|
208
237
|
const message = err instanceof Error ? err.message : String(err);
|