@remit/mailbox-service 0.0.90 → 0.0.92
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 +6 -1
- package/src/filters/filter-config.test.ts +229 -0
- package/src/filters/filter-config.ts +69 -0
- package/src/filters/message-embedder.test.ts +39 -0
- package/src/filters/message-embedder.ts +33 -0
- package/src/filters/pipeline.ts +28 -1
- package/src/index.ts +13 -0
- package/src/message-delete-junk-reconcile.test.ts +5 -2
- package/src/message-delete-no-trash.test.ts +5 -1
- package/src/message-delete-unsettled-placement.test.ts +25 -17
- package/src/message-move-copy.test.ts +28 -1
- package/src/message-move-empty-trash.test.ts +48 -1
- package/src/message-move-same-mailbox.test.ts +5 -2
- package/src/message-move-unsettled-placement.test.ts +56 -17
- package/src/message-move.ts +189 -62
- package/src/message-sync-repoint.test.ts +22 -2
- package/src/message-sync.ts +9 -10
- package/src/placement-move-terminal.test.ts +198 -133
- package/src/placement-move-terminal.ts +47 -14
- package/src/placement-move-unsettled.test.ts +18 -4
- package/src/placement-move.ts +65 -9
- package/src/placement-settled.test.ts +36 -6
- package/src/placement-settled.ts +28 -25
- package/src/placement-transition-race.test.ts +482 -0
- package/src/restore-source-placement.ts +157 -0
- package/src/spam-report.test.ts +13 -10
- package/src/spam-report.ts +28 -2
- package/src/thread-message-rows.ts +77 -0
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
3
|
import type {
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
MessageItem,
|
|
5
|
+
PlacementPredicate,
|
|
6
|
+
PlacementTransitionInput,
|
|
6
7
|
} from "@remit/data-ports";
|
|
8
|
+
import {
|
|
9
|
+
MessageMutation,
|
|
10
|
+
MessageStatus,
|
|
11
|
+
MessageSyncStatus,
|
|
12
|
+
} from "@remit/domain-enums";
|
|
7
13
|
import type { PlacementMoveLogger } from "./placement-move.js";
|
|
8
14
|
import {
|
|
9
15
|
type ResolveExhaustedPlacementMoveDeps,
|
|
@@ -16,20 +22,89 @@ interface LogEntry {
|
|
|
16
22
|
msg: string;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
interface Transition {
|
|
26
|
+
messageId: string;
|
|
27
|
+
expected: PlacementPredicate;
|
|
28
|
+
next: PlacementTransitionInput;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Recorder {
|
|
32
|
+
deps: ResolveExhaustedPlacementMoveDeps;
|
|
21
33
|
infos: LogEntry[];
|
|
22
34
|
errors: LogEntry[];
|
|
23
|
-
|
|
35
|
+
markerDeletes: string[];
|
|
36
|
+
deletedMessages: string[];
|
|
37
|
+
deletedThreadMessages: Array<{
|
|
38
|
+
accountConfigId: string;
|
|
39
|
+
threadMessageId: string;
|
|
40
|
+
}>;
|
|
41
|
+
transitions: Transition[];
|
|
42
|
+
threadUpdates: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const buildRecorder = (
|
|
46
|
+
threadMessages: Array<{ accountConfigId: string; threadMessageId: string }>,
|
|
47
|
+
transitionWins = true,
|
|
48
|
+
): Recorder => {
|
|
24
49
|
const infos: LogEntry[] = [];
|
|
25
50
|
const errors: LogEntry[] = [];
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
51
|
+
const markerDeletes: string[] = [];
|
|
52
|
+
const deletedMessages: string[] = [];
|
|
53
|
+
const deletedThreadMessages: Array<{
|
|
54
|
+
accountConfigId: string;
|
|
55
|
+
threadMessageId: string;
|
|
56
|
+
}> = [];
|
|
57
|
+
const transitions: Transition[] = [];
|
|
58
|
+
const threadUpdates: string[] = [];
|
|
59
|
+
|
|
60
|
+
const log: PlacementMoveLogger = {
|
|
61
|
+
info: (obj, msg) => infos.push({ obj, msg }),
|
|
62
|
+
error: (obj, msg) => errors.push({ obj, msg }),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const deps: ResolveExhaustedPlacementMoveDeps = {
|
|
66
|
+
markerService: {
|
|
67
|
+
delete: async (id: string) => {
|
|
68
|
+
markerDeletes.push(id);
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
messageService: {
|
|
72
|
+
delete: async (id: string) => {
|
|
73
|
+
deletedMessages.push(id);
|
|
74
|
+
},
|
|
75
|
+
transitionPlacement: async (
|
|
76
|
+
messageId: string,
|
|
77
|
+
expected: PlacementPredicate,
|
|
78
|
+
next: PlacementTransitionInput,
|
|
79
|
+
) => {
|
|
80
|
+
transitions.push({ messageId, expected, next });
|
|
81
|
+
return transitionWins ? ({ messageId } as MessageItem) : undefined;
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
threadMessageService: {
|
|
85
|
+
findAllByMessageId: async () => threadMessages as never,
|
|
86
|
+
deleteMany: async (
|
|
87
|
+
keys: Array<{ accountConfigId: string; threadMessageId: string }>,
|
|
88
|
+
) => {
|
|
89
|
+
deletedThreadMessages.push(...keys);
|
|
90
|
+
},
|
|
91
|
+
update: async (_accountConfigId: string, threadMessageId: string) => {
|
|
92
|
+
threadUpdates.push(threadMessageId);
|
|
93
|
+
return undefined as never;
|
|
94
|
+
},
|
|
30
95
|
},
|
|
96
|
+
log,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
deps,
|
|
31
101
|
infos,
|
|
32
102
|
errors,
|
|
103
|
+
markerDeletes,
|
|
104
|
+
deletedMessages,
|
|
105
|
+
deletedThreadMessages,
|
|
106
|
+
transitions,
|
|
107
|
+
threadUpdates,
|
|
33
108
|
};
|
|
34
109
|
};
|
|
35
110
|
|
|
@@ -52,181 +127,171 @@ const buildConnection = (
|
|
|
52
127
|
|
|
53
128
|
describe("resolveExhaustedPlacementMoveFailure — the two terminal outcomes (mirrors #1270 for placement moves)", () => {
|
|
54
129
|
it("RECONCILED (expected): the message is gone from its pending-move source — marker dropped, stale row reconciled, no alarm", async () => {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
threadMessageId: string;
|
|
59
|
-
}> = [];
|
|
60
|
-
const markerDeletes: string[] = [];
|
|
61
|
-
const { log, infos, errors } = buildLogger();
|
|
62
|
-
|
|
63
|
-
const deps: ResolveExhaustedPlacementMoveDeps = {
|
|
64
|
-
markerService: {
|
|
65
|
-
delete: async (id: string) => {
|
|
66
|
-
markerDeletes.push(id);
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
messageService: {
|
|
70
|
-
delete: async (id: string) => {
|
|
71
|
-
deletedMessages.push(id);
|
|
72
|
-
},
|
|
73
|
-
} as unknown as Pick<IMessageRepository, "delete">,
|
|
74
|
-
threadMessageService: {
|
|
75
|
-
findAllByMessageId: async (
|
|
76
|
-
accountConfigId: string,
|
|
77
|
-
messageId: string,
|
|
78
|
-
) => [{ accountConfigId, threadMessageId: `tm-${messageId}` }],
|
|
79
|
-
deleteMany: async (
|
|
80
|
-
keys: Array<{ accountConfigId: string; threadMessageId: string }>,
|
|
81
|
-
) => {
|
|
82
|
-
deletedThreadMessages.push(...keys);
|
|
83
|
-
},
|
|
84
|
-
} as unknown as Pick<
|
|
85
|
-
IThreadMessageRepository,
|
|
86
|
-
"findAllByMessageId" | "deleteMany"
|
|
87
|
-
>,
|
|
88
|
-
log,
|
|
89
|
-
};
|
|
130
|
+
const rec = buildRecorder([
|
|
131
|
+
{ accountConfigId: "cfg-1", threadMessageId: "tm-msg-gone" },
|
|
132
|
+
]);
|
|
90
133
|
|
|
91
|
-
const result = await resolveExhaustedPlacementMoveFailure(deps, {
|
|
134
|
+
const result = await resolveExhaustedPlacementMoveFailure(rec.deps, {
|
|
92
135
|
accountId: "acc-1",
|
|
93
136
|
accountConfigId: "cfg-1",
|
|
94
137
|
messageId: "msg-gone",
|
|
95
138
|
uid: 101,
|
|
139
|
+
sourceMailboxId: "mbx-junk",
|
|
96
140
|
sourceMailboxPath: "Junk",
|
|
97
141
|
getConnection: async () => buildConnection(new Set()),
|
|
98
142
|
});
|
|
99
143
|
|
|
100
144
|
assert.equal(result.outcome, "reconciled");
|
|
101
|
-
assert.deepEqual(markerDeletes, ["msg-gone"]);
|
|
102
|
-
assert.deepEqual(deletedMessages, ["msg-gone"]);
|
|
103
|
-
assert.deepEqual(deletedThreadMessages, [
|
|
145
|
+
assert.deepEqual(rec.markerDeletes, ["msg-gone"]);
|
|
146
|
+
assert.deepEqual(rec.deletedMessages, ["msg-gone"]);
|
|
147
|
+
assert.deepEqual(rec.deletedThreadMessages, [
|
|
104
148
|
{ accountConfigId: "cfg-1", threadMessageId: "tm-msg-gone" },
|
|
105
149
|
]);
|
|
106
|
-
assert.equal(
|
|
150
|
+
assert.equal(
|
|
151
|
+
rec.errors.length,
|
|
152
|
+
0,
|
|
153
|
+
"no alarm for the expected/routine outcome",
|
|
154
|
+
);
|
|
107
155
|
assert.ok(
|
|
108
|
-
infos.some(
|
|
156
|
+
rec.infos.some(
|
|
157
|
+
(e) => e.obj.metric === "placement_move_stale_row_reconciled",
|
|
158
|
+
),
|
|
109
159
|
);
|
|
110
160
|
});
|
|
111
161
|
|
|
112
|
-
it("BROKEN: the message still
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const deps: ResolveExhaustedPlacementMoveDeps = {
|
|
118
|
-
markerService: {
|
|
119
|
-
delete: async (id: string) => {
|
|
120
|
-
markerDeletes.push(id);
|
|
121
|
-
},
|
|
122
|
-
},
|
|
123
|
-
messageService: {
|
|
124
|
-
delete: async (id: string) => {
|
|
125
|
-
deletedMessages.push(id);
|
|
126
|
-
},
|
|
127
|
-
} as unknown as Pick<IMessageRepository, "delete">,
|
|
128
|
-
threadMessageService: {
|
|
129
|
-
findAllByMessageId: async () => [],
|
|
130
|
-
deleteMany: async () => {},
|
|
131
|
-
} as unknown as Pick<
|
|
132
|
-
IThreadMessageRepository,
|
|
133
|
-
"findAllByMessageId" | "deleteMany"
|
|
134
|
-
>,
|
|
135
|
-
log,
|
|
136
|
-
};
|
|
162
|
+
it("BROKEN: the message is still at the source — the row goes back to the source pair rather than being left `moving` forever", async () => {
|
|
163
|
+
const rec = buildRecorder([
|
|
164
|
+
{ accountConfigId: "cfg-1", threadMessageId: "tm-msg-broken" },
|
|
165
|
+
]);
|
|
137
166
|
|
|
138
|
-
const result = await resolveExhaustedPlacementMoveFailure(deps, {
|
|
167
|
+
const result = await resolveExhaustedPlacementMoveFailure(rec.deps, {
|
|
139
168
|
accountId: "acc-1",
|
|
140
169
|
accountConfigId: "cfg-1",
|
|
141
170
|
messageId: "msg-broken",
|
|
142
171
|
uid: 202,
|
|
172
|
+
sourceMailboxId: "mbx-inbox",
|
|
143
173
|
sourceMailboxPath: "INBOX",
|
|
144
174
|
getConnection: async () => buildConnection(new Set([202])),
|
|
145
175
|
});
|
|
146
176
|
|
|
147
177
|
assert.equal(result.outcome, "broken");
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
178
|
+
assert.deepEqual(rec.transitions, [
|
|
179
|
+
{
|
|
180
|
+
messageId: "msg-broken",
|
|
181
|
+
expected: {
|
|
182
|
+
status: [MessageStatus.moving, MessageStatus.deleting],
|
|
183
|
+
},
|
|
184
|
+
next: {
|
|
185
|
+
mailboxId: "mbx-inbox",
|
|
186
|
+
uid: 202,
|
|
187
|
+
status: MessageStatus.active,
|
|
188
|
+
syncStatus: MessageSyncStatus.synced,
|
|
189
|
+
// Remit's own classification filing, not something the user asked
|
|
190
|
+
// for, so no per-message give-up marker — the alert is the surface.
|
|
191
|
+
abandonedMutation: MessageMutation.none,
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
]);
|
|
195
|
+
assert.deepEqual(rec.threadUpdates, ["tm-msg-broken"]);
|
|
196
|
+
// The marker predicted a move that has now been given up on; leaving it
|
|
197
|
+
// would keep a folder-count prediction nothing will ever settle.
|
|
198
|
+
assert.deepEqual(rec.markerDeletes, ["msg-broken"]);
|
|
199
|
+
assert.deepEqual(rec.deletedMessages, []);
|
|
153
200
|
assert.ok(
|
|
154
|
-
errors.some((e) => e.obj.alert === "placement_move_failed"),
|
|
155
|
-
"
|
|
201
|
+
rec.errors.some((e) => e.obj.alert === "placement_move_failed"),
|
|
202
|
+
"the operator signal is the alert, not a stuck row",
|
|
156
203
|
);
|
|
157
204
|
});
|
|
158
205
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
markerDeletes.push(id);
|
|
169
|
-
},
|
|
170
|
-
},
|
|
171
|
-
messageService: {
|
|
172
|
-
delete: async (id: string) => {
|
|
173
|
-
deletedMessages.push(id);
|
|
174
|
-
},
|
|
175
|
-
} as unknown as Pick<IMessageRepository, "delete">,
|
|
176
|
-
threadMessageService: {
|
|
177
|
-
findAllByMessageId: async () => [
|
|
178
|
-
{ accountConfigId: "cfg-1", threadMessageId: "tm-msg-live" },
|
|
179
|
-
],
|
|
180
|
-
deleteMany: async (keys: unknown[]) => {
|
|
181
|
-
deletedThreadMessages.push(...keys);
|
|
182
|
-
},
|
|
183
|
-
} as unknown as Pick<
|
|
184
|
-
IThreadMessageRepository,
|
|
185
|
-
"findAllByMessageId" | "deleteMany"
|
|
186
|
-
>,
|
|
187
|
-
log,
|
|
206
|
+
// The marker is the operator's evidence that this move still owed IMAP a
|
|
207
|
+
// push. Dropping it first meant a restore that threw left the row stuck
|
|
208
|
+
// `moving` with nothing left to say why.
|
|
209
|
+
it("BROKEN: a restore that throws leaves the marker standing", async () => {
|
|
210
|
+
const rec = buildRecorder([
|
|
211
|
+
{ accountConfigId: "cfg-1", threadMessageId: "tm-msg-broken" },
|
|
212
|
+
]);
|
|
213
|
+
rec.deps.messageService.transitionPlacement = async () => {
|
|
214
|
+
throw new Error("ProvisionedThroughputExceeded");
|
|
188
215
|
};
|
|
189
216
|
|
|
190
|
-
|
|
217
|
+
await assert.rejects(
|
|
218
|
+
() =>
|
|
219
|
+
resolveExhaustedPlacementMoveFailure(rec.deps, {
|
|
220
|
+
accountId: "acc-1",
|
|
221
|
+
accountConfigId: "cfg-1",
|
|
222
|
+
messageId: "msg-broken",
|
|
223
|
+
uid: 202,
|
|
224
|
+
sourceMailboxId: "mbx-inbox",
|
|
225
|
+
sourceMailboxPath: "INBOX",
|
|
226
|
+
getConnection: async () => buildConnection(new Set([202])),
|
|
227
|
+
}),
|
|
228
|
+
/ProvisionedThroughputExceeded/,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
assert.deepEqual(
|
|
232
|
+
rec.markerDeletes,
|
|
233
|
+
[],
|
|
234
|
+
"the record of the owed push outlives a failed restore",
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("BROKEN with another lane already settled: the restore loses its predicate and writes nothing", async () => {
|
|
239
|
+
const rec = buildRecorder(
|
|
240
|
+
[{ accountConfigId: "cfg-1", threadMessageId: "tm-msg-raced" }],
|
|
241
|
+
false,
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const result = await resolveExhaustedPlacementMoveFailure(rec.deps, {
|
|
245
|
+
accountId: "acc-1",
|
|
246
|
+
accountConfigId: "cfg-1",
|
|
247
|
+
messageId: "msg-raced",
|
|
248
|
+
uid: 404,
|
|
249
|
+
sourceMailboxId: "mbx-inbox",
|
|
250
|
+
sourceMailboxPath: "INBOX",
|
|
251
|
+
getConnection: async () => buildConnection(new Set([404])),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
assert.equal(result.outcome, "broken");
|
|
255
|
+
assert.equal(rec.transitions.length, 1);
|
|
256
|
+
assert.deepEqual(
|
|
257
|
+
rec.threadUpdates,
|
|
258
|
+
[],
|
|
259
|
+
"a lost predicate leaves the listing rows to whoever won",
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("a dropped FETCH row is not absence: the message is still at the source — local rows survive", async () => {
|
|
264
|
+
const rec = buildRecorder([
|
|
265
|
+
{ accountConfigId: "cfg-1", threadMessageId: "tm-msg-live" },
|
|
266
|
+
]);
|
|
267
|
+
|
|
268
|
+
const result = await resolveExhaustedPlacementMoveFailure(rec.deps, {
|
|
191
269
|
accountId: "acc-1",
|
|
192
270
|
accountConfigId: "cfg-1",
|
|
193
271
|
messageId: "msg-live",
|
|
194
272
|
uid: 303,
|
|
273
|
+
sourceMailboxId: "mbx-inbox",
|
|
195
274
|
sourceMailboxPath: "INBOX",
|
|
196
275
|
getConnection: async () =>
|
|
197
276
|
buildConnection(new Set([303]), new Set([303])),
|
|
198
277
|
});
|
|
199
278
|
|
|
200
279
|
assert.equal(result.outcome, "broken");
|
|
201
|
-
assert.deepEqual(
|
|
202
|
-
assert.deepEqual(
|
|
203
|
-
assert.
|
|
204
|
-
assert.ok(errors.some((e) => e.obj.alert === "placement_move_failed"));
|
|
280
|
+
assert.deepEqual(rec.deletedMessages, []);
|
|
281
|
+
assert.deepEqual(rec.deletedThreadMessages, []);
|
|
282
|
+
assert.ok(rec.errors.some((e) => e.obj.alert === "placement_move_failed"));
|
|
205
283
|
});
|
|
206
284
|
|
|
207
285
|
it("never throws — both outcomes are terminal, the caller always acks", async () => {
|
|
208
|
-
const
|
|
209
|
-
const deps: ResolveExhaustedPlacementMoveDeps = {
|
|
210
|
-
markerService: { delete: async () => {} },
|
|
211
|
-
messageService: {
|
|
212
|
-
delete: async () => {},
|
|
213
|
-
} as unknown as Pick<IMessageRepository, "delete">,
|
|
214
|
-
threadMessageService: {
|
|
215
|
-
findAllByMessageId: async () => [],
|
|
216
|
-
deleteMany: async () => {},
|
|
217
|
-
} as unknown as Pick<
|
|
218
|
-
IThreadMessageRepository,
|
|
219
|
-
"findAllByMessageId" | "deleteMany"
|
|
220
|
-
>,
|
|
221
|
-
log,
|
|
222
|
-
};
|
|
286
|
+
const rec = buildRecorder([]);
|
|
223
287
|
|
|
224
288
|
await assert.doesNotReject(() =>
|
|
225
|
-
resolveExhaustedPlacementMoveFailure(deps, {
|
|
289
|
+
resolveExhaustedPlacementMoveFailure(rec.deps, {
|
|
226
290
|
accountId: "acc-1",
|
|
227
291
|
accountConfigId: "cfg-1",
|
|
228
292
|
messageId: "msg-x",
|
|
229
293
|
uid: 1,
|
|
294
|
+
sourceMailboxId: "mbx-inbox",
|
|
230
295
|
sourceMailboxPath: "INBOX",
|
|
231
296
|
getConnection: async () => buildConnection(new Set([1])),
|
|
232
297
|
}),
|
|
@@ -1,13 +1,21 @@
|
|
|
1
|
+
import { MessageMutation, MessageSyncStatus } from "@remit/domain-enums";
|
|
1
2
|
import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
|
|
2
3
|
import type { PlacementMoveLogger } from "./placement-move.js";
|
|
4
|
+
import {
|
|
5
|
+
type RestoreSourcePlacementDeps,
|
|
6
|
+
restoreSourcePlacement,
|
|
7
|
+
} from "./restore-source-placement.js";
|
|
3
8
|
import {
|
|
4
9
|
reconcileStaleMessage,
|
|
5
10
|
type StaleMessageReconcileDeps,
|
|
6
11
|
} from "./stale-message-reconcile.js";
|
|
7
12
|
import type { IImapConnection } from "./types.js";
|
|
8
13
|
|
|
9
|
-
export interface ResolveExhaustedPlacementMoveDeps
|
|
10
|
-
|
|
14
|
+
export interface ResolveExhaustedPlacementMoveDeps {
|
|
15
|
+
messageService: StaleMessageReconcileDeps["messageService"] &
|
|
16
|
+
RestoreSourcePlacementDeps["messageService"];
|
|
17
|
+
threadMessageService: StaleMessageReconcileDeps["threadMessageService"] &
|
|
18
|
+
RestoreSourcePlacementDeps["threadMessageService"];
|
|
11
19
|
markerService: { delete(messageId: string): Promise<void> };
|
|
12
20
|
log: PlacementMoveLogger;
|
|
13
21
|
}
|
|
@@ -17,6 +25,7 @@ export interface ResolveExhaustedPlacementMoveInput {
|
|
|
17
25
|
accountConfigId: string;
|
|
18
26
|
messageId: string;
|
|
19
27
|
uid: number;
|
|
28
|
+
sourceMailboxId: string;
|
|
20
29
|
sourceMailboxPath: string;
|
|
21
30
|
getConnection: () => Promise<IImapConnection>;
|
|
22
31
|
}
|
|
@@ -45,22 +54,26 @@ export interface ResolveExhaustedPlacementMoveResult {
|
|
|
45
54
|
* only, no alarm — routine.
|
|
46
55
|
* 2. BROKEN — the message still exists at the source, but the move keeps
|
|
47
56
|
* failing. This indicates broken code or a broken account (issue #1271),
|
|
48
|
-
* not a transient blip. The
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
57
|
+
* not a transient blip. The row is put back on the source pair the server
|
|
58
|
+
* has just confirmed, exactly as the move and delete resolvers do, and the
|
|
59
|
+
* marker goes with it: an auto-file whose push gave up must not leave
|
|
60
|
+
* `moving` plus the destination folder plus the source's uid standing
|
|
61
|
+
* forever, because that pair refuses every later delete and move with a
|
|
62
|
+
* 409, blocks the sighting repair, and nothing else clears it. The failure
|
|
63
|
+
* is an operator's to read, so it lives in the `alert`-shaped log entry and
|
|
64
|
+
* the alarm on it — not in a row the user's own mail is stuck behind.
|
|
65
|
+
* Never re-thrown (terminal — the caller acks either way, since retrying a
|
|
66
|
+
* stale or permanently-broken move can never succeed).
|
|
55
67
|
*
|
|
56
68
|
* An operator reading `placement_move_failed` should know one case where the
|
|
57
69
|
* message is not actually at the source: a message another client expunged
|
|
58
70
|
* mid-session can answer an empty FETCH while the server still lists its UID
|
|
59
71
|
* in SEARCH, until it is allowed to send the untagged EXPUNGE. That message
|
|
60
|
-
* lands in BROKEN, and BROKEN is terminal — the
|
|
61
|
-
*
|
|
62
|
-
* mail, so the cost is paid
|
|
63
|
-
*
|
|
72
|
+
* lands in BROKEN, and BROKEN is terminal — the alert stands until someone
|
|
73
|
+
* clears it, and the row goes back to a source the message may already have
|
|
74
|
+
* left. The reverse mistake deletes live mail, so the cost is paid
|
|
75
|
+
* deliberately: a stale alert over a row the next sync re-points is
|
|
76
|
+
* recoverable, a deleted message is not.
|
|
64
77
|
*/
|
|
65
78
|
export const resolveExhaustedPlacementMoveFailure = async (
|
|
66
79
|
deps: ResolveExhaustedPlacementMoveDeps,
|
|
@@ -71,6 +84,7 @@ export const resolveExhaustedPlacementMoveFailure = async (
|
|
|
71
84
|
accountConfigId,
|
|
72
85
|
messageId,
|
|
73
86
|
uid,
|
|
87
|
+
sourceMailboxId,
|
|
74
88
|
sourceMailboxPath,
|
|
75
89
|
getConnection,
|
|
76
90
|
} = input;
|
|
@@ -100,6 +114,24 @@ export const resolveExhaustedPlacementMoveFailure = async (
|
|
|
100
114
|
return { outcome: "reconciled" };
|
|
101
115
|
}
|
|
102
116
|
|
|
117
|
+
// The row first, the marker second. The marker is the operator's evidence
|
|
118
|
+
// that this move still owed IMAP a push; dropping it before the restore
|
|
119
|
+
// means a restore that throws leaves the row stuck `moving` with nothing
|
|
120
|
+
// left to say why.
|
|
121
|
+
await restoreSourcePlacement(deps, {
|
|
122
|
+
accountConfigId,
|
|
123
|
+
messageId,
|
|
124
|
+
sourceMailboxId,
|
|
125
|
+
uid,
|
|
126
|
+
// No give-up marker: this move is Remit's own classification filing, not
|
|
127
|
+
// something the user asked for, so a per-message "your move failed" would
|
|
128
|
+
// report a failure against an intent nobody formed. The alert below is the
|
|
129
|
+
// whole surface, and it is an operator's.
|
|
130
|
+
syncStatus: MessageSyncStatus.synced,
|
|
131
|
+
abandonedMutation: MessageMutation.none,
|
|
132
|
+
});
|
|
133
|
+
await deps.markerService.delete(messageId);
|
|
134
|
+
|
|
103
135
|
deps.log.error(
|
|
104
136
|
{
|
|
105
137
|
alert: "placement_move_failed",
|
|
@@ -107,9 +139,10 @@ export const resolveExhaustedPlacementMoveFailure = async (
|
|
|
107
139
|
accountConfigId,
|
|
108
140
|
messageId,
|
|
109
141
|
uid,
|
|
142
|
+
sourceMailboxId,
|
|
110
143
|
sourceMailboxPath,
|
|
111
144
|
},
|
|
112
|
-
"Placement move could not be pushed to IMAP after retry exhaustion; message still exists at its source — marker left
|
|
145
|
+
"Placement move could not be pushed to IMAP after retry exhaustion; message still exists at its source — row restored to the source pair, marker dropped, alert left for operator investigation",
|
|
113
146
|
);
|
|
114
147
|
return { outcome: "broken" };
|
|
115
148
|
};
|
|
@@ -16,8 +16,9 @@ import type {
|
|
|
16
16
|
IThreadMessageRepository,
|
|
17
17
|
MessageItem,
|
|
18
18
|
MessagePlacementMoveItem,
|
|
19
|
+
PlacementPredicate,
|
|
20
|
+
PlacementTransitionInput,
|
|
19
21
|
PutMessagePlacementMoveInput,
|
|
20
|
-
UpdateMessageMoveInput,
|
|
21
22
|
} from "@remit/data-ports";
|
|
22
23
|
import { PlacementMoveService } from "./placement-move.js";
|
|
23
24
|
import { noFolderRoles } from "./test-helpers/folder-roles.js";
|
|
@@ -60,11 +61,24 @@ const buildHarness = (): Harness => {
|
|
|
60
61
|
|
|
61
62
|
const messageService = {
|
|
62
63
|
get: async () => row,
|
|
63
|
-
|
|
64
|
+
transitionPlacement: async (
|
|
64
65
|
_messageId: string,
|
|
65
|
-
|
|
66
|
+
expected: PlacementPredicate,
|
|
67
|
+
next: PlacementTransitionInput,
|
|
66
68
|
) => {
|
|
67
|
-
|
|
69
|
+
if (expected.status !== undefined && expected.status !== row.status) {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
if (expected.uid !== undefined && expected.uid !== row.uid) {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
if (
|
|
76
|
+
expected.mailboxId !== undefined &&
|
|
77
|
+
expected.mailboxId !== row.mailboxId
|
|
78
|
+
) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
Object.assign(row, next);
|
|
68
82
|
return row;
|
|
69
83
|
},
|
|
70
84
|
} as unknown as IMessageRepository;
|
package/src/placement-move.ts
CHANGED
|
@@ -7,7 +7,11 @@ import type {
|
|
|
7
7
|
IMessageRepository,
|
|
8
8
|
IThreadMessageRepository,
|
|
9
9
|
} from "@remit/data-ports";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
MessageMutation,
|
|
12
|
+
MessageStatus,
|
|
13
|
+
MessageSyncStatus,
|
|
14
|
+
} from "@remit/domain-enums";
|
|
11
15
|
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
12
16
|
import {
|
|
13
17
|
isPlacementUnsettled,
|
|
@@ -163,11 +167,30 @@ export class PlacementMoveService {
|
|
|
163
167
|
// to the settled row (docs/architecture/imap-mutations.md R2). Blocking is
|
|
164
168
|
// cheap — a move settles in well under a second — and on timeout the
|
|
165
169
|
// dependent write is simply not made.
|
|
170
|
+
// A delete in flight is not a move to wait out: the user has asked for the
|
|
171
|
+
// message to go, and a classification filing behind it has nothing left to
|
|
172
|
+
// file. Skipping is the answer, and it is a clean one — throwing would
|
|
173
|
+
// burn the ceiling and then drive an ordinary sequence toward the DLQ.
|
|
174
|
+
if (message.status === MessageStatus.deleting) {
|
|
175
|
+
this.log.info(
|
|
176
|
+
{ messageId, accountId, destinationMailboxId },
|
|
177
|
+
"Placement move not applied: this message is being deleted",
|
|
178
|
+
);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
166
182
|
if (isPlacementUnsettled(message)) {
|
|
167
183
|
message = await waitForPlacementToSettle(this.messageService, messageId, {
|
|
168
184
|
timeoutMs: this.moveSettleTimeoutMs,
|
|
169
185
|
pollMs: this.moveSettlePollMs,
|
|
170
186
|
});
|
|
187
|
+
if (message.status === MessageStatus.deleting) {
|
|
188
|
+
this.log.info(
|
|
189
|
+
{ messageId, accountId, destinationMailboxId },
|
|
190
|
+
"Placement move not applied: a delete claimed this message while the earlier move settled",
|
|
191
|
+
);
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
171
194
|
if (isPlacementUnsettled(message)) {
|
|
172
195
|
throw new Error(
|
|
173
196
|
`Placement move for ${messageId} to ${destinationMailboxId} not applied: an earlier move has not settled`,
|
|
@@ -195,20 +218,53 @@ export class PlacementMoveService {
|
|
|
195
218
|
destinationMailboxId,
|
|
196
219
|
});
|
|
197
220
|
|
|
221
|
+
// The Message row first, the listing row after it — the order the user
|
|
222
|
+
// move and the delete already use. A transition off the row the wait above
|
|
223
|
+
// settled on (imap-mutations R3): this lane rides a standard queue that
|
|
224
|
+
// the account's FIFO group does not order, so a user's own move or delete
|
|
225
|
+
// can claim the row between that read and this write, and writing anyway
|
|
226
|
+
// would stamp Remit's classification over a folder the user just chose,
|
|
227
|
+
// with the old uid attached.
|
|
228
|
+
//
|
|
229
|
+
// Writing the listing row first meant a loser left the two disagreeing —
|
|
230
|
+
// the listing showing the classification folder while the Message row sat
|
|
231
|
+
// where the winner put it — and nothing routine reconciles that.
|
|
232
|
+
//
|
|
233
|
+
// The marker goes with the loss. It is the record that a move owes IMAP a
|
|
234
|
+
// push, and this move is not happening, so leaving it would send the
|
|
235
|
+
// reconciler after a placement nobody wrote and would inflate the folder
|
|
236
|
+
// counts it predicts.
|
|
237
|
+
const filed = await this.messageService.transitionPlacement(
|
|
238
|
+
messageId,
|
|
239
|
+
{
|
|
240
|
+
status: MessageStatus.active,
|
|
241
|
+
mailboxId: sourceMailboxId,
|
|
242
|
+
uid: message.uid,
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
mailboxId: destinationMailboxId,
|
|
246
|
+
status: MessageStatus.moving,
|
|
247
|
+
syncStatus: MessageSyncStatus.pending,
|
|
248
|
+
abandonedMutation: MessageMutation.none,
|
|
249
|
+
originalMailboxId: sourceMailboxId,
|
|
250
|
+
originalUid: message.uid,
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
if (!filed) {
|
|
254
|
+
await this.markerService.delete(messageId);
|
|
255
|
+
this.log.info(
|
|
256
|
+
{ messageId, accountId, sourceMailboxId, destinationMailboxId },
|
|
257
|
+
"Placement move not applied: something else claimed this message's placement first",
|
|
258
|
+
);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
198
262
|
await this.updateThreadMessageMailbox(
|
|
199
263
|
accountConfigId,
|
|
200
264
|
messageId,
|
|
201
265
|
destinationMailboxId,
|
|
202
266
|
);
|
|
203
267
|
|
|
204
|
-
await this.messageService.updateForMove(messageId, {
|
|
205
|
-
mailboxId: destinationMailboxId,
|
|
206
|
-
status: MessageStatus.moving,
|
|
207
|
-
syncStatus: MessageSyncStatus.pending,
|
|
208
|
-
originalMailboxId: sourceMailboxId,
|
|
209
|
-
originalUid: message.uid,
|
|
210
|
-
});
|
|
211
|
-
|
|
212
268
|
await this.addressService.reconcileJunkOnlyForMessage(
|
|
213
269
|
messageId,
|
|
214
270
|
await this.mailboxSpecialUseService.resolveJunkRolesForConfig(
|