@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.
@@ -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(
@@ -9,10 +9,10 @@ import { carriesForeignUid, placementBindingOf } from "./placement-settled.js";
9
9
  * only end-to-end specs covered it, so a change made for the client's benefit
10
10
  * could have moved it without anything going red.
11
11
  *
12
- * Note what these cases say about `syncStatus: failed`: it answers `abandoned`
13
- * here for a row that may well be mid-retry. That is correct for a guard whose
14
- * consequence is a temporary refusal, and it is exactly why no user-facing
15
- * statement may be derived from that field alone.
12
+ * Note what these cases say about `syncStatus`: `failed` is a transient attempt
13
+ * with a redelivery behind it, so a foreign-uid row carrying it is `in_flight`
14
+ * and gets the settle ceiling. Only `abandoned` — the give-up value of the
15
+ * placement state model (imap-mutations R3) — refuses outright.
16
16
  */
17
17
  const row = (over: Partial<MessageItem>): MessageItem =>
18
18
  ({
@@ -38,10 +38,40 @@ describe("placementBindingOf", () => {
38
38
  assert.equal(placementBindingOf(row({})), "in_flight");
39
39
  });
40
40
 
41
- test("`failed` on a foreign-uid row is refused as abandoned", () => {
41
+ /**
42
+ * A give-up cannot reach this pair. `abandoned` is written only alongside
43
+ * `active` or `deleted`, on a row already put back on a placement the server
44
+ * confirmed, so the binding it produces is `consistent` — there is no
45
+ * give-up member here to return, and no dependent mutation is refused on
46
+ * account of one.
47
+ */
48
+ test("a give-up never presents as a foreign-uid row at all", () => {
49
+ assert.equal(
50
+ placementBindingOf(
51
+ row({
52
+ status: MessageStatus.active,
53
+ syncStatus: MessageSyncStatus.abandoned,
54
+ mailboxId: "mbx-src",
55
+ }),
56
+ ),
57
+ "consistent",
58
+ );
59
+ });
60
+
61
+ test("`failed` on a foreign-uid row is mid-retry, so it waits rather than refusing", () => {
42
62
  assert.equal(
43
63
  placementBindingOf(row({ syncStatus: MessageSyncStatus.failed })),
44
- "abandoned",
64
+ "in_flight",
65
+ );
66
+ });
67
+
68
+ // R3's state table calls `deleting` in flight exactly as `moving` is: a
69
+ // delete points the row at Trash with the source's uid, so a dependent
70
+ // mutation reading it resolves the same mismatched pair.
71
+ test("a delete in flight is unsettled, not settled", () => {
72
+ assert.equal(
73
+ placementBindingOf(row({ status: MessageStatus.deleting })),
74
+ "in_flight",
45
75
  );
46
76
  });
47
77
 
@@ -1,5 +1,5 @@
1
1
  import type { MessageItem } from "@remit/data-ports";
2
- import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
2
+ import { MessageStatus } from "@remit/domain-enums";
3
3
 
4
4
  /**
5
5
  * Whether the message row's placement is still an unconfirmed local write
@@ -12,18 +12,26 @@ import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
12
12
  *
13
13
  * `syncStatus` is not the signal. An ordinary freshly-synced inbound row is
14
14
  * `pending` forever (nothing on the inbound path promotes it), so keying off it
15
- * defers every outbound mutation in the product. `status` is set to `moving`
16
- * only by an actual move and cleared only once that move settles.
15
+ * defers every outbound mutation in the product. `status` is what an actual
16
+ * mutation writes and what settling clears.
17
+ *
18
+ * Both in-flight states count, per the R3 state table: a delete points the row
19
+ * at Trash with the source's uid exactly as a move points it at the
20
+ * destination, so a dependent mutation that reads a `deleting` row resolves the
21
+ * same mismatched pair. Reading `moving` alone let a move enqueued behind a
22
+ * delete bind to the row mid-delete and be dropped when the delete settled.
17
23
  */
18
24
  export const isPlacementUnsettled = (
19
25
  message: Pick<MessageItem, "status">,
20
- ): boolean => message.status === MessageStatus.moving;
26
+ ): boolean =>
27
+ message.status === MessageStatus.moving ||
28
+ message.status === MessageStatus.deleting;
21
29
 
22
30
  /**
23
31
  * Whether the row's `uid` belongs to a folder other than the one `mailboxId`
24
32
  * names — the state that makes the pair a lie rather than merely incomplete.
25
33
  *
26
- * `updateForMove` is the only writer of this shape: it points the row at the
34
+ * The optimistic half of a move is the only writer of this shape: it points the row at the
27
35
  * destination and records the pre-move pair, leaving `uid` untouched until the
28
36
  * server confirms and `updateUid` replaces it. A row with no `originalUid` was
29
37
  * never moved, so its uid names nothing but itself — a freshly copied row is
@@ -57,32 +65,27 @@ export const bindsForeignUid = (
57
65
  ): boolean => isPlacementUnsettled(message) && carriesForeignUid(message);
58
66
 
59
67
  /**
60
- * `in_flight` — a mover is still working on the row, so waiting resolves it.
61
- * `abandoned` — the last attempt failed (`syncStatus: failed`) and the row is
62
- * still `moving`: either a redelivery is pending, or the record dead-lettered
63
- * and nothing is coming for it. The two are indistinguishable from the row
64
- * (`message-settlement.ts`), and the pair is a lie either way, so neither is
65
- * worth spending the settle ceiling on. A mover that exhausts its budget
66
- * settles the row against IMAP instead of leaving this state behind (#1005).
68
+ * `in_flight` — a mutation is still working on the row, so waiting resolves it.
69
+ * That includes a row mid-retry: `message-move`, `message-delete` and
70
+ * `message-copy` write `syncStatus: failed` on an ordinary transient attempt
71
+ * and re-throw for redelivery, so a failed attempt is a mutation about to
72
+ * succeed and the settle ceiling is exactly what it is for (imap-mutations R3).
73
+ *
74
+ * There is no give-up member, because a give-up cannot reach here: `abandoned`
75
+ * is written only alongside `status: active` (a hand-back) or `deleted` (a copy
76
+ * that never existed), and the row it leaves names a placement the server
77
+ * confirmed — so it binds `consistent` and no dependent mutation is refused on
78
+ * account of it. A mutation that exhausts its budget settles the row against
79
+ * IMAP rather than leaving a mismatched pair behind (#1005).
67
80
  */
68
- export type PlacementBinding = "consistent" | "in_flight" | "abandoned";
81
+ export type PlacementBinding = "consistent" | "in_flight";
69
82
 
70
83
  export const placementBindingOf = (
71
84
  message: Pick<
72
85
  MessageItem,
73
- | "status"
74
- | "syncStatus"
75
- | "mailboxId"
76
- | "uid"
77
- | "originalMailboxId"
78
- | "originalUid"
86
+ "status" | "mailboxId" | "uid" | "originalMailboxId" | "originalUid"
79
87
  >,
80
- ): PlacementBinding => {
81
- if (!bindsForeignUid(message)) return "consistent";
82
- return message.syncStatus === MessageSyncStatus.failed
83
- ? "abandoned"
84
- : "in_flight";
85
- };
88
+ ): PlacementBinding => (bindsForeignUid(message) ? "in_flight" : "consistent");
86
89
 
87
90
  export interface PlacementSettleOptions {
88
91
  timeoutMs: number;