@remit/imap-worker 0.0.67 → 0.0.68
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-delete-terminal.ts +5 -18
- package/src/handlers/message-delete.test.ts +42 -0
- package/src/handlers/message-delete.ts +36 -5
- package/src/handlers/message-move-terminal.test.ts +175 -18
- package/src/handlers/message-move-terminal.ts +41 -10
- package/src/handlers/message-move.ts +60 -19
- package/src/handlers/restore-source-placement.ts +89 -0
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
reconcileStaleMessage,
|
|
9
9
|
type StaleMessageReconcileDeps,
|
|
10
10
|
} from "@remit/mailbox-service";
|
|
11
|
-
import {
|
|
11
|
+
import { restoreSourcePlacement } from "./restore-source-placement.js";
|
|
12
12
|
|
|
13
13
|
export interface MessageDeleteTerminalLogger {
|
|
14
14
|
info(obj: Record<string, unknown>, msg: string): void;
|
|
@@ -125,25 +125,12 @@ export const resolveExhaustedMessageDeleteFailure = async (
|
|
|
125
125
|
"Delete could not be pushed to IMAP after retry exhaustion; the message is still at its source — row moved back to the source folder",
|
|
126
126
|
);
|
|
127
127
|
|
|
128
|
-
await deps
|
|
129
|
-
|
|
130
|
-
const threadMessages = await deps.threadMessageService.findAllByMessageId(
|
|
128
|
+
await restoreSourcePlacement(deps, {
|
|
131
129
|
accountConfigId,
|
|
132
130
|
messageId,
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
threadMessage,
|
|
137
|
-
uid,
|
|
138
|
-
sourceMailboxId,
|
|
139
|
-
);
|
|
140
|
-
await deps.threadMessageService.update(
|
|
141
|
-
threadMessage.accountConfigId,
|
|
142
|
-
threadMessage.threadMessageId,
|
|
143
|
-
args.set,
|
|
144
|
-
{ composites: args.composites },
|
|
145
|
-
);
|
|
146
|
-
}
|
|
131
|
+
sourceMailboxId,
|
|
132
|
+
uid,
|
|
133
|
+
});
|
|
147
134
|
|
|
148
135
|
return { outcome: "broken" };
|
|
149
136
|
};
|
|
@@ -303,6 +303,10 @@ interface Harness {
|
|
|
303
303
|
allThreadMessages: Record<string, unknown>[];
|
|
304
304
|
getConnectionCount: number;
|
|
305
305
|
disconnectCount: number;
|
|
306
|
+
// Rejections handed to `getConnection`, one per call, oldest first. A
|
|
307
|
+
// dropped connection is how the guarded openBox gets skipped on a delivery
|
|
308
|
+
// that still goes on to settle the row.
|
|
309
|
+
connectionErrors: Error[];
|
|
306
310
|
}
|
|
307
311
|
|
|
308
312
|
let h: Harness;
|
|
@@ -367,6 +371,7 @@ const fresh = (): Harness => ({
|
|
|
367
371
|
],
|
|
368
372
|
getConnectionCount: 0,
|
|
369
373
|
disconnectCount: 0,
|
|
374
|
+
connectionErrors: [],
|
|
370
375
|
});
|
|
371
376
|
|
|
372
377
|
const deps = (): MessageDeleteDeps =>
|
|
@@ -417,6 +422,8 @@ const deps = (): MessageDeleteDeps =>
|
|
|
417
422
|
createConnectionScope: () => ({
|
|
418
423
|
getConnection: async () => {
|
|
419
424
|
h.getConnectionCount += 1;
|
|
425
|
+
const failure = h.connectionErrors.shift();
|
|
426
|
+
if (failure) throw failure;
|
|
420
427
|
return h.connection;
|
|
421
428
|
},
|
|
422
429
|
disconnect: async () => {
|
|
@@ -1178,6 +1185,41 @@ describe("handleMessageDelete", () => {
|
|
|
1178
1185
|
assert.equal(called("message.updateUid").length, 0);
|
|
1179
1186
|
});
|
|
1180
1187
|
|
|
1188
|
+
// The terminal resolver opens the source itself, and since #1005 its answer
|
|
1189
|
+
// WRITES a placement. A folder deleted and recreated renumbers every uid, so
|
|
1190
|
+
// an unguarded probe finds whatever now sits at this one, reads it as "still
|
|
1191
|
+
// at source", and binds the row to a stranger the next permanent delete
|
|
1192
|
+
// expunges. The dropped connection is what makes it reachable: it skips the
|
|
1193
|
+
// guarded openBox on the very delivery that goes on to settle.
|
|
1194
|
+
it("never settles an exhausted delete off a probe that skipped the cursor guard", async () => {
|
|
1195
|
+
h.connectionErrors = [new Error("ECONNRESET")];
|
|
1196
|
+
h.connection.openBox = async () => ({ uidvalidity: 999 });
|
|
1197
|
+
|
|
1198
|
+
await handleMessageDelete(
|
|
1199
|
+
moveEvent,
|
|
1200
|
+
noopLog,
|
|
1201
|
+
MESSAGE_DELETE_MAX_ATTEMPTS,
|
|
1202
|
+
deps(),
|
|
1203
|
+
);
|
|
1204
|
+
|
|
1205
|
+
assert.equal(
|
|
1206
|
+
called("message.updateUid").length,
|
|
1207
|
+
0,
|
|
1208
|
+
"a renumbered folder answers for a different message; nothing settles off it",
|
|
1209
|
+
);
|
|
1210
|
+
assert.equal(
|
|
1211
|
+
called("message.delete").length,
|
|
1212
|
+
0,
|
|
1213
|
+
"and nothing is reconciled away off it either",
|
|
1214
|
+
);
|
|
1215
|
+
assert.equal(
|
|
1216
|
+
(called("mailbox.update")[0]?.args[2] as { cursorState?: string })
|
|
1217
|
+
?.cursorState,
|
|
1218
|
+
"cursor_invalid",
|
|
1219
|
+
"the probe trips the mailbox into a rebuild instead",
|
|
1220
|
+
);
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1181
1223
|
it("skips without opening a connection when the cursor is rebuilding", async () => {
|
|
1182
1224
|
h.mailbox = {
|
|
1183
1225
|
mailboxId: "src-mbx",
|
|
@@ -259,7 +259,7 @@ export const handleMessageDelete = async (
|
|
|
259
259
|
accountConfigId: string,
|
|
260
260
|
getConnection: () => Promise<IImapConnection>,
|
|
261
261
|
): Promise<void> => {
|
|
262
|
-
const
|
|
262
|
+
const settled = await resolveExhaustedMessageDeleteFailure(
|
|
263
263
|
{ messageService, threadMessageService, log },
|
|
264
264
|
{
|
|
265
265
|
accountId,
|
|
@@ -270,9 +270,25 @@ export const handleMessageDelete = async (
|
|
|
270
270
|
sourceMailboxPath: mailboxPath,
|
|
271
271
|
getConnection,
|
|
272
272
|
},
|
|
273
|
-
)
|
|
273
|
+
).catch((settleError: unknown) => {
|
|
274
|
+
// The guarded probe found a mailbox whose UIDVALIDITY has moved. Every
|
|
275
|
+
// stored uid for this folder now names something else, so there is
|
|
276
|
+
// nothing here that may be settled off them, and the pause is the
|
|
277
|
+
// routine skip `guardMailboxCursor` documents, not a fault to re-throw
|
|
278
|
+
// out of the caller's catch.
|
|
279
|
+
if (settleError instanceof MailboxCursorPausedError) {
|
|
280
|
+
log.info(
|
|
281
|
+
{ accountId, messageId, mailboxId, cursorState: settleError.state },
|
|
282
|
+
"Mailbox cursor not normal; leaving the exhausted delete for the cursor rebuild",
|
|
283
|
+
);
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
throw settleError;
|
|
287
|
+
});
|
|
274
288
|
|
|
275
|
-
if (
|
|
289
|
+
if (!settled) return;
|
|
290
|
+
|
|
291
|
+
if (settled.outcome === "broken") {
|
|
276
292
|
recordImapFailure("MESSAGE_DELETE_EXHAUSTED", "other");
|
|
277
293
|
}
|
|
278
294
|
|
|
@@ -380,6 +396,21 @@ export const handleMessageDelete = async (
|
|
|
380
396
|
|
|
381
397
|
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
382
398
|
|
|
399
|
+
// The terminal resolver opens the source itself, and its answer now
|
|
400
|
+
// WRITES a placement, so it has to reach IMAP through the same
|
|
401
|
+
// UIDVALIDITY choke point every other outbound operation does. An
|
|
402
|
+
// unguarded probe on a folder that was deleted and recreated finds
|
|
403
|
+
// whatever the server has renumbered onto this uid, reads it as "still
|
|
404
|
+
// at source", and binds the row to a stranger the next permanent delete
|
|
405
|
+
// would expunge.
|
|
406
|
+
const getGuardedConnection = async (): Promise<IImapConnection> =>
|
|
407
|
+
guardConnectionCursor(
|
|
408
|
+
await scope.getConnection(),
|
|
409
|
+
{ mailboxService },
|
|
410
|
+
accountId,
|
|
411
|
+
mailbox,
|
|
412
|
+
);
|
|
413
|
+
|
|
383
414
|
await scope
|
|
384
415
|
.getConnection()
|
|
385
416
|
.then(async (rawConnection) => {
|
|
@@ -490,7 +521,7 @@ export const handleMessageDelete = async (
|
|
|
490
521
|
await settleUnconfirmedTrashMove(
|
|
491
522
|
confirmation,
|
|
492
523
|
account.accountConfigId,
|
|
493
|
-
|
|
524
|
+
getGuardedConnection,
|
|
494
525
|
);
|
|
495
526
|
} else {
|
|
496
527
|
// Permanent delete — reached only by `operation === "permanent_delete"`.
|
|
@@ -594,7 +625,7 @@ export const handleMessageDelete = async (
|
|
|
594
625
|
// failure this budget exists for.
|
|
595
626
|
await settleExhaustedDelete(
|
|
596
627
|
account.accountConfigId,
|
|
597
|
-
|
|
628
|
+
getGuardedConnection,
|
|
598
629
|
);
|
|
599
630
|
log.error(
|
|
600
631
|
{ accountId, messageId, uid, mailboxPath, error: errorMessage },
|
|
@@ -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. `updateUid` writes what the repository writes: the settled
|
|
79
|
+
* pair AND the `active`/`synced` that clears `moving`.
|
|
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,22 @@ const buildRepositories = (row: MessageRow) => {
|
|
|
89
104
|
const current = messages.get(messageId);
|
|
90
105
|
if (current) messages.set(messageId, { ...current, ...input });
|
|
91
106
|
},
|
|
92
|
-
|
|
107
|
+
updateUid: async (
|
|
108
|
+
messageId: string,
|
|
109
|
+
newUid: number,
|
|
110
|
+
newMailboxId: string,
|
|
111
|
+
) => {
|
|
112
|
+
const current = messages.get(messageId);
|
|
113
|
+
if (current)
|
|
114
|
+
messages.set(messageId, {
|
|
115
|
+
...current,
|
|
116
|
+
uid: newUid,
|
|
117
|
+
mailboxId: newMailboxId,
|
|
118
|
+
status: "active",
|
|
119
|
+
syncStatus: "synced",
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
} as unknown as Pick<IMessageRepository, "delete" | "updateUid">,
|
|
93
123
|
threadMessageService: {
|
|
94
124
|
findAllByMessageId: async () => [...threadMessages.values()],
|
|
95
125
|
deleteMany: async (
|
|
@@ -97,9 +127,18 @@ const buildRepositories = (row: MessageRow) => {
|
|
|
97
127
|
) => {
|
|
98
128
|
for (const key of keys) threadMessages.delete(key.threadMessageId);
|
|
99
129
|
},
|
|
130
|
+
update: async (
|
|
131
|
+
_accountConfigId: string,
|
|
132
|
+
threadMessageId: string,
|
|
133
|
+
set: Partial<ThreadRow>,
|
|
134
|
+
) => {
|
|
135
|
+
const current = threadMessages.get(threadMessageId);
|
|
136
|
+
if (current)
|
|
137
|
+
threadMessages.set(threadMessageId, { ...current, ...set });
|
|
138
|
+
},
|
|
100
139
|
} as unknown as Pick<
|
|
101
140
|
IThreadMessageRepository,
|
|
102
|
-
"findAllByMessageId" | "deleteMany"
|
|
141
|
+
"findAllByMessageId" | "deleteMany" | "update"
|
|
103
142
|
>,
|
|
104
143
|
};
|
|
105
144
|
};
|
|
@@ -118,6 +157,7 @@ const input = {
|
|
|
118
157
|
accountId: "acc-1",
|
|
119
158
|
accountConfigId: "cfg-1",
|
|
120
159
|
messageId: "msg-1",
|
|
160
|
+
sourceMailboxId: "mbx-inbox",
|
|
121
161
|
uid: 101,
|
|
122
162
|
sourceMailboxPath: "INBOX",
|
|
123
163
|
};
|
|
@@ -150,7 +190,7 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
|
|
|
150
190
|
);
|
|
151
191
|
});
|
|
152
192
|
|
|
153
|
-
it("BROKEN: the message is still at the source — the row is
|
|
193
|
+
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
194
|
const repos = buildRepositories(pendingMoveRow());
|
|
155
195
|
const { log, errors } = buildLogger();
|
|
156
196
|
const deps: ResolveExhaustedMessageMoveDeps = {
|
|
@@ -165,15 +205,46 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
|
|
|
165
205
|
});
|
|
166
206
|
|
|
167
207
|
assert.equal(result.outcome, "broken");
|
|
168
|
-
assert.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
"
|
|
208
|
+
assert.equal(repos.messages.get("msg-1")?.mailboxId, "mbx-inbox");
|
|
209
|
+
assert.equal(repos.messages.get("msg-1")?.uid, 101);
|
|
210
|
+
assert.equal(
|
|
211
|
+
repos.threadMessages.get("tm-msg-1")?.mailboxId,
|
|
212
|
+
"mbx-inbox",
|
|
213
|
+
"the listing row follows the message back to the folder it never left",
|
|
172
214
|
);
|
|
173
|
-
assert.equal(repos.threadMessages.size, 1);
|
|
174
215
|
assert.ok(errors.some((e) => e.obj.alert === "message_move_failed"));
|
|
175
216
|
});
|
|
176
217
|
|
|
218
|
+
// Issue #1005: only `updateUid` clears `moving`, so a give-up that returned
|
|
219
|
+
// without calling it left the row naming the destination with the source's
|
|
220
|
+
// uid — a pair `bindsForeignUid` refuses, which made the message
|
|
221
|
+
// undeletable and unmovable for good.
|
|
222
|
+
it("BROKEN: the settled row is no longer refused by the placement guard (#1005)", async () => {
|
|
223
|
+
const repos = buildRepositories(pendingMoveRow());
|
|
224
|
+
const { log } = buildLogger();
|
|
225
|
+
const deps: ResolveExhaustedMessageMoveDeps = {
|
|
226
|
+
messageService: repos.messageService,
|
|
227
|
+
threadMessageService: repos.threadMessageService,
|
|
228
|
+
log,
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
assert.equal(
|
|
232
|
+
placementBindingOf(pendingMoveRow() as never),
|
|
233
|
+
"abandoned",
|
|
234
|
+
"the row starts in the state the delete route refuses as unverified",
|
|
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");
|
|
243
|
+
assert.ok(settled);
|
|
244
|
+
assert.equal(placementBindingOf(settled as never), "consistent");
|
|
245
|
+
assert.equal(settled.status, "active");
|
|
246
|
+
});
|
|
247
|
+
|
|
177
248
|
it("BROKEN: an empty FETCH the SEARCH contradicts never counts as gone", async () => {
|
|
178
249
|
const repos = buildRepositories(pendingMoveRow());
|
|
179
250
|
const { log } = buildLogger();
|
|
@@ -190,7 +261,93 @@ describe("resolveExhaustedMessageMoveFailure — the two terminal outcomes (issu
|
|
|
190
261
|
});
|
|
191
262
|
|
|
192
263
|
assert.equal(result.outcome, "broken");
|
|
193
|
-
assert.
|
|
264
|
+
assert.equal(
|
|
265
|
+
repos.messages.get("msg-1")?.mailboxId,
|
|
266
|
+
"mbx-inbox",
|
|
267
|
+
"a message the SEARCH still lists is at the source, and the row says so",
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// The resolver's contract is that it is never re-thrown, so the caller can
|
|
272
|
+
// ack the record and emit its resync. A row another path deleted while the
|
|
273
|
+
// probe was in flight has nothing left to restore, and the same NotFound
|
|
274
|
+
// wraps an ElectroDB composites miss on the listing row.
|
|
275
|
+
it("BROKEN: a row deleted underneath the probe settles instead of throwing", async () => {
|
|
276
|
+
const repos = buildRepositories(pendingMoveRow());
|
|
277
|
+
const notFound = Object.assign(new Error("Message not found: msg-1"), {
|
|
278
|
+
name: "NotFoundError",
|
|
279
|
+
});
|
|
280
|
+
const { log } = buildLogger();
|
|
281
|
+
const deps: ResolveExhaustedMessageMoveDeps = {
|
|
282
|
+
messageService: {
|
|
283
|
+
...repos.messageService,
|
|
284
|
+
updateUid: async () => {
|
|
285
|
+
throw notFound;
|
|
286
|
+
},
|
|
287
|
+
} as unknown as ResolveExhaustedMessageMoveDeps["messageService"],
|
|
288
|
+
threadMessageService: repos.threadMessageService,
|
|
289
|
+
log,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const result = await resolveExhaustedMessageMoveFailure(deps, {
|
|
293
|
+
...input,
|
|
294
|
+
getConnection: async () => buildConnection(new Set([101])),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
assert.equal(result.outcome, "broken");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("BROKEN: a listing row whose composites moved on does not fail the settle", async () => {
|
|
301
|
+
const repos = buildRepositories(pendingMoveRow());
|
|
302
|
+
const { log } = buildLogger();
|
|
303
|
+
const deps: ResolveExhaustedMessageMoveDeps = {
|
|
304
|
+
messageService: repos.messageService,
|
|
305
|
+
threadMessageService: {
|
|
306
|
+
...repos.threadMessageService,
|
|
307
|
+
update: async () => {
|
|
308
|
+
throw Object.assign(new Error("Thread message not found"), {
|
|
309
|
+
name: "NotFoundError",
|
|
310
|
+
});
|
|
311
|
+
},
|
|
312
|
+
} as unknown as ResolveExhaustedMessageMoveDeps["threadMessageService"],
|
|
313
|
+
log,
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const result = await resolveExhaustedMessageMoveFailure(deps, {
|
|
317
|
+
...input,
|
|
318
|
+
getConnection: async () => buildConnection(new Set([101])),
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
assert.equal(result.outcome, "broken");
|
|
322
|
+
assert.equal(
|
|
323
|
+
repos.messages.get("msg-1")?.mailboxId,
|
|
324
|
+
"mbx-inbox",
|
|
325
|
+
"the Message row still lands on the pair the server confirmed",
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("a repository fault that is not a missing row still propagates", async () => {
|
|
330
|
+
const repos = buildRepositories(pendingMoveRow());
|
|
331
|
+
const { log } = buildLogger();
|
|
332
|
+
const deps: ResolveExhaustedMessageMoveDeps = {
|
|
333
|
+
messageService: {
|
|
334
|
+
...repos.messageService,
|
|
335
|
+
updateUid: async () => {
|
|
336
|
+
throw new Error("ProvisionedThroughputExceeded");
|
|
337
|
+
},
|
|
338
|
+
} as unknown as ResolveExhaustedMessageMoveDeps["messageService"],
|
|
339
|
+
threadMessageService: repos.threadMessageService,
|
|
340
|
+
log,
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
await assert.rejects(
|
|
344
|
+
() =>
|
|
345
|
+
resolveExhaustedMessageMoveFailure(deps, {
|
|
346
|
+
...input,
|
|
347
|
+
getConnection: async () => buildConnection(new Set([101])),
|
|
348
|
+
}),
|
|
349
|
+
/ProvisionedThroughputExceeded/,
|
|
350
|
+
);
|
|
194
351
|
});
|
|
195
352
|
|
|
196
353
|
it("an unreachable server reaches no verdict at all — the probe propagates and the row is untouched", async () => {
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IMessageRepository,
|
|
3
|
+
IThreadMessageRepository,
|
|
4
|
+
} from "@remit/data-ports";
|
|
1
5
|
import {
|
|
2
6
|
type IImapConnection,
|
|
3
7
|
isMessageGoneFromOpenMailbox,
|
|
4
8
|
reconcileStaleMessage,
|
|
5
9
|
type StaleMessageReconcileDeps,
|
|
6
10
|
} from "@remit/mailbox-service";
|
|
11
|
+
import { restoreSourcePlacement } from "./restore-source-placement.js";
|
|
7
12
|
|
|
8
13
|
export interface MessageMoveTerminalLogger {
|
|
9
14
|
info(obj: Record<string, unknown>, msg: string): void;
|
|
@@ -12,6 +17,11 @@ export interface MessageMoveTerminalLogger {
|
|
|
12
17
|
|
|
13
18
|
export interface ResolveExhaustedMessageMoveDeps
|
|
14
19
|
extends StaleMessageReconcileDeps {
|
|
20
|
+
messageService: Pick<IMessageRepository, "delete" | "updateUid">;
|
|
21
|
+
threadMessageService: Pick<
|
|
22
|
+
IThreadMessageRepository,
|
|
23
|
+
"findAllByMessageId" | "deleteMany" | "update"
|
|
24
|
+
>;
|
|
15
25
|
log: MessageMoveTerminalLogger;
|
|
16
26
|
}
|
|
17
27
|
|
|
@@ -19,6 +29,7 @@ export interface ResolveExhaustedMessageMoveInput {
|
|
|
19
29
|
accountId: string;
|
|
20
30
|
accountConfigId: string;
|
|
21
31
|
messageId: string;
|
|
32
|
+
sourceMailboxId: string;
|
|
22
33
|
uid: number;
|
|
23
34
|
sourceMailboxPath: string;
|
|
24
35
|
getConnection: () => Promise<IImapConnection>;
|
|
@@ -51,12 +62,21 @@ export interface ResolveExhaustedMessageMoveResult {
|
|
|
51
62
|
* server's own UID. Metric only, no alarm — routine.
|
|
52
63
|
* 2. BROKEN — the message is still at the source, so the move never took
|
|
53
64
|
* effect, but it keeps failing: broken code or a broken account, not a
|
|
54
|
-
* transient blip.
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
65
|
+
* transient blip. The row is put back where the server has just said the
|
|
66
|
+
* message is, as `resolveExhaustedMessageDeleteFailure` does (#1098). Logged
|
|
67
|
+
* with an `alert`-shaped entry for an operator alarm; never re-thrown
|
|
68
|
+
* (terminal — the caller acks either way, since retrying a
|
|
69
|
+
* permanently-broken move can never succeed).
|
|
70
|
+
*
|
|
71
|
+
* Leaving the row `moving` is what this replaces. BROKEN is terminal, so
|
|
72
|
+
* nothing was ever going to come back and settle it, and only `updateUid`
|
|
73
|
+
* clears `moving` — the row kept a `mailboxId` naming the destination and a
|
|
74
|
+
* `uid` belonging to the source, which `bindsForeignUid` refuses to act on,
|
|
75
|
+
* leaving the message undeletable and unmovable for good (issue #1005).
|
|
76
|
+
* Restoring the pair is not the revert-on-ambiguity PR #652 was pulled for:
|
|
77
|
+
* the presence probe above has just answered, so this writes a placement the
|
|
78
|
+
* server confirmed, and a MOVE that lands after all is re-projected by the
|
|
79
|
+
* resync the caller runs.
|
|
60
80
|
*
|
|
61
81
|
* A server that cannot be reached at exhaustion time never reaches either
|
|
62
82
|
* verdict: the probe throws and the record dead-letters with the row untouched.
|
|
@@ -66,9 +86,10 @@ export interface ResolveExhaustedMessageMoveResult {
|
|
|
66
86
|
* message is not actually at the source: a message another client expunged
|
|
67
87
|
* mid-session can answer an empty FETCH while the server still lists its UID
|
|
68
88
|
* 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
|
|
89
|
+
* lands in BROKEN, and BROKEN is terminal — the row is bound to a source the
|
|
90
|
+
* message has already left and the alert stands until someone clears it. The
|
|
91
|
+
* reverse mistake discards the row for live mail, so the cost is paid
|
|
92
|
+
* deliberately.
|
|
72
93
|
*/
|
|
73
94
|
export const resolveExhaustedMessageMoveFailure = async (
|
|
74
95
|
deps: ResolveExhaustedMessageMoveDeps,
|
|
@@ -78,6 +99,7 @@ export const resolveExhaustedMessageMoveFailure = async (
|
|
|
78
99
|
accountId,
|
|
79
100
|
accountConfigId,
|
|
80
101
|
messageId,
|
|
102
|
+
sourceMailboxId,
|
|
81
103
|
uid,
|
|
82
104
|
sourceMailboxPath,
|
|
83
105
|
getConnection,
|
|
@@ -114,9 +136,18 @@ export const resolveExhaustedMessageMoveFailure = async (
|
|
|
114
136
|
accountConfigId,
|
|
115
137
|
messageId,
|
|
116
138
|
uid,
|
|
139
|
+
sourceMailboxId,
|
|
117
140
|
sourceMailboxPath,
|
|
118
141
|
},
|
|
119
|
-
"Message move could not be pushed to IMAP after retry exhaustion; message still
|
|
142
|
+
"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
143
|
);
|
|
144
|
+
|
|
145
|
+
await restoreSourcePlacement(deps, {
|
|
146
|
+
accountConfigId,
|
|
147
|
+
messageId,
|
|
148
|
+
sourceMailboxId,
|
|
149
|
+
uid,
|
|
150
|
+
});
|
|
151
|
+
|
|
121
152
|
return { outcome: "broken" };
|
|
122
153
|
};
|
|
@@ -257,6 +257,21 @@ export const handleMessageMove = async (
|
|
|
257
257
|
|
|
258
258
|
const scope = createConnectionScopeWithCredentials(account, credentials);
|
|
259
259
|
|
|
260
|
+
// The terminal resolver opens the source itself, and its answer now
|
|
261
|
+
// WRITES a placement, so it has to reach IMAP through the same
|
|
262
|
+
// UIDVALIDITY choke point every other outbound operation does. An
|
|
263
|
+
// unguarded probe on a folder that was deleted and recreated finds
|
|
264
|
+
// whatever the server has renumbered onto this uid, reads it as "still
|
|
265
|
+
// at source", and binds the row to a stranger the next permanent delete
|
|
266
|
+
// would expunge.
|
|
267
|
+
const getGuardedConnection = async (): Promise<IImapConnection> =>
|
|
268
|
+
guardConnectionCursor(
|
|
269
|
+
await scope.getConnection(),
|
|
270
|
+
{ mailboxService },
|
|
271
|
+
accountId,
|
|
272
|
+
mailbox,
|
|
273
|
+
);
|
|
274
|
+
|
|
260
275
|
await scope
|
|
261
276
|
.getConnection()
|
|
262
277
|
.then((rawConnection) => {
|
|
@@ -395,36 +410,62 @@ export const handleMessageMove = async (
|
|
|
395
410
|
// Redelivery budget exhausted: resolve into exactly one of the two
|
|
396
411
|
// terminal outcomes instead of dead-lettering with no diagnosis,
|
|
397
412
|
// and never by inferring the server's state from our own failures.
|
|
398
|
-
const
|
|
413
|
+
const settled = await resolveExhaustedMessageMoveFailure(
|
|
399
414
|
{ messageService, threadMessageService, log },
|
|
400
415
|
{
|
|
401
416
|
accountId,
|
|
402
417
|
accountConfigId: account.accountConfigId,
|
|
403
418
|
messageId,
|
|
419
|
+
sourceMailboxId,
|
|
404
420
|
uid,
|
|
405
421
|
sourceMailboxPath,
|
|
406
|
-
getConnection:
|
|
422
|
+
getConnection: getGuardedConnection,
|
|
407
423
|
},
|
|
408
|
-
)
|
|
424
|
+
).catch((settleError: unknown) => {
|
|
425
|
+
// The guarded probe found a mailbox whose UIDVALIDITY has moved.
|
|
426
|
+
// Every stored uid for this folder now names something else, so
|
|
427
|
+
// there is nothing here that may be settled off them, and the
|
|
428
|
+
// pause is the routine skip `guardMailboxCursor` documents, not
|
|
429
|
+
// a fault to re-throw out of this catch.
|
|
430
|
+
if (settleError instanceof MailboxCursorPausedError) {
|
|
431
|
+
log.info(
|
|
432
|
+
{
|
|
433
|
+
accountId,
|
|
434
|
+
messageId,
|
|
435
|
+
mailboxId: sourceMailboxId,
|
|
436
|
+
cursorState: settleError.state,
|
|
437
|
+
},
|
|
438
|
+
"Mailbox cursor not normal; leaving the exhausted move for the cursor rebuild",
|
|
439
|
+
);
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
throw settleError;
|
|
443
|
+
});
|
|
409
444
|
|
|
410
|
-
if (
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
445
|
+
if (!settled) return;
|
|
446
|
+
|
|
447
|
+
if (settled.outcome === "broken") {
|
|
448
|
+
// Terminal and never re-thrown, so the handler-outcome series
|
|
449
|
+
// records this record as a success. Counted here or it is
|
|
450
|
+
// invisible.
|
|
451
|
+
recordImapFailure("MESSAGE_MOVE_EXHAUSTED", "other");
|
|
452
|
+
log.error(
|
|
453
|
+
{ error: errorMessage },
|
|
454
|
+
"Message move retry exhausted; message still exists at its source",
|
|
455
|
+
);
|
|
419
456
|
}
|
|
420
457
|
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
458
|
+
// Both verdicts end in a row the server has contradicted, so this
|
|
459
|
+
// move reconciles rather than waits (R2). RECONCILED, the local rows
|
|
460
|
+
// are gone and whichever folder actually holds the message
|
|
461
|
+
// re-projects it with the server's own UID. BROKEN, the row has just
|
|
462
|
+
// been put back at the source the server confirmed, and the resync
|
|
463
|
+
// is what carries any drift either folder has picked up since.
|
|
464
|
+
await emitMoveResync(emitEvent, {
|
|
465
|
+
accountId,
|
|
466
|
+
sourceMailboxId,
|
|
467
|
+
destinationMailboxId,
|
|
468
|
+
});
|
|
428
469
|
// Terminal — never re-thrown, so the caller acks either way.
|
|
429
470
|
})
|
|
430
471
|
.finally(() => scope.disconnect());
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
IMessageRepository,
|
|
3
|
+
IThreadMessageRepository,
|
|
4
|
+
} from "@remit/data-ports";
|
|
5
|
+
import { isNotFoundError } from "../is-not-found.js";
|
|
6
|
+
import { buildThreadMessageMoveRevert } from "./thread-message-rows.js";
|
|
7
|
+
|
|
8
|
+
export interface RestoreSourcePlacementDeps {
|
|
9
|
+
messageService: Pick<IMessageRepository, "updateUid">;
|
|
10
|
+
threadMessageService: Pick<
|
|
11
|
+
IThreadMessageRepository,
|
|
12
|
+
"findAllByMessageId" | "update"
|
|
13
|
+
>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RestoreSourcePlacementInput {
|
|
17
|
+
accountConfigId: string;
|
|
18
|
+
messageId: string;
|
|
19
|
+
sourceMailboxId: string;
|
|
20
|
+
uid: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Put a row back on the source pair a presence probe has just confirmed, and
|
|
25
|
+
* hand its listing rows back with it.
|
|
26
|
+
*
|
|
27
|
+
* Only `updateUid` clears `status: moving`, so a give-up that writes
|
|
28
|
+
* `syncStatus: failed` and returns leaves the row naming the destination with
|
|
29
|
+
* the source's uid — the pair `bindsForeignUid` calls a lie and
|
|
30
|
+
* `MessagePlacementUnsettledError` refuses to act on. Nothing routine repairs
|
|
31
|
+
* it (sync does not touch `status`, and the cursor rebuild's `updateUid` runs
|
|
32
|
+
* only on a UIDVALIDITY change), so the message is undeletable and unmovable
|
|
33
|
+
* for good (issue #1005).
|
|
34
|
+
*
|
|
35
|
+
* The caller must have evidence, not an inference: this writes a placement, and
|
|
36
|
+
* a placement written on a guess binds live mail to a uid that names somebody
|
|
37
|
+
* else's message. Every caller reaches here from
|
|
38
|
+
* {@link isMessageGoneFromOpenMailbox} answering that the message is STILL at
|
|
39
|
+
* `sourceMailboxId`/`uid`, which makes this the server's own answer rather than
|
|
40
|
+
* a revert on ambiguity (what PR #652 was pulled for).
|
|
41
|
+
*
|
|
42
|
+
* A row another path deleted while the probe was in flight has nothing left to
|
|
43
|
+
* restore, and a thread row whose composites have moved on is being rewritten
|
|
44
|
+
* by that other path anyway. Both surface as `NotFoundError` — ElectroDB wraps
|
|
45
|
+
* the conditional-check miss as one — and both are settled states here, not
|
|
46
|
+
* faults. Throwing on them would escape the caller's `.catch()` and cost the
|
|
47
|
+
* record its ack and its resync, which is the contract the terminal resolvers
|
|
48
|
+
* state: never re-thrown.
|
|
49
|
+
*/
|
|
50
|
+
export const restoreSourcePlacement = async (
|
|
51
|
+
deps: RestoreSourcePlacementDeps,
|
|
52
|
+
input: RestoreSourcePlacementInput,
|
|
53
|
+
): Promise<void> => {
|
|
54
|
+
const { accountConfigId, messageId, sourceMailboxId, uid } = input;
|
|
55
|
+
|
|
56
|
+
const skipNotFound = (error: unknown): void => {
|
|
57
|
+
if (isNotFoundError(error)) return;
|
|
58
|
+
throw error;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const messageRestored = await deps.messageService
|
|
62
|
+
.updateUid(messageId, uid, sourceMailboxId)
|
|
63
|
+
.then(() => true)
|
|
64
|
+
.catch((error: unknown) => {
|
|
65
|
+
skipNotFound(error);
|
|
66
|
+
return false;
|
|
67
|
+
});
|
|
68
|
+
if (!messageRestored) return;
|
|
69
|
+
|
|
70
|
+
const threadMessages = await deps.threadMessageService.findAllByMessageId(
|
|
71
|
+
accountConfigId,
|
|
72
|
+
messageId,
|
|
73
|
+
);
|
|
74
|
+
for (const threadMessage of threadMessages) {
|
|
75
|
+
const args = buildThreadMessageMoveRevert(
|
|
76
|
+
threadMessage,
|
|
77
|
+
uid,
|
|
78
|
+
sourceMailboxId,
|
|
79
|
+
);
|
|
80
|
+
await deps.threadMessageService
|
|
81
|
+
.update(
|
|
82
|
+
threadMessage.accountConfigId,
|
|
83
|
+
threadMessage.threadMessageId,
|
|
84
|
+
args.set,
|
|
85
|
+
{ composites: args.composites },
|
|
86
|
+
)
|
|
87
|
+
.catch(skipNotFound);
|
|
88
|
+
}
|
|
89
|
+
};
|