@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/imap-worker",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.73",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"exports": {
|
|
@@ -39,7 +39,6 @@
|
|
|
39
39
|
"@remit/logger-lambda": "*",
|
|
40
40
|
"@remit/mailbox-service": "*",
|
|
41
41
|
"@remit/search-index-worker": "*",
|
|
42
|
-
"@remit/search-service": "*",
|
|
43
42
|
"@remit/sqs-client": "*",
|
|
44
43
|
"@remit/backend": "*",
|
|
45
44
|
"@remit/domain-enums": "*",
|
|
@@ -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
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getClient } from "@remit/backend/client";
|
|
2
|
+
import { isNotFoundError } from "@remit/data-ports/errors";
|
|
2
3
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
3
4
|
import type { Logger } from "@remit/logger-lambda";
|
|
4
5
|
import { MailboxManagementService } from "@remit/mailbox-service";
|
|
@@ -10,7 +11,6 @@ import type {
|
|
|
10
11
|
MailboxManagementEvent,
|
|
11
12
|
MailboxRenameEvent,
|
|
12
13
|
} from "../events.js";
|
|
13
|
-
import { isNotFoundError } from "../is-not-found.js";
|
|
14
14
|
import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
|
|
15
15
|
import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
|
|
16
16
|
|
|
@@ -112,6 +112,10 @@ const deps = (): MessageCopyDeps =>
|
|
|
112
112
|
},
|
|
113
113
|
updateUid: record("message.updateUid"),
|
|
114
114
|
update: record("message.update"),
|
|
115
|
+
transitionPlacement: async (...args: unknown[]) => {
|
|
116
|
+
h.calls.push({ method: "message.transitionPlacement", args });
|
|
117
|
+
return { messageId: "new-msg" };
|
|
118
|
+
},
|
|
115
119
|
delete: record("message.delete"),
|
|
116
120
|
},
|
|
117
121
|
threadMessage: {
|
|
@@ -191,11 +195,9 @@ describe("handleMessageCopy", () => {
|
|
|
191
195
|
20,
|
|
192
196
|
"dst-mbx",
|
|
193
197
|
]);
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
"synced",
|
|
198
|
-
);
|
|
198
|
+
// `updateUid` writes the confirmed uid, `active` and `synced` in one
|
|
199
|
+
// statement, so there is no second, unpredicated write behind it.
|
|
200
|
+
assert.equal(called("message.update").length, 0);
|
|
199
201
|
assert.equal(called("threadMessage.update").length, 1);
|
|
200
202
|
assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
|
|
201
203
|
});
|
|
@@ -212,11 +214,7 @@ describe("handleMessageCopy", () => {
|
|
|
212
214
|
42,
|
|
213
215
|
"dst-mbx",
|
|
214
216
|
]);
|
|
215
|
-
assert.equal(
|
|
216
|
-
(called("message.update")[0]?.args[1] as { syncStatus?: string })
|
|
217
|
-
?.syncStatus,
|
|
218
|
-
"synced",
|
|
219
|
-
);
|
|
217
|
+
assert.equal(called("message.update").length, 0);
|
|
220
218
|
assert.equal(called("threadMessage.update").length, 1);
|
|
221
219
|
});
|
|
222
220
|
|
|
@@ -263,11 +261,15 @@ describe("handleMessageCopy", () => {
|
|
|
263
261
|
|
|
264
262
|
assert.equal(copies, 1, "the COPY is issued once");
|
|
265
263
|
assert.equal(called("search").length, 0, "there is nothing to ask with");
|
|
266
|
-
const
|
|
267
|
-
assert.
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
264
|
+
const [givenUp] = called("message.transitionPlacement");
|
|
265
|
+
assert.deepEqual(
|
|
266
|
+
givenUp?.args[2],
|
|
267
|
+
{
|
|
268
|
+
status: "deleted",
|
|
269
|
+
syncStatus: "abandoned",
|
|
270
|
+
abandonedMutation: "copy",
|
|
271
|
+
},
|
|
272
|
+
"a copy that gave up carries the give-up value and names itself",
|
|
271
273
|
);
|
|
272
274
|
assert.equal(called("message.delete").length, 0, "no row is thrown away");
|
|
273
275
|
});
|
|
@@ -302,8 +304,8 @@ describe("handleMessageCopy", () => {
|
|
|
302
304
|
|
|
303
305
|
assert.equal(copies, 1, "the redelivery issues no second COPY");
|
|
304
306
|
assert.equal(called("search").length, 0);
|
|
305
|
-
const settled = called("message.
|
|
306
|
-
assert.equal((settled?.args[
|
|
307
|
+
const settled = called("message.transitionPlacement").at(-1);
|
|
308
|
+
assert.equal((settled?.args[2] as { status?: string })?.status, "deleted");
|
|
307
309
|
});
|
|
308
310
|
|
|
309
311
|
it("acks a copy that already settled without touching IMAP", async () => {
|
|
@@ -327,6 +329,7 @@ describe("handleMessageCopy", () => {
|
|
|
327
329
|
|
|
328
330
|
assert.equal(h.getConnectionCount, 0);
|
|
329
331
|
assert.equal(called("message.update").length, 0);
|
|
332
|
+
assert.equal(called("message.transitionPlacement").length, 0);
|
|
330
333
|
});
|
|
331
334
|
|
|
332
335
|
it("returns early without connecting when the account is soft-deleted", async () => {
|
|
@@ -358,6 +361,7 @@ describe("handleMessageCopy", () => {
|
|
|
358
361
|
assert.equal(h.getConnectionCount, 0);
|
|
359
362
|
assert.equal(called("message.updateUid").length, 0);
|
|
360
363
|
assert.equal(called("message.update").length, 0);
|
|
364
|
+
assert.equal(called("message.transitionPlacement").length, 0);
|
|
361
365
|
});
|
|
362
366
|
|
|
363
367
|
// Issue #1203. Both pauses are reached before `copyMessages`, so the COPY
|
|
@@ -427,7 +431,11 @@ describe("handleMessageCopy", () => {
|
|
|
427
431
|
"silence is never grounds to delete a row that may describe real mail",
|
|
428
432
|
);
|
|
429
433
|
assert.equal(
|
|
430
|
-
(
|
|
434
|
+
(
|
|
435
|
+
called("message.transitionPlacement")[0]?.args[2] as {
|
|
436
|
+
status?: string;
|
|
437
|
+
}
|
|
438
|
+
)?.status,
|
|
431
439
|
"deleted",
|
|
432
440
|
);
|
|
433
441
|
});
|
|
@@ -505,11 +513,33 @@ describe("handleMessageCopy", () => {
|
|
|
505
513
|
|
|
506
514
|
await handleMessageCopy(event, noopLogger, 1, deps());
|
|
507
515
|
|
|
508
|
-
const
|
|
509
|
-
assert.equal((
|
|
516
|
+
const [givenUp] = called("message.transitionPlacement");
|
|
517
|
+
assert.equal((givenUp?.args[2] as { status?: string })?.status, "deleted");
|
|
518
|
+
assert.equal(
|
|
519
|
+
(givenUp?.args[2] as { abandonedMutation?: string })?.abandonedMutation,
|
|
520
|
+
"copy",
|
|
521
|
+
);
|
|
510
522
|
assert.equal(called("createMailbox").length, 0);
|
|
511
523
|
});
|
|
512
524
|
|
|
525
|
+
// The guard asks whether THIS copy is still outstanding, not whether anything
|
|
526
|
+
// is. A row a delete has claimed is `deleting`, and reading that as unsettled
|
|
527
|
+
// let a redelivery COPY the message a second time — COPY has no source-side
|
|
528
|
+
// effect to make that a no-op, so the duplicate is permanent.
|
|
529
|
+
it("skips a redelivered copy whose row a delete has claimed", async () => {
|
|
530
|
+
let copies = 0;
|
|
531
|
+
h.connection.copyMessages = async () => {
|
|
532
|
+
copies += 1;
|
|
533
|
+
return { uidMap: new Map([[10, 20]]) };
|
|
534
|
+
};
|
|
535
|
+
h.copyRow = { ...unsettledCopyRow(), status: "deleting" };
|
|
536
|
+
|
|
537
|
+
await handleMessageCopy(event, noopLogger, 2, deps());
|
|
538
|
+
|
|
539
|
+
assert.equal(copies, 0, "no second copy is issued");
|
|
540
|
+
assert.equal(called("message.updateUid").length, 0);
|
|
541
|
+
});
|
|
542
|
+
|
|
513
543
|
it("marks failed and rethrows on an unclassified IMAP error within the budget", async () => {
|
|
514
544
|
h.connection.copyMessages = async () => {
|
|
515
545
|
throw new Error("server exploded");
|
|
@@ -520,11 +550,12 @@ describe("handleMessageCopy", () => {
|
|
|
520
550
|
/server exploded/,
|
|
521
551
|
);
|
|
522
552
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
);
|
|
553
|
+
// The attempt marker is a transition too, predicated on this copy still
|
|
554
|
+
// being outstanding: a row another mutation has claimed keeps its own
|
|
555
|
+
// placement rather than picking up this attempt's failure.
|
|
556
|
+
const [marker] = called("message.transitionPlacement");
|
|
557
|
+
assert.deepEqual(marker?.args[1], { status: "moving" });
|
|
558
|
+
assert.deepEqual(marker?.args[2], { syncStatus: "failed" });
|
|
528
559
|
});
|
|
529
560
|
|
|
530
561
|
it("settles on the destination UID instead of dead-lettering the last attempt", async () => {
|