@remit/imap-worker 0.0.73 → 0.0.74
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
|
@@ -42,6 +42,7 @@ interface Harness {
|
|
|
42
42
|
} | null;
|
|
43
43
|
connection: Connection;
|
|
44
44
|
mailboxUpdateError?: Error;
|
|
45
|
+
mailboxRowGone?: boolean;
|
|
45
46
|
disconnectCount: number;
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -96,6 +97,13 @@ const deps = (): MailboxManagementDeps =>
|
|
|
96
97
|
h.calls.push({ method: "mailbox.update", args });
|
|
97
98
|
if (h.mailboxUpdateError) throw h.mailboxUpdateError;
|
|
98
99
|
},
|
|
100
|
+
// The conditional write the folder state now goes through: a row
|
|
101
|
+
// that is gone matches nothing, so the loser gets a null rather
|
|
102
|
+
// than a throw and the handler raises the NotFoundError itself.
|
|
103
|
+
transition: async (...args: unknown[]) => {
|
|
104
|
+
h.calls.push({ method: "mailbox.transition", args });
|
|
105
|
+
return h.mailboxRowGone ? null : {};
|
|
106
|
+
},
|
|
99
107
|
findByPathPrefix: async (...args: unknown[]) => {
|
|
100
108
|
h.calls.push({ method: "mailbox.findByPathPrefix", args });
|
|
101
109
|
return [];
|
|
@@ -147,6 +155,12 @@ const called = (method: string): Call[] =>
|
|
|
147
155
|
const lastUpdate = (): Record<string, unknown> =>
|
|
148
156
|
(called("mailbox.update").at(-1)?.args[2] ?? {}) as Record<string, unknown>;
|
|
149
157
|
|
|
158
|
+
const lastSettle = (): { to?: string; set?: Record<string, unknown> } =>
|
|
159
|
+
(called("mailbox.transition").at(-1)?.args[2] ?? {}) as {
|
|
160
|
+
to?: string;
|
|
161
|
+
set?: Record<string, unknown>;
|
|
162
|
+
};
|
|
163
|
+
|
|
150
164
|
describe("processMailboxManagement — MAILBOX_CREATE", () => {
|
|
151
165
|
beforeEach(() => {
|
|
152
166
|
h = fresh();
|
|
@@ -160,8 +174,8 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
|
|
|
160
174
|
uidValidity: 7,
|
|
161
175
|
uidNext: 42,
|
|
162
176
|
messageCount: 3,
|
|
163
|
-
syncStatus: "synced",
|
|
164
177
|
});
|
|
178
|
+
assert.equal(lastSettle().to, "synced");
|
|
165
179
|
assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
|
|
166
180
|
});
|
|
167
181
|
|
|
@@ -183,7 +197,8 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
|
|
|
183
197
|
|
|
184
198
|
await processMailboxManagement(createEvent, noopLogger, deps());
|
|
185
199
|
|
|
186
|
-
assert.
|
|
200
|
+
assert.equal(called("mailbox.update").length, 0);
|
|
201
|
+
assert.equal(lastSettle().to, "synced");
|
|
187
202
|
});
|
|
188
203
|
|
|
189
204
|
it("treats an already-existing folder as success rather than a failure", async () => {
|
|
@@ -193,7 +208,7 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
|
|
|
193
208
|
|
|
194
209
|
await processMailboxManagement(createEvent, noopLogger, deps());
|
|
195
210
|
|
|
196
|
-
assert.
|
|
211
|
+
assert.equal(lastSettle().to, "synced");
|
|
197
212
|
});
|
|
198
213
|
|
|
199
214
|
it("marks the mailbox failed and rethrows on any other create error", async () => {
|
|
@@ -206,14 +221,14 @@ describe("processMailboxManagement — MAILBOX_CREATE", () => {
|
|
|
206
221
|
/server exploded/,
|
|
207
222
|
);
|
|
208
223
|
|
|
209
|
-
assert.
|
|
224
|
+
assert.equal(lastSettle().to, "failed");
|
|
210
225
|
assert.equal(h.disconnectCount, 1);
|
|
211
226
|
});
|
|
212
227
|
|
|
213
228
|
it("acks terminally without rethrowing when the mailbox row was deleted mid-create (#289)", async () => {
|
|
214
|
-
// The
|
|
215
|
-
//
|
|
216
|
-
h.
|
|
229
|
+
// The settle matches no row because the row is gone; the create is moot
|
|
230
|
+
// and must not poison the account's FIFO.
|
|
231
|
+
h.mailboxRowGone = true;
|
|
217
232
|
|
|
218
233
|
await processMailboxManagement(createEvent, noopLogger, deps());
|
|
219
234
|
|
|
@@ -247,17 +262,14 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
|
|
|
247
262
|
h = fresh();
|
|
248
263
|
});
|
|
249
264
|
|
|
250
|
-
it("renames on the server and
|
|
265
|
+
it("renames on the server and settles the row", async () => {
|
|
251
266
|
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
252
267
|
|
|
253
268
|
assert.deepEqual(called("connection.renameMailbox")[0]?.args, [
|
|
254
269
|
"Archive",
|
|
255
270
|
"Archive 2024",
|
|
256
271
|
]);
|
|
257
|
-
assert.
|
|
258
|
-
oldPath: undefined,
|
|
259
|
-
syncStatus: "synced",
|
|
260
|
-
});
|
|
272
|
+
assert.equal(lastSettle().to, "synced");
|
|
261
273
|
});
|
|
262
274
|
|
|
263
275
|
it("drops the local row when the source folder is gone on the server", async () => {
|
|
@@ -268,14 +280,14 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
|
|
|
268
280
|
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
269
281
|
|
|
270
282
|
assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
|
|
271
|
-
assert.equal(called("mailbox.
|
|
283
|
+
assert.equal(called("mailbox.transition").length, 0);
|
|
272
284
|
});
|
|
273
285
|
|
|
274
286
|
it("acks terminally without rethrowing when the rollback write finds the row gone", async () => {
|
|
275
287
|
h.connection.renameMailbox = async () => {
|
|
276
288
|
throw new Error("server exploded");
|
|
277
289
|
};
|
|
278
|
-
h.
|
|
290
|
+
h.mailboxRowGone = true;
|
|
279
291
|
|
|
280
292
|
await processMailboxManagement(renameEvent, noopLogger, deps());
|
|
281
293
|
|
|
@@ -292,11 +304,8 @@ describe("processMailboxManagement — MAILBOX_RENAME", () => {
|
|
|
292
304
|
/server exploded/,
|
|
293
305
|
);
|
|
294
306
|
|
|
295
|
-
assert.
|
|
296
|
-
|
|
297
|
-
oldPath: undefined,
|
|
298
|
-
syncStatus: "failed",
|
|
299
|
-
});
|
|
307
|
+
assert.equal(lastSettle().to, "failed");
|
|
308
|
+
assert.equal(lastSettle().set?.fullPath, "Archive");
|
|
300
309
|
});
|
|
301
310
|
});
|
|
302
311
|
|
|
@@ -329,7 +338,7 @@ describe("processMailboxManagement — MAILBOX_DELETE", () => {
|
|
|
329
338
|
|
|
330
339
|
await processMailboxManagement(deleteEvent, noopLogger, deps());
|
|
331
340
|
|
|
332
|
-
assert.
|
|
341
|
+
assert.equal(lastSettle().to, "synced");
|
|
333
342
|
assert.equal(called("mailbox.delete").length, 0);
|
|
334
343
|
});
|
|
335
344
|
|
|
@@ -343,14 +352,14 @@ describe("processMailboxManagement — MAILBOX_DELETE", () => {
|
|
|
343
352
|
/server exploded/,
|
|
344
353
|
);
|
|
345
354
|
|
|
346
|
-
assert.
|
|
355
|
+
assert.equal(lastSettle().to, "failed");
|
|
347
356
|
});
|
|
348
357
|
|
|
349
358
|
it("acks terminally without rethrowing when the rollback write finds the row gone", async () => {
|
|
350
359
|
h.connection.deleteMailbox = async () => {
|
|
351
360
|
throw new Error("server exploded");
|
|
352
361
|
};
|
|
353
|
-
h.
|
|
362
|
+
h.mailboxRowGone = true;
|
|
354
363
|
|
|
355
364
|
await processMailboxManagement(deleteEvent, noopLogger, deps());
|
|
356
365
|
|
|
@@ -381,7 +390,7 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
381
390
|
processMailboxManagement(deleteEvent, noopLogger, deps()),
|
|
382
391
|
);
|
|
383
392
|
assert.deepEqual(called("mailbox.delete")[0]?.args, ["acc-1", "mbx-1"]);
|
|
384
|
-
assert.equal(called("mailbox.
|
|
393
|
+
assert.equal(called("mailbox.transition").length, 0);
|
|
385
394
|
});
|
|
386
395
|
|
|
387
396
|
it("still marks failed and rethrows when the server fails for any other reason", async () => {
|
|
@@ -396,7 +405,7 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
396
405
|
processMailboxManagement(deleteEvent, noopLogger, deps()),
|
|
397
406
|
/Command failed/,
|
|
398
407
|
);
|
|
399
|
-
assert.
|
|
408
|
+
assert.equal(lastSettle().to, "failed");
|
|
400
409
|
});
|
|
401
410
|
|
|
402
411
|
/**
|
|
@@ -416,7 +425,7 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
416
425
|
await assert.doesNotReject(
|
|
417
426
|
processMailboxManagement(createEvent, noopLogger, deps()),
|
|
418
427
|
);
|
|
419
|
-
assert.
|
|
428
|
+
assert.equal(lastSettle().to, "synced");
|
|
420
429
|
});
|
|
421
430
|
|
|
422
431
|
it("still marks failed and rethrows when a CREATE fails for any other reason", async () => {
|
|
@@ -431,7 +440,7 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
431
440
|
processMailboxManagement(createEvent, noopLogger, deps()),
|
|
432
441
|
/Command failed/,
|
|
433
442
|
);
|
|
434
|
-
assert.
|
|
443
|
+
assert.equal(lastSettle().to, "failed");
|
|
435
444
|
});
|
|
436
445
|
|
|
437
446
|
it("reads NONEXISTENT on a RENAME as the source folder being gone", async () => {
|
|
@@ -460,6 +469,6 @@ describe("processMailboxManagement — a tagged NO the server means as success (
|
|
|
460
469
|
processMailboxManagement(renameEvent, noopLogger, deps()),
|
|
461
470
|
/Command failed/,
|
|
462
471
|
);
|
|
463
|
-
assert.equal(
|
|
472
|
+
assert.equal(lastSettle().to, "failed");
|
|
464
473
|
});
|
|
465
474
|
});
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
|
-
import {
|
|
2
|
+
import type { IMailboxRepository } from "@remit/data-ports";
|
|
3
|
+
import { isNotFoundError, NotFoundError } from "@remit/data-ports/errors";
|
|
3
4
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
4
5
|
import type { Logger } from "@remit/logger-lambda";
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
EVERY_MAILBOX_STATE,
|
|
8
|
+
MailboxManagementService,
|
|
9
|
+
} from "@remit/mailbox-service";
|
|
6
10
|
import { isAccountDeleted } from "../account-check.js";
|
|
7
11
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
8
12
|
import type {
|
|
@@ -59,6 +63,36 @@ const isMailboxAbsentUpstream = (error: unknown): boolean => {
|
|
|
59
63
|
return /not found|does ?n.?t exist/i.test(saidByServer(error));
|
|
60
64
|
};
|
|
61
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Write the outcome of a folder operation back, as the conditional write that
|
|
68
|
+
* is the only way to write a folder's state
|
|
69
|
+
* (docs/architecture/folder-rename-and-delete.md D3).
|
|
70
|
+
*
|
|
71
|
+
* The from-set is every state because that is what these write-backs decide
|
|
72
|
+
* against today — they are the unconditional writes they replace, moved onto
|
|
73
|
+
* the door. #363 narrows each to the state its intent recorded, and gives a
|
|
74
|
+
* lost predicate its `superseded` outcome. A null means the row is gone, which
|
|
75
|
+
* is the NotFoundError the whole-chain guards below classify as the user having
|
|
76
|
+
* deleted the folder mid-sync.
|
|
77
|
+
*/
|
|
78
|
+
const recordOutcome = async (
|
|
79
|
+
mailboxService: Pick<IMailboxRepository, "transition">,
|
|
80
|
+
accountId: string,
|
|
81
|
+
mailboxId: string,
|
|
82
|
+
to: (typeof MailboxSyncStatus)[keyof typeof MailboxSyncStatus],
|
|
83
|
+
confirmedPath?: string,
|
|
84
|
+
): Promise<void> => {
|
|
85
|
+
const written = await mailboxService.transition(accountId, mailboxId, {
|
|
86
|
+
from: EVERY_MAILBOX_STATE,
|
|
87
|
+
to,
|
|
88
|
+
// `failed` keeps whatever rename target the row carries, so the UI can
|
|
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 } : {},
|
|
92
|
+
});
|
|
93
|
+
if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
|
|
94
|
+
};
|
|
95
|
+
|
|
62
96
|
/** The folder is already on the server: a create has nothing left to do. */
|
|
63
97
|
const isMailboxPresentUpstream = (error: unknown): boolean => {
|
|
64
98
|
if (!(error instanceof Error)) return false;
|
|
@@ -158,13 +192,19 @@ const handleCreate = async (
|
|
|
158
192
|
{ accountId, mailboxId, path },
|
|
159
193
|
"Mailbox already exists, marking as synced",
|
|
160
194
|
);
|
|
161
|
-
await
|
|
162
|
-
|
|
163
|
-
|
|
195
|
+
await recordOutcome(
|
|
196
|
+
mailboxService,
|
|
197
|
+
accountId,
|
|
198
|
+
mailboxId,
|
|
199
|
+
MailboxSyncStatus.synced,
|
|
200
|
+
);
|
|
164
201
|
} else {
|
|
165
|
-
await
|
|
166
|
-
|
|
167
|
-
|
|
202
|
+
await recordOutcome(
|
|
203
|
+
mailboxService,
|
|
204
|
+
accountId,
|
|
205
|
+
mailboxId,
|
|
206
|
+
MailboxSyncStatus.failed,
|
|
207
|
+
);
|
|
168
208
|
throw error;
|
|
169
209
|
}
|
|
170
210
|
})
|
|
@@ -269,11 +309,13 @@ const handleRename = async (
|
|
|
269
309
|
await mailboxService.delete(accountId, mailboxId);
|
|
270
310
|
} else {
|
|
271
311
|
// Rollback local rename by restoring old path
|
|
272
|
-
await
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
312
|
+
await recordOutcome(
|
|
313
|
+
mailboxService,
|
|
314
|
+
accountId,
|
|
315
|
+
mailboxId,
|
|
316
|
+
MailboxSyncStatus.failed,
|
|
317
|
+
oldPath,
|
|
318
|
+
);
|
|
277
319
|
throw error;
|
|
278
320
|
}
|
|
279
321
|
})
|
|
@@ -370,9 +412,12 @@ const handleDelete = async (
|
|
|
370
412
|
error.message.includes("Cannot delete INBOX")
|
|
371
413
|
) {
|
|
372
414
|
// Restore the mailbox
|
|
373
|
-
await
|
|
374
|
-
|
|
375
|
-
|
|
415
|
+
await recordOutcome(
|
|
416
|
+
mailboxService,
|
|
417
|
+
accountId,
|
|
418
|
+
mailboxId,
|
|
419
|
+
MailboxSyncStatus.synced,
|
|
420
|
+
);
|
|
376
421
|
log.error(
|
|
377
422
|
{ accountId, mailboxId, path },
|
|
378
423
|
"Cannot delete INBOX, restoring mailbox",
|
|
@@ -380,9 +425,12 @@ const handleDelete = async (
|
|
|
380
425
|
// Don't rethrow - this is an expected error
|
|
381
426
|
} else {
|
|
382
427
|
// Restore the mailbox on other errors
|
|
383
|
-
await
|
|
384
|
-
|
|
385
|
-
|
|
428
|
+
await recordOutcome(
|
|
429
|
+
mailboxService,
|
|
430
|
+
accountId,
|
|
431
|
+
mailboxId,
|
|
432
|
+
MailboxSyncStatus.failed,
|
|
433
|
+
);
|
|
386
434
|
throw error;
|
|
387
435
|
}
|
|
388
436
|
})
|
|
@@ -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,
|