@remit/imap-worker 0.0.72 → 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 +1 -1
- package/src/handlers/append-sent-message.ts +1 -1
- package/src/handlers/empty-trash.test.ts +118 -3
- package/src/handlers/empty-trash.ts +90 -31
- package/src/handlers/flag-push-placement-race.test.ts +18 -4
- package/src/handlers/flag-push.ts +1 -1
- package/src/handlers/mailbox-management.test.ts +36 -27
- package/src/handlers/mailbox-management.ts +67 -19
- package/src/handlers/message-copy.test.ts +56 -25
- package/src/handlers/message-copy.ts +43 -20
- package/src/handlers/message-delete-terminal.ts +11 -7
- package/src/handlers/message-delete.test.ts +225 -88
- package/src/handlers/message-delete.ts +92 -12
- package/src/handlers/message-move-terminal.test.ts +65 -20
- package/src/handlers/message-move-terminal.ts +10 -6
- package/src/handlers/message-move.test.ts +26 -17
- package/src/handlers/message-move.ts +27 -10
- package/src/handlers/placement-move-push.ts +2 -1
- package/src/handlers/sync-message-body.ts +4 -4
- package/src/handlers/sync-messages.test.ts +2 -4
- package/src/handlers/sync-messages.ts +20 -14
- package/src/handlers/restore-source-placement.ts +0 -135
- package/src/handlers/thread-message-rows.ts +0 -77
- package/src/is-not-found.ts +0 -17
package/package.json
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
APPENDED_UID_UNREPORTED,
|
|
5
5
|
isSentCopyFiled,
|
|
6
6
|
} from "@remit/data-ports";
|
|
7
|
+
import { isNotFoundError } from "@remit/data-ports/errors";
|
|
7
8
|
import { OutboxMessageStatus } from "@remit/domain-enums";
|
|
8
9
|
import type { Logger } from "@remit/logger-lambda";
|
|
9
10
|
import {
|
|
@@ -14,7 +15,6 @@ import { attemptBudget } from "@remit/sqs-client/attempt-budget";
|
|
|
14
15
|
import { isAccountDeleted } from "../account-check.js";
|
|
15
16
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
16
17
|
import type { AppendSentMessageEvent } from "../events.js";
|
|
17
|
-
import { isNotFoundError } from "../is-not-found.js";
|
|
18
18
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
19
19
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
20
20
|
|
|
@@ -45,6 +45,8 @@ interface Harness {
|
|
|
45
45
|
localMessages: LocalMessage[];
|
|
46
46
|
threadMessage: boolean;
|
|
47
47
|
messagesWithoutListingRow: string[];
|
|
48
|
+
/** Message ids whose placement another lane has changed under the sweep. */
|
|
49
|
+
transitionsLost: string[];
|
|
48
50
|
getConnectionCount: number;
|
|
49
51
|
disconnectCount: number;
|
|
50
52
|
}
|
|
@@ -88,6 +90,25 @@ const retryingIntoTrash = (messageId: string, uid: number): LocalMessage => ({
|
|
|
88
90
|
syncStatus: "failed",
|
|
89
91
|
});
|
|
90
92
|
|
|
93
|
+
/**
|
|
94
|
+
* A row whose move into Trash SETTLED, on a database written before #1217
|
|
95
|
+
* taught `updateUid` to clear `originalUid`. Two folders count uids
|
|
96
|
+
* independently, so Trash handing back the source's own number is ordinary —
|
|
97
|
+
* and the pair it leaves is indistinguishable from a move still in flight.
|
|
98
|
+
*/
|
|
99
|
+
const settledWithStaleOriginalUid = (
|
|
100
|
+
messageId: string,
|
|
101
|
+
uid: number,
|
|
102
|
+
): LocalMessage => ({
|
|
103
|
+
messageId,
|
|
104
|
+
uid,
|
|
105
|
+
status: "deleting",
|
|
106
|
+
syncStatus: "pending",
|
|
107
|
+
mailboxId: "trash-mbx",
|
|
108
|
+
originalMailboxId: "inbox-mbx",
|
|
109
|
+
originalUid: uid,
|
|
110
|
+
});
|
|
111
|
+
|
|
91
112
|
/** The same row after an empty marked the folder, `status` overwritten. */
|
|
92
113
|
const markedMidMove = (messageId: string, uid: number): LocalMessage => ({
|
|
93
114
|
...movingIntoTrash(messageId, uid),
|
|
@@ -106,6 +127,7 @@ const fresh = (): Harness => ({
|
|
|
106
127
|
localMessages: [deleting("msg-1", 10), deleting("msg-2", 11)],
|
|
107
128
|
threadMessage: true,
|
|
108
129
|
messagesWithoutListingRow: [],
|
|
130
|
+
transitionsLost: [],
|
|
109
131
|
getConnectionCount: 0,
|
|
110
132
|
disconnectCount: 0,
|
|
111
133
|
});
|
|
@@ -140,6 +162,18 @@ const deps = (): EmptyTrashDeps =>
|
|
|
140
162
|
listAllByMailbox: async () => h.localMessages,
|
|
141
163
|
delete: record("message.delete"),
|
|
142
164
|
update: record("message.update"),
|
|
165
|
+
transitionPlacement: async (
|
|
166
|
+
messageId: string,
|
|
167
|
+
expected: unknown,
|
|
168
|
+
next: unknown,
|
|
169
|
+
) => {
|
|
170
|
+
const won = !h.transitionsLost.includes(messageId);
|
|
171
|
+
h.calls.push({
|
|
172
|
+
method: "message.transitionPlacement",
|
|
173
|
+
args: [messageId, expected, next, won],
|
|
174
|
+
});
|
|
175
|
+
return won ? { messageId } : undefined;
|
|
176
|
+
},
|
|
143
177
|
},
|
|
144
178
|
threadMessage: {
|
|
145
179
|
findByMessageId: async (_cfg: string, messageId: string) =>
|
|
@@ -193,13 +227,16 @@ const event: EmptyTrashEvent = {
|
|
|
193
227
|
const called = (method: string): Call[] =>
|
|
194
228
|
h.calls.filter((c) => c.method === method);
|
|
195
229
|
|
|
230
|
+
// Only a transition that WON reverted anything; a lost predicate is an offered
|
|
231
|
+
// write that the row refused.
|
|
196
232
|
const revertedMessageIds = (): string[] =>
|
|
197
|
-
called("message.
|
|
233
|
+
called("message.transitionPlacement")
|
|
198
234
|
.filter(
|
|
199
235
|
(c) =>
|
|
200
|
-
|
|
236
|
+
c.args[3] === true &&
|
|
237
|
+
(c.args[2] as { status?: string; syncStatus?: string }).status ===
|
|
201
238
|
"active" &&
|
|
202
|
-
(c.args[
|
|
239
|
+
(c.args[2] as { syncStatus?: string }).syncStatus === "synced",
|
|
203
240
|
)
|
|
204
241
|
.map((c) => c.args[0] as string);
|
|
205
242
|
|
|
@@ -498,6 +535,84 @@ describe("handleEmptyTrash and an unsettled placement", () => {
|
|
|
498
535
|
assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-marked"]);
|
|
499
536
|
});
|
|
500
537
|
|
|
538
|
+
it("hands nothing back for a row another lane settled after the sweep read it", async () => {
|
|
539
|
+
// The hand-back is a transition off the row as this sweep read it
|
|
540
|
+
// (imap-mutations R3). PLACEMENT_MOVE_PUSH rides a standard queue that
|
|
541
|
+
// this account's FIFO group does not order, so it can settle a row
|
|
542
|
+
// mid-sweep; writing `active` + `synced` over that would declare a
|
|
543
|
+
// mutation settled that nobody confirmed, and take the listing row with
|
|
544
|
+
// it.
|
|
545
|
+
h.localMessages = [deleting("msg-1", 10), markedMidMove("msg-raced", 11)];
|
|
546
|
+
h.transitionsLost = ["msg-raced"];
|
|
547
|
+
|
|
548
|
+
await handleEmptyTrash(event, noopLogger, deps());
|
|
549
|
+
|
|
550
|
+
assert.deepEqual(
|
|
551
|
+
called("message.transitionPlacement").map((c) => c.args[0]),
|
|
552
|
+
["msg-raced"],
|
|
553
|
+
"the predicate was still offered — the row just lost it",
|
|
554
|
+
);
|
|
555
|
+
assert.deepEqual(revertedMessageIds(), []);
|
|
556
|
+
assert.deepEqual(
|
|
557
|
+
undeletedThreadMessageIds(),
|
|
558
|
+
[],
|
|
559
|
+
"a lost predicate writes nothing at all, listing rows included",
|
|
560
|
+
);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("predicates the hand-back on the placement the sweep actually read", async () => {
|
|
564
|
+
h.localMessages = [markedMidMove("msg-marked", 11)];
|
|
565
|
+
|
|
566
|
+
await handleEmptyTrash(event, noopLogger, deps());
|
|
567
|
+
|
|
568
|
+
const [call] = called("message.transitionPlacement");
|
|
569
|
+
assert.deepEqual(call?.args[1], {
|
|
570
|
+
status: "deleting",
|
|
571
|
+
mailboxId: "trash-mbx",
|
|
572
|
+
uid: 11,
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// Issue #1230. The exclusion used to run after `deleteMessages`, so a row
|
|
577
|
+
// the sweep refused had already lost its server copy: mail that no longer
|
|
578
|
+
// exists, visible in Trash, with nothing able to clear it — pressing Empty
|
|
579
|
+
// Trash again skips it too, because the server no longer lists the uid.
|
|
580
|
+
it("never expunges a uid whose row it will refuse to remove", async () => {
|
|
581
|
+
h.localMessages = [
|
|
582
|
+
deleting("msg-1", 10),
|
|
583
|
+
settledWithStaleOriginalUid("msg-stale", 11),
|
|
584
|
+
];
|
|
585
|
+
|
|
586
|
+
await handleEmptyTrash(event, noopLogger, deps());
|
|
587
|
+
|
|
588
|
+
assert.deepEqual(
|
|
589
|
+
called("connection.deleteMessages")[0]?.args,
|
|
590
|
+
[[10]],
|
|
591
|
+
"uid 11 survives on the server, because its row survives locally",
|
|
592
|
+
);
|
|
593
|
+
assert.deepEqual(
|
|
594
|
+
called("message.delete").map((c) => c.args[0]),
|
|
595
|
+
["msg-1"],
|
|
596
|
+
);
|
|
597
|
+
assert.deepEqual(
|
|
598
|
+
revertedMessageIds(),
|
|
599
|
+
["msg-stale"],
|
|
600
|
+
"and the refused row is handed back rather than left marked",
|
|
601
|
+
);
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it("issues no expunge at all when every uid in the folder is refused", async () => {
|
|
605
|
+
h.localMessages = [
|
|
606
|
+
settledWithStaleOriginalUid("msg-stale-a", 10),
|
|
607
|
+
settledWithStaleOriginalUid("msg-stale-b", 11),
|
|
608
|
+
];
|
|
609
|
+
|
|
610
|
+
await handleEmptyTrash(event, noopLogger, deps());
|
|
611
|
+
|
|
612
|
+
assert.equal(called("connection.deleteMessages").length, 0);
|
|
613
|
+
assert.equal(called("message.delete").length, 0);
|
|
614
|
+
});
|
|
615
|
+
|
|
501
616
|
it("sweeps a settled row whose Trash uid matches the one it left behind", async () => {
|
|
502
617
|
// Two folders count uids independently, so a move can land on the same
|
|
503
618
|
// number it started from. `updateUid` drops `originalUid` when it settles,
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
2
|
import type { MessageItem } from "@remit/data-ports";
|
|
3
|
+
import { isNotFoundError } from "@remit/data-ports/errors";
|
|
3
4
|
import { trashMailboxAt } from "@remit/data-ports/folder-role";
|
|
4
5
|
import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
|
|
5
6
|
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
6
7
|
import type { Logger } from "@remit/logger-lambda";
|
|
7
8
|
import {
|
|
9
|
+
buildThreadMessageUndelete,
|
|
8
10
|
carriesForeignUid,
|
|
9
11
|
guardConnectionCursor,
|
|
10
12
|
isCursorRebuildNeeded,
|
|
@@ -13,10 +15,8 @@ import {
|
|
|
13
15
|
import { isAccountDeleted } from "../account-check.js";
|
|
14
16
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
15
17
|
import type { EmptyTrashEvent } from "../events.js";
|
|
16
|
-
import { isNotFoundError } from "../is-not-found.js";
|
|
17
18
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
18
19
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
19
|
-
import { buildThreadMessageUndelete } from "./thread-message-rows.js";
|
|
20
20
|
|
|
21
21
|
export interface EmptyTrashDeps {
|
|
22
22
|
getClient: typeof getClient;
|
|
@@ -89,7 +89,10 @@ export const handleEmptyTrash = async (
|
|
|
89
89
|
// an unsettled move is rewritten by its own MESSAGE_DELETE. Not `failed`:
|
|
90
90
|
// the mail is intact, and saying otherwise about a whole folder is a lie.
|
|
91
91
|
const handBackMarkedRows = async (
|
|
92
|
-
rows: readonly
|
|
92
|
+
rows: readonly Pick<
|
|
93
|
+
MessageItem,
|
|
94
|
+
"messageId" | "status" | "mailboxId" | "uid"
|
|
95
|
+
>[],
|
|
93
96
|
): Promise<number> => {
|
|
94
97
|
let revertedCount = 0;
|
|
95
98
|
for (const message of rows) {
|
|
@@ -105,10 +108,30 @@ export const handleEmptyTrash = async (
|
|
|
105
108
|
);
|
|
106
109
|
if (threadMessages.length === 0) continue;
|
|
107
110
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
// The hand-back is a transition off the row as this sweep read it
|
|
112
|
+
// (imap-mutations R3), because the snapshot it walks holds nothing:
|
|
113
|
+
// between the listing and here another lane can settle the row onto a
|
|
114
|
+
// placement of its own, and writing `active` + `synced` over that would
|
|
115
|
+
// declare a mutation settled that nobody confirmed.
|
|
116
|
+
const restored = await messageService.transitionPlacement(
|
|
117
|
+
message.messageId,
|
|
118
|
+
{
|
|
119
|
+
status: MessageStatus.deleting,
|
|
120
|
+
mailboxId: message.mailboxId,
|
|
121
|
+
uid: message.uid,
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
status: MessageStatus.active,
|
|
125
|
+
syncStatus: MessageSyncStatus.synced,
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
if (!restored) {
|
|
129
|
+
log.info(
|
|
130
|
+
{ accountId, messageId: message.messageId },
|
|
131
|
+
"Empty Trash left a row alone: its placement changed after the sweep read it",
|
|
132
|
+
);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
112
135
|
for (const threadMessage of threadMessages) {
|
|
113
136
|
const args = buildThreadMessageUndelete(threadMessage);
|
|
114
137
|
await threadMessageService.update(
|
|
@@ -234,39 +257,75 @@ export const handleEmptyTrash = async (
|
|
|
234
257
|
return;
|
|
235
258
|
}
|
|
236
259
|
|
|
237
|
-
|
|
260
|
+
// One read, before the expunge, decides both what is destroyed on
|
|
261
|
+
// the server and what is removed locally — so the two sets are the
|
|
262
|
+
// same set by construction and no message can be expunged that the
|
|
263
|
+
// sweep then declines to remove (issue #1230). Reading afterwards
|
|
264
|
+
// put the exclusion downstream of the destruction: a refused row
|
|
265
|
+
// had already lost its server copy, could not be refused into
|
|
266
|
+
// existence again, and nothing routine repaired it.
|
|
267
|
+
//
|
|
268
|
+
// Reconciles, never waits (imap-mutations R2). A row whose move
|
|
269
|
+
// into Trash has not settled names this folder while still
|
|
270
|
+
// carrying the SOURCE folder's uid, so matching it against the
|
|
271
|
+
// expunge answers for whatever Trash held at that uid — another
|
|
272
|
+
// message, deleted here in both its rows. Waiting is not open to
|
|
273
|
+
// this handler the way it is to the API-side mutators: a move on
|
|
274
|
+
// this account's own FIFO group cannot run until this returns, so
|
|
275
|
+
// the ceiling would be spent to reach the same answer. It buys
|
|
276
|
+
// nothing against the other lane either — PLACEMENT_MOVE_PUSH
|
|
277
|
+
// rides a standard queue with no group at all
|
|
278
|
+
// (`deploy/vps/queues.json`), so it is ordered against nothing
|
|
279
|
+
// here and can settle a row mid-sweep. That is why the writes
|
|
280
|
+
// below are transitions rather than snapshot writes (R3).
|
|
281
|
+
//
|
|
282
|
+
// `carriesForeignUid`, not the placement binding: the binding reads
|
|
283
|
+
// `status`, and `status` is exactly what an operation marking this
|
|
284
|
+
// folder overwrites. This is the same predicate `emptyTrash`
|
|
285
|
+
// applies before it marks anything — one gate, one answer, now
|
|
286
|
+
// asked once and honoured on both sides of the expunge.
|
|
287
|
+
const localMessages =
|
|
288
|
+
await messageService.listAllByMailbox(trashMailboxId);
|
|
289
|
+
const sweepable = localMessages.filter(
|
|
290
|
+
(message) => !carriesForeignUid(message),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
// A refused row spares its uid only where no row the sweep will
|
|
294
|
+
// carry claims the same number. The two cases the predicate cannot
|
|
295
|
+
// tell apart part here: a row genuinely mid-move borrows a uid that
|
|
296
|
+
// belongs to a different message in this folder, and sparing that
|
|
297
|
+
// number would strand the message that owns it; a settled row whose
|
|
298
|
+
// `originalUid` a pre-#1217 build left behind owns its uid outright,
|
|
299
|
+
// and expunging it destroys mail the sweep then refuses to remove.
|
|
300
|
+
const sweepableUids = new Set(
|
|
301
|
+
sweepable.map((message) => message.uid),
|
|
302
|
+
);
|
|
303
|
+
const refusedUids = new Set(
|
|
304
|
+
localMessages
|
|
305
|
+
.filter(
|
|
306
|
+
(message) =>
|
|
307
|
+
carriesForeignUid(message) && !sweepableUids.has(message.uid),
|
|
308
|
+
)
|
|
309
|
+
.map((message) => message.uid),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
const uids = (await connection.search(["ALL"])).filter(
|
|
313
|
+
(uid) => !refusedUids.has(uid),
|
|
314
|
+
);
|
|
238
315
|
|
|
239
316
|
if (uids.length > 0) {
|
|
240
317
|
await connection.deleteMessages(uids);
|
|
241
318
|
log.info(
|
|
242
|
-
{ count: uids.length },
|
|
319
|
+
{ count: uids.length, refused: refusedUids.size },
|
|
243
320
|
"Deleted messages from IMAP trash",
|
|
244
321
|
);
|
|
245
322
|
}
|
|
246
323
|
|
|
247
|
-
//
|
|
248
|
-
//
|
|
324
|
+
// What was expunged is a fact this connection observed, and only
|
|
325
|
+
// those rows go.
|
|
249
326
|
const expunged = new Set(uids);
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
// Reconciles, never waits (imap-mutations R2). A row whose move
|
|
254
|
-
// into Trash has not settled names this folder while still
|
|
255
|
-
// carrying the SOURCE folder's uid, so matching it against the
|
|
256
|
-
// expunge answers for whatever Trash held at that uid — another
|
|
257
|
-
// message, deleted here in both its rows. Waiting is not open to
|
|
258
|
-
// this handler the way it is to the API-side mutators: every event
|
|
259
|
-
// of an account shares one FIFO group, so the move that would
|
|
260
|
-
// settle the row cannot run until this returns, and the ceiling
|
|
261
|
-
// would be spent to reach the same answer.
|
|
262
|
-
//
|
|
263
|
-
// `carriesForeignUid`, not the placement binding: the binding reads
|
|
264
|
-
// `status`, and `status` is exactly what an operation marking this
|
|
265
|
-
// folder overwrites. This is the second half of the gate
|
|
266
|
-
// `emptyTrash` applies before it marks anything.
|
|
267
|
-
const swept = localMessages.filter(
|
|
268
|
-
(message) =>
|
|
269
|
-
!carriesForeignUid(message) && expunged.has(message.uid),
|
|
327
|
+
const swept = sweepable.filter((message) =>
|
|
328
|
+
expunged.has(message.uid),
|
|
270
329
|
);
|
|
271
330
|
const sweptIds = new Set(swept.map((message) => message.messageId));
|
|
272
331
|
const deletedCount = swept.length;
|
|
@@ -26,7 +26,8 @@ import type {
|
|
|
26
26
|
MessageFlagPushItem,
|
|
27
27
|
MessageItem,
|
|
28
28
|
MessagePlacementMoveItem,
|
|
29
|
-
|
|
29
|
+
PlacementPredicate,
|
|
30
|
+
PlacementTransitionInput,
|
|
30
31
|
} from "@remit/data-ports";
|
|
31
32
|
import type { Logger } from "@remit/logger-lambda";
|
|
32
33
|
import {
|
|
@@ -87,11 +88,24 @@ const buildHarness = (): Harness => {
|
|
|
87
88
|
assert.equal(messageId, MESSAGE_ID);
|
|
88
89
|
return row;
|
|
89
90
|
},
|
|
90
|
-
|
|
91
|
+
transitionPlacement: async (
|
|
91
92
|
_messageId: string,
|
|
92
|
-
|
|
93
|
+
expected: PlacementPredicate,
|
|
94
|
+
next: PlacementTransitionInput,
|
|
93
95
|
) => {
|
|
94
|
-
|
|
96
|
+
if (expected.status !== undefined && expected.status !== row.status) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
if (expected.uid !== undefined && expected.uid !== row.uid) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
expected.mailboxId !== undefined &&
|
|
104
|
+
expected.mailboxId !== row.mailboxId
|
|
105
|
+
) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
Object.assign(row, next);
|
|
95
109
|
return row;
|
|
96
110
|
},
|
|
97
111
|
} as unknown as IMessageRepository;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
|
+
import { isNotFoundError } from "@remit/data-ports/errors";
|
|
2
3
|
import type { Logger } from "@remit/logger-lambda";
|
|
3
4
|
import { recordImapFailure } from "@remit/logger-lambda";
|
|
4
5
|
import {
|
|
@@ -12,7 +13,6 @@ import { attemptBudget } from "@remit/sqs-client/attempt-budget";
|
|
|
12
13
|
import { isAccountDeleted } from "../account-check.js";
|
|
13
14
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
14
15
|
import type { FlagPushEvent } from "../events.js";
|
|
15
|
-
import { isNotFoundError } from "../is-not-found.js";
|
|
16
16
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
17
17
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
18
18
|
|
|
@@ -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
|
});
|