@remit/imap-worker 0.0.67 → 0.0.69
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/message-copy.test.ts +99 -2
- package/src/handlers/message-copy.ts +82 -12
- package/src/handlers/message-delete-terminal.ts +11 -19
- package/src/handlers/message-delete.test.ts +435 -47
- package/src/handlers/message-delete.ts +259 -89
- package/src/handlers/message-move-terminal.test.ts +163 -18
- package/src/handlers/message-move-terminal.ts +46 -10
- package/src/handlers/message-move.test.ts +363 -1
- package/src/handlers/message-move.ts +339 -60
- package/src/handlers/restore-source-placement.ts +135 -0
|
@@ -4,7 +4,10 @@ import type {
|
|
|
4
4
|
IMessageRepository,
|
|
5
5
|
IThreadMessageRepository,
|
|
6
6
|
} from "@remit/data-ports";
|
|
7
|
-
import
|
|
7
|
+
import {
|
|
8
|
+
type IImapConnection,
|
|
9
|
+
placementBindingOf,
|
|
10
|
+
} from "@remit/mailbox-service";
|
|
8
11
|
import {
|
|
9
12
|
type MessageMoveTerminalLogger,
|
|
10
13
|
type ResolveExhaustedMessageMoveDeps,
|
|
@@ -60,21 +63,33 @@ interface MessageRow {
|
|
|
60
63
|
originalUid: number;
|
|
61
64
|
}
|
|
62
65
|
|
|
66
|
+
interface ThreadRow {
|
|
67
|
+
accountConfigId: string;
|
|
68
|
+
threadMessageId: string;
|
|
69
|
+
mailboxId: string;
|
|
70
|
+
uid: number;
|
|
71
|
+
isDeleted: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
63
74
|
/**
|
|
64
75
|
* The move's pending state IS the Message row, so these fakes hold real rows
|
|
65
|
-
* and the assertions read the rows back — a resolver that
|
|
66
|
-
*
|
|
67
|
-
*
|
|
76
|
+
* and the assertions read the rows back — a resolver that settled the row on
|
|
77
|
+
* anything but the server's own answer shows up here as a changed row, not as
|
|
78
|
+
* an uncalled mock. `updateForMove` writes what the repository writes: whatever
|
|
79
|
+
* fields the caller set, and nothing it left out.
|
|
68
80
|
*/
|
|
69
81
|
const buildRepositories = (row: MessageRow) => {
|
|
70
82
|
const messages = new Map<string, MessageRow>([[row.messageId, row]]);
|
|
71
|
-
const threadMessages = new Map<
|
|
72
|
-
string,
|
|
73
|
-
{ accountConfigId: string; threadMessageId: string }
|
|
74
|
-
>([
|
|
83
|
+
const threadMessages = new Map<string, ThreadRow>([
|
|
75
84
|
[
|
|
76
85
|
`tm-${row.messageId}`,
|
|
77
|
-
{
|
|
86
|
+
{
|
|
87
|
+
accountConfigId: "cfg-1",
|
|
88
|
+
threadMessageId: `tm-${row.messageId}`,
|
|
89
|
+
mailboxId: row.mailboxId,
|
|
90
|
+
uid: row.uid,
|
|
91
|
+
isDeleted: false,
|
|
92
|
+
},
|
|
78
93
|
],
|
|
79
94
|
]);
|
|
80
95
|
|
|
@@ -89,7 +104,11 @@ const buildRepositories = (row: MessageRow) => {
|
|
|
89
104
|
const current = messages.get(messageId);
|
|
90
105
|
if (current) messages.set(messageId, { ...current, ...input });
|
|
91
106
|
},
|
|
92
|
-
|
|
107
|
+
updateForMove: async (messageId: string, set: Partial<MessageRow>) => {
|
|
108
|
+
const current = messages.get(messageId);
|
|
109
|
+
if (current) messages.set(messageId, { ...current, ...set });
|
|
110
|
+
},
|
|
111
|
+
} as unknown as Pick<IMessageRepository, "delete" | "updateForMove">,
|
|
93
112
|
threadMessageService: {
|
|
94
113
|
findAllByMessageId: async () => [...threadMessages.values()],
|
|
95
114
|
deleteMany: async (
|
|
@@ -97,9 +116,18 @@ const buildRepositories = (row: MessageRow) => {
|
|
|
97
116
|
) => {
|
|
98
117
|
for (const key of keys) threadMessages.delete(key.threadMessageId);
|
|
99
118
|
},
|
|
119
|
+
update: async (
|
|
120
|
+
_accountConfigId: string,
|
|
121
|
+
threadMessageId: string,
|
|
122
|
+
set: Partial<ThreadRow>,
|
|
123
|
+
) => {
|
|
124
|
+
const current = threadMessages.get(threadMessageId);
|
|
125
|
+
if (current)
|
|
126
|
+
threadMessages.set(threadMessageId, { ...current, ...set });
|
|
127
|
+
},
|
|
100
128
|
} as unknown as Pick<
|
|
101
129
|
IThreadMessageRepository,
|
|
102
|
-
"findAllByMessageId" | "deleteMany"
|
|
130
|
+
"findAllByMessageId" | "deleteMany" | "update"
|
|
103
131
|
>,
|
|
104
132
|
};
|
|
105
133
|
};
|
|
@@ -118,6 +146,7 @@ const input = {
|
|
|
118
146
|
accountId: "acc-1",
|
|
119
147
|
accountConfigId: "cfg-1",
|
|
120
148
|
messageId: "msg-1",
|
|
149
|
+
sourceMailboxId: "mbx-inbox",
|
|
121
150
|
uid: 101,
|
|
122
151
|
sourceMailboxPath: "INBOX",
|
|
123
152
|
};
|
|
@@ -150,7 +179,7 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
|
|
|
150
179
|
);
|
|
151
180
|
});
|
|
152
181
|
|
|
153
|
-
it("BROKEN: the message is still at the source — the row is
|
|
182
|
+
it("BROKEN: the message is still at the source — the row is put back on the pair the server confirmed, alarm logged, never re-thrown", async () => {
|
|
154
183
|
const repos = buildRepositories(pendingMoveRow());
|
|
155
184
|
const { log, errors } = buildLogger();
|
|
156
185
|
const deps: ResolveExhaustedMessageMoveDeps = {
|
|
@@ -165,15 +194,45 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
|
|
|
165
194
|
});
|
|
166
195
|
|
|
167
196
|
assert.equal(result.outcome, "broken");
|
|
168
|
-
assert.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
"
|
|
197
|
+
assert.equal(repos.messages.get("msg-1")?.mailboxId, "mbx-inbox");
|
|
198
|
+
assert.equal(repos.messages.get("msg-1")?.uid, 101);
|
|
199
|
+
assert.equal(
|
|
200
|
+
repos.threadMessages.get("tm-msg-1")?.mailboxId,
|
|
201
|
+
"mbx-inbox",
|
|
202
|
+
"the listing row follows the message back to the folder it never left",
|
|
172
203
|
);
|
|
173
|
-
assert.equal(repos.threadMessages.size, 1);
|
|
174
204
|
assert.ok(errors.some((e) => e.obj.alert === "message_move_failed"));
|
|
175
205
|
});
|
|
176
206
|
|
|
207
|
+
// Issue #1005: a give-up that never writes `status` leaves the row naming
|
|
208
|
+
// the destination with the source's uid — a pair `bindsForeignUid` refuses,
|
|
209
|
+
// which made the message undeletable and unmovable for good.
|
|
210
|
+
it("BROKEN: the settled row is no longer refused by the placement guard (#1005)", async () => {
|
|
211
|
+
const repos = buildRepositories(pendingMoveRow());
|
|
212
|
+
const { log } = buildLogger();
|
|
213
|
+
const deps: ResolveExhaustedMessageMoveDeps = {
|
|
214
|
+
messageService: repos.messageService,
|
|
215
|
+
threadMessageService: repos.threadMessageService,
|
|
216
|
+
log,
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
assert.equal(
|
|
220
|
+
placementBindingOf(pendingMoveRow() as never),
|
|
221
|
+
"abandoned",
|
|
222
|
+
"the row starts in the state the delete route refuses as unverified",
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
await resolveExhaustedMessageMoveFailure(deps, {
|
|
226
|
+
...input,
|
|
227
|
+
getConnection: async () => buildConnection(new Set([101])),
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const settled = repos.messages.get("msg-1");
|
|
231
|
+
assert.ok(settled);
|
|
232
|
+
assert.equal(placementBindingOf(settled as never), "consistent");
|
|
233
|
+
assert.equal(settled.status, "active");
|
|
234
|
+
});
|
|
235
|
+
|
|
177
236
|
it("BROKEN: an empty FETCH the SEARCH contradicts never counts as gone", async () => {
|
|
178
237
|
const repos = buildRepositories(pendingMoveRow());
|
|
179
238
|
const { log } = buildLogger();
|
|
@@ -190,7 +249,93 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
|
|
|
190
249
|
});
|
|
191
250
|
|
|
192
251
|
assert.equal(result.outcome, "broken");
|
|
193
|
-
assert.
|
|
252
|
+
assert.equal(
|
|
253
|
+
repos.messages.get("msg-1")?.mailboxId,
|
|
254
|
+
"mbx-inbox",
|
|
255
|
+
"a message the SEARCH still lists is at the source, and the row says so",
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// 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 while the
|
|
261
|
+
// probe was in flight has nothing left to restore, and the same NotFound
|
|
262
|
+
// wraps an ElectroDB composites miss on the listing row.
|
|
263
|
+
it("BROKEN: a row deleted underneath the probe settles instead of throwing", async () => {
|
|
264
|
+
const repos = buildRepositories(pendingMoveRow());
|
|
265
|
+
const notFound = Object.assign(new Error("Message not found: msg-1"), {
|
|
266
|
+
name: "NotFoundError",
|
|
267
|
+
});
|
|
268
|
+
const { log } = buildLogger();
|
|
269
|
+
const deps: ResolveExhaustedMessageMoveDeps = {
|
|
270
|
+
messageService: {
|
|
271
|
+
...repos.messageService,
|
|
272
|
+
updateForMove: async () => {
|
|
273
|
+
throw notFound;
|
|
274
|
+
},
|
|
275
|
+
} as unknown as ResolveExhaustedMessageMoveDeps["messageService"],
|
|
276
|
+
threadMessageService: repos.threadMessageService,
|
|
277
|
+
log,
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const result = await resolveExhaustedMessageMoveFailure(deps, {
|
|
281
|
+
...input,
|
|
282
|
+
getConnection: async () => buildConnection(new Set([101])),
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
assert.equal(result.outcome, "broken");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("BROKEN: a listing row whose composites moved on does not fail the settle", async () => {
|
|
289
|
+
const repos = buildRepositories(pendingMoveRow());
|
|
290
|
+
const { log } = buildLogger();
|
|
291
|
+
const deps: ResolveExhaustedMessageMoveDeps = {
|
|
292
|
+
messageService: repos.messageService,
|
|
293
|
+
threadMessageService: {
|
|
294
|
+
...repos.threadMessageService,
|
|
295
|
+
update: async () => {
|
|
296
|
+
throw Object.assign(new Error("Thread message not found"), {
|
|
297
|
+
name: "NotFoundError",
|
|
298
|
+
});
|
|
299
|
+
},
|
|
300
|
+
} as unknown as ResolveExhaustedMessageMoveDeps["threadMessageService"],
|
|
301
|
+
log,
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const result = await resolveExhaustedMessageMoveFailure(deps, {
|
|
305
|
+
...input,
|
|
306
|
+
getConnection: async () => buildConnection(new Set([101])),
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
assert.equal(result.outcome, "broken");
|
|
310
|
+
assert.equal(
|
|
311
|
+
repos.messages.get("msg-1")?.mailboxId,
|
|
312
|
+
"mbx-inbox",
|
|
313
|
+
"the Message row still lands on the pair the server confirmed",
|
|
314
|
+
);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("a repository fault that is not a missing row still propagates", async () => {
|
|
318
|
+
const repos = buildRepositories(pendingMoveRow());
|
|
319
|
+
const { log } = buildLogger();
|
|
320
|
+
const deps: ResolveExhaustedMessageMoveDeps = {
|
|
321
|
+
messageService: {
|
|
322
|
+
...repos.messageService,
|
|
323
|
+
updateForMove: async () => {
|
|
324
|
+
throw new Error("ProvisionedThroughputExceeded");
|
|
325
|
+
},
|
|
326
|
+
} as unknown as ResolveExhaustedMessageMoveDeps["messageService"],
|
|
327
|
+
threadMessageService: repos.threadMessageService,
|
|
328
|
+
log,
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
await assert.rejects(
|
|
332
|
+
() =>
|
|
333
|
+
resolveExhaustedMessageMoveFailure(deps, {
|
|
334
|
+
...input,
|
|
335
|
+
getConnection: async () => buildConnection(new Set([101])),
|
|
336
|
+
}),
|
|
337
|
+
/ProvisionedThroughputExceeded/,
|
|
338
|
+
);
|
|
194
339
|
});
|
|
195
340
|
|
|
196
341
|
it("an unreachable server reaches no verdict at all — the probe propagates and the row is untouched", async () => {
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IMessageRepository,
|
|
3
|
+
IThreadMessageRepository,
|
|
4
|
+
} from "@remit/data-ports";
|
|
5
|
+
import { MessageSyncStatus } from "@remit/domain-enums";
|
|
1
6
|
import {
|
|
2
7
|
type IImapConnection,
|
|
3
8
|
isMessageGoneFromOpenMailbox,
|
|
4
9
|
reconcileStaleMessage,
|
|
5
10
|
type StaleMessageReconcileDeps,
|
|
6
11
|
} from "@remit/mailbox-service";
|
|
12
|
+
import { restoreSourcePlacement } from "./restore-source-placement.js";
|
|
7
13
|
|
|
8
14
|
export interface MessageMoveTerminalLogger {
|
|
9
15
|
info(obj: Record<string, unknown>, msg: string): void;
|
|
@@ -12,6 +18,11 @@ export interface MessageMoveTerminalLogger {
|
|
|
12
18
|
|
|
13
19
|
export interface ResolveExhaustedMessageMoveDeps
|
|
14
20
|
extends StaleMessageReconcileDeps {
|
|
21
|
+
messageService: Pick<IMessageRepository, "delete" | "updateForMove">;
|
|
22
|
+
threadMessageService: Pick<
|
|
23
|
+
IThreadMessageRepository,
|
|
24
|
+
"findAllByMessageId" | "deleteMany" | "update"
|
|
25
|
+
>;
|
|
15
26
|
log: MessageMoveTerminalLogger;
|
|
16
27
|
}
|
|
17
28
|
|
|
@@ -19,6 +30,7 @@ export interface ResolveExhaustedMessageMoveInput {
|
|
|
19
30
|
accountId: string;
|
|
20
31
|
accountConfigId: string;
|
|
21
32
|
messageId: string;
|
|
33
|
+
sourceMailboxId: string;
|
|
22
34
|
uid: number;
|
|
23
35
|
sourceMailboxPath: string;
|
|
24
36
|
getConnection: () => Promise<IImapConnection>;
|
|
@@ -51,12 +63,21 @@ export interface ResolveExhaustedMessageMoveResult {
|
|
|
51
63
|
* server's own UID. Metric only, no alarm — routine.
|
|
52
64
|
* 2. BROKEN — the message is still at the source, so the move never took
|
|
53
65
|
* effect, but it keeps failing: broken code or a broken account, not a
|
|
54
|
-
* transient blip.
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
66
|
+
* transient blip. The row is put back where the server has just said the
|
|
67
|
+
* message is, as `resolveExhaustedMessageDeleteFailure` does (#1098). Logged
|
|
68
|
+
* with an `alert`-shaped entry for an operator alarm; never re-thrown
|
|
69
|
+
* (terminal — the caller acks either way, since retrying a
|
|
70
|
+
* permanently-broken move can never succeed).
|
|
71
|
+
*
|
|
72
|
+
* Leaving the row `moving` is what this replaces. BROKEN is terminal, so
|
|
73
|
+
* nothing was ever going to come back and settle it, and only `updateUid`
|
|
74
|
+
* clears `moving` — the row kept a `mailboxId` naming the destination and a
|
|
75
|
+
* `uid` belonging to the source, which `bindsForeignUid` refuses to act on,
|
|
76
|
+
* leaving the message undeletable and unmovable for good (issue #1005).
|
|
77
|
+
* Restoring the pair is not the revert-on-ambiguity PR #652 was pulled for:
|
|
78
|
+
* the presence probe above has just answered, so this writes a placement the
|
|
79
|
+
* server confirmed, and a MOVE that lands after all is re-projected by the
|
|
80
|
+
* resync the caller runs.
|
|
60
81
|
*
|
|
61
82
|
* A server that cannot be reached at exhaustion time never reaches either
|
|
62
83
|
* verdict: the probe throws and the record dead-letters with the row untouched.
|
|
@@ -66,9 +87,10 @@ export interface ResolveExhaustedMessageMoveResult {
|
|
|
66
87
|
* message is not actually at the source: a message another client expunged
|
|
67
88
|
* mid-session can answer an empty FETCH while the server still lists its UID
|
|
68
89
|
* in SEARCH, until it is allowed to send the untagged EXPUNGE. That message
|
|
69
|
-
* lands in BROKEN, and BROKEN is terminal — the row
|
|
70
|
-
* alert stands until someone clears it. The
|
|
71
|
-
* for live mail, so the cost is paid
|
|
90
|
+
* lands in BROKEN, and BROKEN is terminal — the row is bound to a source the
|
|
91
|
+
* message has already left and the alert stands until someone clears it. The
|
|
92
|
+
* reverse mistake discards the row for live mail, so the cost is paid
|
|
93
|
+
* deliberately.
|
|
72
94
|
*/
|
|
73
95
|
export const resolveExhaustedMessageMoveFailure = async (
|
|
74
96
|
deps: ResolveExhaustedMessageMoveDeps,
|
|
@@ -78,6 +100,7 @@ export const resolveExhaustedMessageMoveFailure = async (
|
|
|
78
100
|
accountId,
|
|
79
101
|
accountConfigId,
|
|
80
102
|
messageId,
|
|
103
|
+
sourceMailboxId,
|
|
81
104
|
uid,
|
|
82
105
|
sourceMailboxPath,
|
|
83
106
|
getConnection,
|
|
@@ -114,9 +137,22 @@ export const resolveExhaustedMessageMoveFailure = async (
|
|
|
114
137
|
accountConfigId,
|
|
115
138
|
messageId,
|
|
116
139
|
uid,
|
|
140
|
+
sourceMailboxId,
|
|
117
141
|
sourceMailboxPath,
|
|
118
142
|
},
|
|
119
|
-
"Message move could not be pushed to IMAP after retry exhaustion; message still
|
|
143
|
+
"Message move could not be pushed to IMAP after retry exhaustion; the message is still at its source — row moved back to the source folder",
|
|
120
144
|
);
|
|
145
|
+
|
|
146
|
+
await restoreSourcePlacement(deps, {
|
|
147
|
+
accountConfigId,
|
|
148
|
+
messageId,
|
|
149
|
+
sourceMailboxId,
|
|
150
|
+
uid,
|
|
151
|
+
// The probe above just confirmed the pair, so the row is a faithful
|
|
152
|
+
// projection of the source again. The move's own failure is the alert, not
|
|
153
|
+
// a `failed` left on a row nothing is still trying to move.
|
|
154
|
+
syncStatus: MessageSyncStatus.synced,
|
|
155
|
+
});
|
|
156
|
+
|
|
121
157
|
return { outcome: "broken" };
|
|
122
158
|
};
|