@remit/imap-worker 0.0.71 → 0.0.73
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 -2
- 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.ts +1 -1
- 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 +2 -2
- package/src/handlers/sync-messages.ts +1 -1
- package/src/filter-config.test.ts +0 -171
- package/src/filter-config.ts +0 -63
- 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/src/message-embedder.test.ts +0 -39
- package/src/message-embedder.ts +0 -33
|
@@ -4,23 +4,29 @@ import type {
|
|
|
4
4
|
IThreadMessageRepository,
|
|
5
5
|
MessageItem,
|
|
6
6
|
} from "@remit/data-ports";
|
|
7
|
+
import { isNotFoundError } from "@remit/data-ports/errors";
|
|
7
8
|
import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
MessageMutation,
|
|
11
|
+
MessageStatus,
|
|
12
|
+
MessageSyncStatus,
|
|
13
|
+
} from "@remit/domain-enums";
|
|
9
14
|
import type { Logger } from "@remit/logger-lambda";
|
|
10
15
|
import { recordImapFailure } from "@remit/logger-lambda";
|
|
11
16
|
import {
|
|
17
|
+
buildThreadMessageTrashUpdate,
|
|
12
18
|
guardConnectionCursor,
|
|
13
19
|
type IImapConnection,
|
|
14
20
|
isCursorRebuildNeeded,
|
|
15
21
|
isMessageGoneFromOpenMailbox,
|
|
16
22
|
MailboxCursorPausedError,
|
|
23
|
+
restoreSourcePlacement,
|
|
17
24
|
} from "@remit/mailbox-service";
|
|
18
25
|
import { attemptBudget } from "@remit/sqs-client/attempt-budget";
|
|
19
26
|
import { isAccountDeleted } from "../account-check.js";
|
|
20
27
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
21
28
|
import { emitEvent } from "../emit.js";
|
|
22
29
|
import type { MessageDeleteEvent } from "../events.js";
|
|
23
|
-
import { isNotFoundError } from "../is-not-found.js";
|
|
24
30
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
25
31
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
26
32
|
import { resolveExhaustedMessageDeleteFailure } from "./message-delete-terminal.js";
|
|
@@ -31,8 +37,6 @@ import {
|
|
|
31
37
|
probePausedPlacement,
|
|
32
38
|
searchMailboxForHighestMessageIdUid,
|
|
33
39
|
} from "./message-move.js";
|
|
34
|
-
import { restoreSourcePlacement } from "./restore-source-placement.js";
|
|
35
|
-
import { buildThreadMessageTrashUpdate } from "./thread-message-rows.js";
|
|
36
40
|
|
|
37
41
|
export const getMessageDeleteMaxAttempts = (
|
|
38
42
|
processEnv: NodeJS.ProcessEnv = process.env,
|
|
@@ -196,6 +200,55 @@ export const handleMessageDelete = async (
|
|
|
196
200
|
return;
|
|
197
201
|
}
|
|
198
202
|
|
|
203
|
+
const [message] = await messageService.get([messageId]);
|
|
204
|
+
|
|
205
|
+
// The row is gone — a permanent delete that already ran, or a
|
|
206
|
+
// reconciliation path that removed it. Nothing left to delete; ack.
|
|
207
|
+
if (!message) {
|
|
208
|
+
log.warn(
|
|
209
|
+
{ accountId, messageId, operation },
|
|
210
|
+
"Skipping MESSAGE_DELETE: message row no longer exists",
|
|
211
|
+
);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// This delete already settled. The guard asks for the state THIS delete
|
|
216
|
+
// wrote, the way MESSAGE_MOVE and MESSAGE_COPY ask for theirs: a move to
|
|
217
|
+
// Trash records `moving`, a permanent delete records `deleting`, and a row
|
|
218
|
+
// in the other one is a different mutation's work that this handler must
|
|
219
|
+
// not read as its own to finish.
|
|
220
|
+
//
|
|
221
|
+
// Without it a lost SQS acknowledgement re-runs the delete against a uid
|
|
222
|
+
// the source no longer holds, exhausts, and lets the terminal resolver
|
|
223
|
+
// read the source's honest "gone" as grounds to delete rows that are
|
|
224
|
+
// correct — taking spamReport, classificationState, category and the Undo
|
|
225
|
+
// target's `originalMailboxId` with them, none of which the resync
|
|
226
|
+
// re-projection can rebuild.
|
|
227
|
+
// An operation this build does not recognise gets no verdict here: it falls
|
|
228
|
+
// through to `abandonDelete` below, which is the designed refusal. Skipping
|
|
229
|
+
// it as "already settled" would swallow the very event the refusal exists to
|
|
230
|
+
// report.
|
|
231
|
+
const outstandingStatus =
|
|
232
|
+
operation === "move_to_trash"
|
|
233
|
+
? MessageStatus.moving
|
|
234
|
+
: operation === "permanent_delete"
|
|
235
|
+
? MessageStatus.deleting
|
|
236
|
+
: undefined;
|
|
237
|
+
if (outstandingStatus !== undefined && message.status !== outstandingStatus) {
|
|
238
|
+
log.info(
|
|
239
|
+
{
|
|
240
|
+
accountId,
|
|
241
|
+
messageId,
|
|
242
|
+
operation,
|
|
243
|
+
uid: message.uid,
|
|
244
|
+
status: message.status,
|
|
245
|
+
syncStatus: message.syncStatus,
|
|
246
|
+
},
|
|
247
|
+
"Skipping MESSAGE_DELETE: the row no longer carries the mutation this event was enqueued for",
|
|
248
|
+
);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
199
252
|
/**
|
|
200
253
|
* Re-read the folders this delete touched. A move to trash has two; a
|
|
201
254
|
* permanent delete has only the source, and it is owed one just as much —
|
|
@@ -271,6 +324,24 @@ export const handleMessageDelete = async (
|
|
|
271
324
|
}
|
|
272
325
|
|
|
273
326
|
if (localRowsRemoved) {
|
|
327
|
+
// Removing the Message row is destructive and unpredicated on its own,
|
|
328
|
+
// so it is claimed first: the row is transitioned to `deleted` from a
|
|
329
|
+
// placement this delete actually owns, and removed only if that claim
|
|
330
|
+
// wins (imap-mutations R3). A row nothing is deleting — an unrecognised
|
|
331
|
+
// operation reaches this refusal on whatever the row happens to be —
|
|
332
|
+
// is left where it is rather than destroyed on the way past.
|
|
333
|
+
const claimed = await messageService.transitionPlacement(
|
|
334
|
+
messageId,
|
|
335
|
+
{ status: [MessageStatus.moving, MessageStatus.deleting] },
|
|
336
|
+
{ status: MessageStatus.deleted },
|
|
337
|
+
);
|
|
338
|
+
if (!claimed) {
|
|
339
|
+
log.warn(
|
|
340
|
+
{ ...context, status: message.status },
|
|
341
|
+
"Delete given up with no listing rows left, and no delete outstanding on the row; the local row was left alone",
|
|
342
|
+
);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
274
345
|
await messageService.delete(messageId);
|
|
275
346
|
return;
|
|
276
347
|
}
|
|
@@ -283,6 +354,12 @@ export const handleMessageDelete = async (
|
|
|
283
354
|
sourceMailboxId: mailboxId,
|
|
284
355
|
uid,
|
|
285
356
|
syncStatus,
|
|
357
|
+
// Every hand-back this handler makes is a delete's, so the mutation
|
|
358
|
+
// follows the give-up rather than being passed in beside it.
|
|
359
|
+
abandonedMutation:
|
|
360
|
+
syncStatus === MessageSyncStatus.abandoned
|
|
361
|
+
? MessageMutation.delete
|
|
362
|
+
: MessageMutation.none,
|
|
286
363
|
threadMessages,
|
|
287
364
|
},
|
|
288
365
|
);
|
|
@@ -294,11 +371,12 @@ export const handleMessageDelete = async (
|
|
|
294
371
|
// "anything that is not move_to_trash is an expunge" inference is the same
|
|
295
372
|
// one that destroyed mail in the service, and an unrecoverable EXPUNGE is
|
|
296
373
|
// not a default. Abandoning hands the row back where the server still has
|
|
297
|
-
// it: an invisible
|
|
298
|
-
//
|
|
299
|
-
// the
|
|
374
|
+
// it: an invisible failure on a row the user cannot see is the shape of the
|
|
375
|
+
// incident this whole change is about. The row carries `abandoned` — the
|
|
376
|
+
// give-up value, not the transient one a redelivery follows (R3) — because
|
|
377
|
+
// the product refused something the user asked for and nothing is coming.
|
|
300
378
|
const abandonDelete = (reason: string, alert: string): Promise<void> =>
|
|
301
|
-
handBackDelete(MessageSyncStatus.
|
|
379
|
+
handBackDelete(MessageSyncStatus.abandoned, { alert }, reason);
|
|
302
380
|
|
|
303
381
|
const settleTrashMoveConfirmed = async (
|
|
304
382
|
newUid: number,
|
|
@@ -386,7 +464,7 @@ export const handleMessageDelete = async (
|
|
|
386
464
|
|
|
387
465
|
if (unproven) {
|
|
388
466
|
await handBackDelete(
|
|
389
|
-
MessageSyncStatus.
|
|
467
|
+
MessageSyncStatus.abandoned,
|
|
390
468
|
{ alert: "message_delete_paused_placement_unproven" },
|
|
391
469
|
placement.kind === "at-destination"
|
|
392
470
|
? "the destination holds this Message-ID but the source's uid axis has moved, so the sighting does not prove this delete ran"
|
|
@@ -751,9 +829,11 @@ export const handleMessageDelete = async (
|
|
|
751
829
|
// Transient failure — connections drop. No alarm; redelivery
|
|
752
830
|
// retries, and `failed` marks the row unsettled meanwhile. It is
|
|
753
831
|
// not a terminal signal: only the resolver below settles anything.
|
|
754
|
-
await messageService.
|
|
755
|
-
|
|
756
|
-
|
|
832
|
+
await messageService.transitionPlacement(
|
|
833
|
+
messageId,
|
|
834
|
+
{ status: [MessageStatus.moving, MessageStatus.deleting] },
|
|
835
|
+
{ syncStatus: MessageSyncStatus.failed },
|
|
836
|
+
);
|
|
757
837
|
throw error;
|
|
758
838
|
}
|
|
759
839
|
|
|
@@ -4,6 +4,10 @@ import type {
|
|
|
4
4
|
IMessageRepository,
|
|
5
5
|
IThreadMessageRepository,
|
|
6
6
|
} from "@remit/data-ports";
|
|
7
|
+
import {
|
|
8
|
+
hasAbandonedDelete,
|
|
9
|
+
hasAbandonedMove,
|
|
10
|
+
} from "@remit/data-ports/message-settlement";
|
|
7
11
|
import {
|
|
8
12
|
type IImapConnection,
|
|
9
13
|
placementBindingOf,
|
|
@@ -75,8 +79,10 @@ interface ThreadRow {
|
|
|
75
79
|
* The move's pending state IS the Message row, so these fakes hold real rows
|
|
76
80
|
* and the assertions read the rows back — a resolver that settled the row on
|
|
77
81
|
* anything but the server's own answer shows up here as a changed row, not as
|
|
78
|
-
* an uncalled mock. `
|
|
79
|
-
*
|
|
82
|
+
* an uncalled mock. `transitionPlacement` honours its predicate the way the
|
|
83
|
+
* repository does: the write lands only while the row is still in one of the
|
|
84
|
+
* from-states the caller named (imap-mutations R3), and answers `undefined`
|
|
85
|
+
* otherwise.
|
|
80
86
|
*/
|
|
81
87
|
const buildRepositories = (row: MessageRow) => {
|
|
82
88
|
const messages = new Map<string, MessageRow>([[row.messageId, row]]);
|
|
@@ -104,11 +110,21 @@ const buildRepositories = (row: MessageRow) => {
|
|
|
104
110
|
const current = messages.get(messageId);
|
|
105
111
|
if (current) messages.set(messageId, { ...current, ...input });
|
|
106
112
|
},
|
|
107
|
-
|
|
113
|
+
transitionPlacement: async (
|
|
114
|
+
messageId: string,
|
|
115
|
+
expected: { status?: string[] },
|
|
116
|
+
next: Partial<MessageRow>,
|
|
117
|
+
) => {
|
|
108
118
|
const current = messages.get(messageId);
|
|
109
|
-
if (current)
|
|
119
|
+
if (!current) return undefined;
|
|
120
|
+
if (expected.status && !expected.status.includes(current.status)) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
const written = { ...current, ...next };
|
|
124
|
+
messages.set(messageId, written);
|
|
125
|
+
return written;
|
|
110
126
|
},
|
|
111
|
-
} as unknown as Pick<IMessageRepository, "delete" | "
|
|
127
|
+
} as unknown as Pick<IMessageRepository, "delete" | "transitionPlacement">,
|
|
112
128
|
threadMessageService: {
|
|
113
129
|
findAllByMessageId: async () => [...threadMessages.values()],
|
|
114
130
|
deleteMany: async (
|
|
@@ -204,9 +220,42 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
|
|
|
204
220
|
assert.ok(errors.some((e) => e.obj.alert === "message_move_failed"));
|
|
205
221
|
});
|
|
206
222
|
|
|
223
|
+
// Issue #1153: before the give-up had a value of its own, this row settled
|
|
224
|
+
// on `synced` and read exactly like a move that never happened — nothing on
|
|
225
|
+
// it said "the move you asked for is not coming". #1229 is why it also has
|
|
226
|
+
// to say WHICH mutation: a hand-back clears `status`, and `status` was the
|
|
227
|
+
// only field naming one.
|
|
228
|
+
it("BROKEN: the row says a move gave up, and says it was a move", async () => {
|
|
229
|
+
const repos = buildRepositories(pendingMoveRow());
|
|
230
|
+
const { log } = buildLogger();
|
|
231
|
+
const deps: ResolveExhaustedMessageMoveDeps = {
|
|
232
|
+
messageService: repos.messageService,
|
|
233
|
+
threadMessageService: repos.threadMessageService,
|
|
234
|
+
log,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
await resolveExhaustedMessageMoveFailure(deps, {
|
|
238
|
+
...input,
|
|
239
|
+
getConnection: async () => buildConnection(new Set([101])),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const settled = repos.messages.get("msg-1") as unknown as {
|
|
243
|
+
syncStatus: string;
|
|
244
|
+
abandonedMutation: string;
|
|
245
|
+
};
|
|
246
|
+
assert.equal(settled.syncStatus, "abandoned");
|
|
247
|
+
assert.equal(settled.abandonedMutation, "move");
|
|
248
|
+
assert.equal(
|
|
249
|
+
hasAbandonedDelete(settled as never),
|
|
250
|
+
false,
|
|
251
|
+
"and never reads as a delete that gave up",
|
|
252
|
+
);
|
|
253
|
+
assert.equal(hasAbandonedMove(settled as never), true);
|
|
254
|
+
});
|
|
255
|
+
|
|
207
256
|
// Issue #1005: a give-up that never writes `status` leaves the row naming
|
|
208
|
-
// the destination with the source's uid — a pair
|
|
209
|
-
// which made the message undeletable and unmovable for good.
|
|
257
|
+
// the destination with the source's uid — a pair every dependent mutation
|
|
258
|
+
// refuses, which made the message undeletable and unmovable for good.
|
|
210
259
|
it("BROKEN: the settled row is no longer refused by the placement guard (#1005)", async () => {
|
|
211
260
|
const repos = buildRepositories(pendingMoveRow());
|
|
212
261
|
const { log } = buildLogger();
|
|
@@ -218,8 +267,8 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
|
|
|
218
267
|
|
|
219
268
|
assert.equal(
|
|
220
269
|
placementBindingOf(pendingMoveRow() as never),
|
|
221
|
-
"
|
|
222
|
-
"the row starts
|
|
270
|
+
"in_flight",
|
|
271
|
+
"the row starts on a pair the delete route will not act on",
|
|
223
272
|
);
|
|
224
273
|
|
|
225
274
|
await resolveExhaustedMessageMoveFailure(deps, {
|
|
@@ -257,21 +306,17 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
|
|
|
257
306
|
});
|
|
258
307
|
|
|
259
308
|
// The resolver's contract is that it is never re-thrown, so the caller can
|
|
260
|
-
// ack the record and emit its resync. A row another path deleted
|
|
261
|
-
// probe was in flight has nothing left to restore, and
|
|
262
|
-
//
|
|
263
|
-
|
|
309
|
+
// ack the record and emit its resync. A row another path deleted or settled
|
|
310
|
+
// while the probe was in flight has nothing left to restore, and it loses
|
|
311
|
+
// the transition's predicate rather than raising; a NotFound still wraps an
|
|
312
|
+
// ElectroDB composites miss on the listing row.
|
|
313
|
+
it("BROKEN: a row settled underneath the probe loses the predicate instead of throwing", async () => {
|
|
264
314
|
const repos = buildRepositories(pendingMoveRow());
|
|
265
|
-
const notFound = Object.assign(new Error("Message not found: msg-1"), {
|
|
266
|
-
name: "NotFoundError",
|
|
267
|
-
});
|
|
268
315
|
const { log } = buildLogger();
|
|
269
316
|
const deps: ResolveExhaustedMessageMoveDeps = {
|
|
270
317
|
messageService: {
|
|
271
318
|
...repos.messageService,
|
|
272
|
-
|
|
273
|
-
throw notFound;
|
|
274
|
-
},
|
|
319
|
+
transitionPlacement: async () => undefined,
|
|
275
320
|
} as unknown as ResolveExhaustedMessageMoveDeps["messageService"],
|
|
276
321
|
threadMessageService: repos.threadMessageService,
|
|
277
322
|
log,
|
|
@@ -320,7 +365,7 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
|
|
|
320
365
|
const deps: ResolveExhaustedMessageMoveDeps = {
|
|
321
366
|
messageService: {
|
|
322
367
|
...repos.messageService,
|
|
323
|
-
|
|
368
|
+
transitionPlacement: async () => {
|
|
324
369
|
throw new Error("ProvisionedThroughputExceeded");
|
|
325
370
|
},
|
|
326
371
|
} as unknown as ResolveExhaustedMessageMoveDeps["messageService"],
|
|
@@ -2,14 +2,14 @@ import type {
|
|
|
2
2
|
IMessageRepository,
|
|
3
3
|
IThreadMessageRepository,
|
|
4
4
|
} from "@remit/data-ports";
|
|
5
|
-
import { MessageSyncStatus } from "@remit/domain-enums";
|
|
5
|
+
import { MessageMutation, MessageSyncStatus } from "@remit/domain-enums";
|
|
6
6
|
import {
|
|
7
7
|
type IImapConnection,
|
|
8
8
|
isMessageGoneFromOpenMailbox,
|
|
9
9
|
reconcileStaleMessage,
|
|
10
|
+
restoreSourcePlacement,
|
|
10
11
|
type StaleMessageReconcileDeps,
|
|
11
12
|
} from "@remit/mailbox-service";
|
|
12
|
-
import { restoreSourcePlacement } from "./restore-source-placement.js";
|
|
13
13
|
|
|
14
14
|
export interface MessageMoveTerminalLogger {
|
|
15
15
|
info(obj: Record<string, unknown>, msg: string): void;
|
|
@@ -18,7 +18,7 @@ export interface MessageMoveTerminalLogger {
|
|
|
18
18
|
|
|
19
19
|
export interface ResolveExhaustedMessageMoveDeps
|
|
20
20
|
extends StaleMessageReconcileDeps {
|
|
21
|
-
messageService: Pick<IMessageRepository, "delete" | "
|
|
21
|
+
messageService: Pick<IMessageRepository, "delete" | "transitionPlacement">;
|
|
22
22
|
threadMessageService: Pick<
|
|
23
23
|
IThreadMessageRepository,
|
|
24
24
|
"findAllByMessageId" | "deleteMany" | "update"
|
|
@@ -149,9 +149,13 @@ export const resolveExhaustedMessageMoveFailure = async (
|
|
|
149
149
|
sourceMailboxId,
|
|
150
150
|
uid,
|
|
151
151
|
// The probe above just confirmed the pair, so the row is a faithful
|
|
152
|
-
// projection of the source again
|
|
153
|
-
//
|
|
154
|
-
|
|
152
|
+
// projection of the source again — and the move the user asked for did
|
|
153
|
+
// not happen, with nothing left to try it. `abandoned` is the value that
|
|
154
|
+
// says so without claiming a retry is coming (imap-mutations R3); the
|
|
155
|
+
// alert stays for the operator, and the mutation names itself so the
|
|
156
|
+
// reading pane offers the right way out (issue #1229).
|
|
157
|
+
syncStatus: MessageSyncStatus.abandoned,
|
|
158
|
+
abandonedMutation: MessageMutation.move,
|
|
155
159
|
});
|
|
156
160
|
|
|
157
161
|
return { outcome: "broken" };
|
|
@@ -277,7 +277,7 @@ describe("handleMessageMove — the move's own pending state gates every attempt
|
|
|
277
277
|
get: async () => [],
|
|
278
278
|
update: async () => undefined,
|
|
279
279
|
updateUid: async () => undefined,
|
|
280
|
-
|
|
280
|
+
transitionPlacement: async () => undefined,
|
|
281
281
|
},
|
|
282
282
|
threadMessage: {
|
|
283
283
|
findAllByMessageId: async () => [],
|
|
@@ -410,7 +410,7 @@ describe("handleMessageMove — the move's own pending state gates every attempt
|
|
|
410
410
|
const arrangePausedMove = async (
|
|
411
411
|
options: { probeable: boolean } = { probeable: true },
|
|
412
412
|
): Promise<{
|
|
413
|
-
|
|
413
|
+
transitionCalls: unknown[][];
|
|
414
414
|
updateUidCalls: unknown[][];
|
|
415
415
|
threadUpdateCalls: unknown[][];
|
|
416
416
|
}> => {
|
|
@@ -424,10 +424,15 @@ describe("handleMessageMove — the move's own pending state gates every attempt
|
|
|
424
424
|
},
|
|
425
425
|
]);
|
|
426
426
|
mock.method(client.mailbox, "get", async () => pausedSource());
|
|
427
|
-
const
|
|
428
|
-
mock.method(
|
|
429
|
-
|
|
430
|
-
|
|
427
|
+
const transitionCalls: unknown[][] = [];
|
|
428
|
+
mock.method(
|
|
429
|
+
client.message,
|
|
430
|
+
"transitionPlacement",
|
|
431
|
+
async (...args: unknown[]) => {
|
|
432
|
+
transitionCalls.push(args);
|
|
433
|
+
return { messageId: "mm-msg-zzz" };
|
|
434
|
+
},
|
|
435
|
+
);
|
|
431
436
|
const updateUidCalls: unknown[][] = [];
|
|
432
437
|
mock.method(client.message, "updateUid", async (...args: unknown[]) => {
|
|
433
438
|
updateUidCalls.push(args);
|
|
@@ -446,7 +451,7 @@ describe("handleMessageMove — the move's own pending state gates every attempt
|
|
|
446
451
|
mock.method(client.threadMessage, "update", async (...args: unknown[]) => {
|
|
447
452
|
threadUpdateCalls.push(args);
|
|
448
453
|
});
|
|
449
|
-
return {
|
|
454
|
+
return { transitionCalls, updateUidCalls, threadUpdateCalls };
|
|
450
455
|
};
|
|
451
456
|
|
|
452
457
|
// Issue #1203. Acking a paused cursor left the row `moving` with `mailboxId`
|
|
@@ -456,7 +461,7 @@ describe("handleMessageMove — the move's own pending state gates every attempt
|
|
|
456
461
|
// neither folder's set: `placementBindingOf` answered `in_flight` for good
|
|
457
462
|
// and the message became unmovable and undeletable.
|
|
458
463
|
it("hands the row back to its source on a first delivery, without connecting", async () => {
|
|
459
|
-
const {
|
|
464
|
+
const { transitionCalls, threadUpdateCalls } = await arrangePausedMove();
|
|
460
465
|
|
|
461
466
|
await handleMessageMove(event, silentLogger, 1, moveDeps());
|
|
462
467
|
|
|
@@ -466,14 +471,16 @@ describe("handleMessageMove — the move's own pending state gates every attempt
|
|
|
466
471
|
"a first delivery has provably issued no MOVE, so it needs no answer from the server",
|
|
467
472
|
);
|
|
468
473
|
assert.deepEqual(
|
|
469
|
-
|
|
474
|
+
transitionCalls[0],
|
|
470
475
|
[
|
|
471
476
|
"mm-msg-zzz",
|
|
477
|
+
{ status: ["moving", "deleting"] },
|
|
472
478
|
{
|
|
473
479
|
mailboxId: "mm-src-zzz",
|
|
474
480
|
uid: 10,
|
|
475
481
|
status: "active",
|
|
476
482
|
syncStatus: "synced",
|
|
483
|
+
abandonedMutation: "none",
|
|
477
484
|
},
|
|
478
485
|
],
|
|
479
486
|
"the row goes back to the source pair, which is the set the rebuild adjudicates",
|
|
@@ -509,7 +516,7 @@ describe("handleMessageMove — the move's own pending state gates every attempt
|
|
|
509
516
|
// binds the row to that copy, and the next permanent delete expunges it:
|
|
510
517
|
// Archive is not paused, so no rebuild is ever coming to adjudicate there.
|
|
511
518
|
it("never settles a redelivered paused move as moved off a destination sighting", async () => {
|
|
512
|
-
const {
|
|
519
|
+
const { transitionCalls, updateUidCalls } = await arrangePausedMove();
|
|
513
520
|
|
|
514
521
|
await handleMessageMove(
|
|
515
522
|
event,
|
|
@@ -525,11 +532,12 @@ describe("handleMessageMove — the move's own pending state gates every attempt
|
|
|
525
532
|
);
|
|
526
533
|
// The source pair is the set its own cursor rebuild walks and adjudicates
|
|
527
534
|
// by Message-ID, so handing the row back there is the reconcile path.
|
|
528
|
-
assert.deepEqual(
|
|
535
|
+
assert.deepEqual(transitionCalls[0]?.[2], {
|
|
529
536
|
mailboxId: "mm-src-zzz",
|
|
530
537
|
uid: 10,
|
|
531
538
|
status: "active",
|
|
532
|
-
syncStatus: "
|
|
539
|
+
syncStatus: "abandoned",
|
|
540
|
+
abandonedMutation: "move",
|
|
533
541
|
});
|
|
534
542
|
assert.equal(
|
|
535
543
|
logLines.filter(
|
|
@@ -545,7 +553,7 @@ describe("handleMessageMove — the move's own pending state gates every attempt
|
|
|
545
553
|
// redelivery is broken, not settled. With no Message-ID header neither
|
|
546
554
|
// folder was asked, and silence is not an answer to write `synced` on.
|
|
547
555
|
it("settles a redelivered paused move broken when neither folder can be asked", async () => {
|
|
548
|
-
const {
|
|
556
|
+
const { transitionCalls } = await arrangePausedMove({
|
|
549
557
|
probeable: false,
|
|
550
558
|
});
|
|
551
559
|
|
|
@@ -557,8 +565,8 @@ describe("handleMessageMove — the move's own pending state gates every attempt
|
|
|
557
565
|
);
|
|
558
566
|
|
|
559
567
|
assert.equal(
|
|
560
|
-
(
|
|
561
|
-
"
|
|
568
|
+
(transitionCalls[0]?.[2] as { syncStatus?: string })?.syncStatus,
|
|
569
|
+
"abandoned",
|
|
562
570
|
);
|
|
563
571
|
assert.equal(
|
|
564
572
|
logLines.filter(
|
|
@@ -589,7 +597,7 @@ describe("handleMessageMove — the move's own pending state gates every attempt
|
|
|
589
597
|
// this message. The source is asked first for exactly that reason, and its
|
|
590
598
|
// answer ends it.
|
|
591
599
|
it("never binds a redelivered paused move to an older copy while the source still answers", async () => {
|
|
592
|
-
const {
|
|
600
|
+
const { transitionCalls, updateUidCalls } = await arrangePausedMove();
|
|
593
601
|
|
|
594
602
|
await handleMessageMove(
|
|
595
603
|
event,
|
|
@@ -599,11 +607,12 @@ describe("handleMessageMove — the move's own pending state gates every attempt
|
|
|
599
607
|
);
|
|
600
608
|
|
|
601
609
|
assert.equal(updateUidCalls.length, 0);
|
|
602
|
-
assert.deepEqual(
|
|
610
|
+
assert.deepEqual(transitionCalls[0]?.[2], {
|
|
603
611
|
mailboxId: "mm-src-zzz",
|
|
604
612
|
uid: 10,
|
|
605
613
|
status: "active",
|
|
606
614
|
syncStatus: "synced",
|
|
615
|
+
abandonedMutation: "none",
|
|
607
616
|
});
|
|
608
617
|
});
|
|
609
618
|
|
|
@@ -1,25 +1,28 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
2
|
import type { MessageItem, ThreadMessageItem } from "@remit/data-ports";
|
|
3
|
-
import {
|
|
3
|
+
import { isNotFoundError } from "@remit/data-ports/errors";
|
|
4
|
+
import {
|
|
5
|
+
MessageMutation,
|
|
6
|
+
MessageStatus,
|
|
7
|
+
MessageSyncStatus,
|
|
8
|
+
} from "@remit/domain-enums";
|
|
4
9
|
import type { Logger } from "@remit/logger-lambda";
|
|
5
10
|
import { recordImapFailure } from "@remit/logger-lambda";
|
|
6
11
|
import {
|
|
7
12
|
guardConnectionCursor,
|
|
8
13
|
type IImapConnection,
|
|
9
14
|
isCursorRebuildNeeded,
|
|
10
|
-
isPlacementUnsettled,
|
|
11
15
|
MailboxCursorPausedError,
|
|
16
|
+
restoreSourcePlacement,
|
|
12
17
|
} from "@remit/mailbox-service";
|
|
13
18
|
import { attemptBudget } from "@remit/sqs-client/attempt-budget";
|
|
14
19
|
import { isAccountDeleted } from "../account-check.js";
|
|
15
20
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
16
21
|
import { emitEvent } from "../emit.js";
|
|
17
22
|
import type { MessageMoveEvent, SyncMessagesEvent } from "../events.js";
|
|
18
|
-
import { isNotFoundError } from "../is-not-found.js";
|
|
19
23
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
20
24
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
21
25
|
import { resolveExhaustedMessageMoveFailure } from "./message-move-terminal.js";
|
|
22
|
-
import { restoreSourcePlacement } from "./restore-source-placement.js";
|
|
23
26
|
|
|
24
27
|
export const getMessageMoveMaxAttempts = (
|
|
25
28
|
processEnv: NodeJS.ProcessEnv = process.env,
|
|
@@ -323,7 +326,12 @@ export const handleMessageMove = async (
|
|
|
323
326
|
// "gone" as grounds to reconcile away a row that is correct. There is no
|
|
324
327
|
// marker to find missing (unlike FLAG_PUSH and PLACEMENT_MOVE_PUSH), so the
|
|
325
328
|
// row's own pending marker is what stands in for one.
|
|
326
|
-
|
|
329
|
+
//
|
|
330
|
+
// `moving` specifically, not the shared unsettled predicate: the question is
|
|
331
|
+
// whether THIS move is still outstanding, and a row a delete has since
|
|
332
|
+
// claimed is `deleting` — work that belongs to MESSAGE_DELETE, and a state
|
|
333
|
+
// this move must not read as its own to finish.
|
|
334
|
+
if (message.status !== MessageStatus.moving) {
|
|
327
335
|
log.info(
|
|
328
336
|
{ accountId, messageId, uid: message.uid, status: message.status },
|
|
329
337
|
"Skipping MESSAGE_MOVE: the move already settled against confirmed IMAP state",
|
|
@@ -430,6 +438,13 @@ export const handleMessageMove = async (
|
|
|
430
438
|
sourceMailboxId,
|
|
431
439
|
uid,
|
|
432
440
|
syncStatus,
|
|
441
|
+
// Every hand-back this handler makes is a move's. Naming it on
|
|
442
|
+
// the row is what stops the reading pane calling a move that
|
|
443
|
+
// gave up a failed delete (issue #1229).
|
|
444
|
+
abandonedMutation:
|
|
445
|
+
syncStatus === MessageSyncStatus.abandoned
|
|
446
|
+
? MessageMutation.move
|
|
447
|
+
: MessageMutation.none,
|
|
433
448
|
},
|
|
434
449
|
);
|
|
435
450
|
};
|
|
@@ -451,7 +466,7 @@ export const handleMessageMove = async (
|
|
|
451
466
|
mailbox,
|
|
452
467
|
);
|
|
453
468
|
|
|
454
|
-
// A paused cursor is never acked on the optimistic row:
|
|
469
|
+
// A paused cursor is never acked on the optimistic row: the transition
|
|
455
470
|
// has pointed it at the destination while `uid` still names the source,
|
|
456
471
|
// and that pair strands the row for good — the cursor rebuild matches
|
|
457
472
|
// rows by `(accountConfigId, mailboxId)`, so a row naming the
|
|
@@ -499,7 +514,7 @@ export const handleMessageMove = async (
|
|
|
499
514
|
|
|
500
515
|
if (unproven) {
|
|
501
516
|
await handBackToSource(
|
|
502
|
-
MessageSyncStatus.
|
|
517
|
+
MessageSyncStatus.abandoned,
|
|
503
518
|
{ alert: "message_move_paused_placement_unproven" },
|
|
504
519
|
placement.kind === "at-destination"
|
|
505
520
|
? "destination holds this Message-ID but the source's uid axis has moved, so the sighting does not prove this move ran"
|
|
@@ -637,9 +652,11 @@ export const handleMessageMove = async (
|
|
|
637
652
|
// alarm; queue redelivery retries, and `failed` marks the row
|
|
638
653
|
// as unsettled meanwhile. It is not a terminal signal: only the
|
|
639
654
|
// resolver below settles anything.
|
|
640
|
-
await messageService.
|
|
641
|
-
|
|
642
|
-
|
|
655
|
+
await messageService.transitionPlacement(
|
|
656
|
+
messageId,
|
|
657
|
+
{ status: MessageStatus.moving },
|
|
658
|
+
{ syncStatus: MessageSyncStatus.failed },
|
|
659
|
+
);
|
|
643
660
|
throw error;
|
|
644
661
|
}
|
|
645
662
|
|
|
@@ -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 {
|
|
@@ -15,7 +16,6 @@ import { isAccountDeleted } from "../account-check.js";
|
|
|
15
16
|
import { createConnectionScopeWithCredentials } from "../connection-scope.js";
|
|
16
17
|
import { emitEvent } from "../emit.js";
|
|
17
18
|
import type { PlacementMovePushEvent } from "../events.js";
|
|
18
|
-
import { isNotFoundError } from "../is-not-found.js";
|
|
19
19
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
20
20
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
21
21
|
import {
|
|
@@ -399,6 +399,7 @@ export const handlePlacementMovePush = async (
|
|
|
399
399
|
accountConfigId,
|
|
400
400
|
messageId,
|
|
401
401
|
uid: message.uid,
|
|
402
|
+
sourceMailboxId: marker.sourceMailboxId,
|
|
402
403
|
sourceMailboxPath: sourceMailbox.fullPath,
|
|
403
404
|
getConnection: scope.getConnection,
|
|
404
405
|
},
|
|
@@ -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 {
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
QuarantineService,
|
|
12
13
|
resolveExhaustedBodySyncFailures,
|
|
13
14
|
} from "@remit/mailbox-service";
|
|
15
|
+
import { buildFilterConfig } from "@remit/mailbox-service/filter-config";
|
|
14
16
|
import { attemptBudget } from "@remit/sqs-client/attempt-budget";
|
|
15
17
|
import { env } from "expect-env";
|
|
16
18
|
import { isAccountDeleted } from "../account-check.js";
|
|
@@ -20,8 +22,6 @@ import {
|
|
|
20
22
|
createConnectionScopeWithCredentials,
|
|
21
23
|
} from "../connection-scope.js";
|
|
22
24
|
import type { SyncMessageBodyEvent } from "../events.js";
|
|
23
|
-
import { buildFilterConfig } from "../filter-config.js";
|
|
24
|
-
import { isNotFoundError } from "../is-not-found.js";
|
|
25
25
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
26
26
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
27
27
|
import { workerVersion } from "../worker-version.js";
|
|
@@ -13,6 +13,7 @@ import type {
|
|
|
13
13
|
IThreadMessageRepository,
|
|
14
14
|
IUnitOfWork,
|
|
15
15
|
} from "@remit/data-ports";
|
|
16
|
+
import { isNotFoundError } from "@remit/data-ports/errors";
|
|
16
17
|
import { SyncPhase } from "@remit/domain-enums";
|
|
17
18
|
import { type Logger, recordImapFailure } from "@remit/logger-lambda";
|
|
18
19
|
import { RefreshTokenError } from "@remit/mail-oauth-service";
|
|
@@ -33,7 +34,6 @@ import type {
|
|
|
33
34
|
SyncMessageBodyEvent,
|
|
34
35
|
SyncMessagesEvent,
|
|
35
36
|
} from "../events.js";
|
|
36
|
-
import { isNotFoundError } from "../is-not-found.js";
|
|
37
37
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
38
38
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
39
39
|
import { workerVersion } from "../worker-version.js";
|