@remit/mailbox-service 0.0.91 → 0.0.93

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.
@@ -30,7 +30,6 @@ import {
30
30
  MailboxCursorState,
31
31
  MessageKeywordFlag,
32
32
  MessageStatus,
33
- MessageSyncStatus,
34
33
  MessageSystemFlag,
35
34
  StarColor,
36
35
  } from "@remit/domain-enums";
@@ -149,22 +148,22 @@ export const addressSightingIn = (
149
148
  * reconciles rather than waits (imap-mutations R2): it never blocks on a
150
149
  * mutation, it re-reads the row on the next sighting after the move settles.
151
150
  *
152
- * In flight is read off `status`, never off `syncStatus` (#1096). Every
151
+ * In flight is `status`, and `status` alone (#1096, imap-mutations R3). Every
153
152
  * outbound mutation writes `moving` or `deleting` and only settling returns the
154
- * row to `active`, while an ordinary inbound row stays `pending` for its whole
155
- * life because nothing on the sync path promotes it — so demanding `synced`
156
- * here meant ordinary mail never re-pointed at all. `failed` is still refused:
157
- * a mutation that gave up leaves it set on a row put back to `active`, and
158
- * nothing routine settles that row afterwards.
153
+ * row to `active`, so `active` is precisely the set nothing is coming for —
154
+ * whatever `syncStatus` says about how the row got there. A row put back by
155
+ * `abandonDelete` carries `abandoned`, and it needs this repair more than any
156
+ * other row does: nothing routine settles it, and reader shares its mailboxes,
157
+ * so refusing it left a message whose delete was refused unable to follow a
158
+ * move the user then made in another client.
159
159
  */
160
160
  export const repointsOnSighting = (
161
161
  mailbox: MailboxItem,
162
- message: Pick<MessageItem, "mailboxId" | "status" | "syncStatus">,
162
+ message: Pick<MessageItem, "mailboxId" | "status">,
163
163
  ): boolean => {
164
164
  if (message.mailboxId === mailbox.mailboxId) return false;
165
165
  if (isVirtualCopyMailbox(mailbox)) return false;
166
- if (message.status !== MessageStatus.active) return false;
167
- return message.syncStatus !== MessageSyncStatus.failed;
166
+ return message.status === MessageStatus.active;
168
167
  };
169
168
 
170
169
  /**
@@ -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;