@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.
@@ -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
- IMessageRepository,
5
- IThreadMessageRepository,
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
- const buildLogger = (): {
20
- log: PlacementMoveLogger;
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
- return {
27
- log: {
28
- info: (obj, msg) => infos.push({ obj, msg }),
29
- error: (obj, msg) => errors.push({ obj, msg }),
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 deletedMessages: string[] = [];
56
- const deletedThreadMessages: Array<{
57
- accountConfigId: string;
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(errors.length, 0, "no alarm for the expected/routine outcome");
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((e) => e.obj.metric === "placement_move_stale_row_reconciled"),
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 exists at the source — marker LEFT PENDING (never dropped), alarm logged, never re-thrown", async () => {
113
- const deletedMessages: string[] = [];
114
- const markerDeletes: string[] = [];
115
- const { log, errors } = buildLogger();
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
- // The marker is the lock that stops a resync from correcting the message
149
- // back to its server location (rule 3) — dropping it here would remove
150
- // that protection while the move is still unresolved.
151
- assert.deepEqual(markerDeletes, []);
152
- assert.deepEqual(deletedMessages, []);
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
- "expected an alert-shaped log for the broken case",
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
- it("a dropped FETCH row is not absence: the message is still at the source — marker and local rows survive", async () => {
160
- const deletedMessages: string[] = [];
161
- const deletedThreadMessages: unknown[] = [];
162
- const markerDeletes: string[] = [];
163
- const { log, errors } = buildLogger();
164
-
165
- const deps: ResolveExhaustedPlacementMoveDeps = {
166
- markerService: {
167
- delete: async (id: string) => {
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
- const result = await resolveExhaustedPlacementMoveFailure(deps, {
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(markerDeletes, []);
202
- assert.deepEqual(deletedMessages, []);
203
- assert.deepEqual(deletedThreadMessages, []);
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 { log } = buildLogger();
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
- extends StaleMessageReconcileDeps {
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 marker is left in place (not cleared) so a
49
- * later resync can never "correct" the message back to its server
50
- * location out from under an operator's investigation — dropping it here
51
- * would remove the very lock rule 3 depends on. Logged with an
52
- * `alert`-shaped entry for an operator alarm; never re-thrown (terminal —
53
- * the caller acks either way, since retrying a stale or permanently-broken
54
- * move can never succeed).
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 marker stays pending and the
61
- * alert stands until someone clears it. The reverse mistake deletes live
62
- * mail, so the cost is paid deliberately: a stale alert is recoverable, a
63
- * deleted message is not.
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 pending for operator investigation",
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
- updateForMove: async (
64
+ transitionPlacement: async (
64
65
  _messageId: string,
65
- input: UpdateMessageMoveInput,
66
+ expected: PlacementPredicate,
67
+ next: PlacementTransitionInput,
66
68
  ) => {
67
- Object.assign(row, input);
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;
@@ -7,7 +7,11 @@ import type {
7
7
  IMessageRepository,
8
8
  IThreadMessageRepository,
9
9
  } from "@remit/data-ports";
10
- import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
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(