@remit/mailbox-service 0.0.111 → 0.0.112

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.
Files changed (123) hide show
  1. package/package.json +21 -6
  2. package/src/body-part-mapper.ts +51 -1
  3. package/src/body-sync-queue.test.ts +39 -99
  4. package/src/body-sync-terminal.test.ts +115 -277
  5. package/src/body-sync-terminal.ts +10 -12
  6. package/src/body-sync.test.ts +925 -0
  7. package/src/body-sync.ts +156 -235
  8. package/src/classification-backfill.test.ts +197 -527
  9. package/src/classify-message.test.ts +11 -74
  10. package/src/external-move.test.ts +102 -209
  11. package/src/external-move.ts +13 -0
  12. package/src/filter-operations.test.ts +319 -0
  13. package/src/filter-operations.ts +377 -0
  14. package/src/filter-update.test.ts +313 -0
  15. package/src/filters/filter-config.test.ts +55 -197
  16. package/src/filters/filter-config.ts +4 -39
  17. package/src/filters/match.test.ts +1 -38
  18. package/src/filters/match.ts +3 -52
  19. package/src/filters/pipeline.test.ts +158 -289
  20. package/src/filters/pipeline.ts +56 -170
  21. package/src/flag-push-terminal.ts +9 -11
  22. package/src/flag-push.test.ts +76 -372
  23. package/src/flag-queue.test.ts +197 -0
  24. package/src/folder-operations.test.ts +273 -0
  25. package/src/folder-operations.ts +217 -0
  26. package/src/forwarded-message.test.ts +122 -0
  27. package/src/imapflow-connect-retry.test.ts +76 -113
  28. package/src/imapflow-connection.test.ts +34 -0
  29. package/src/imapflow-connection.ts +5 -4
  30. package/src/index.ts +59 -12
  31. package/src/list-id-backfill.test.ts +133 -499
  32. package/src/mailbox-cursor.test.ts +81 -189
  33. package/src/mailbox-delete-intent.test.ts +78 -205
  34. package/src/mailbox-intent.ts +5 -3
  35. package/src/mailbox-management.test.ts +107 -220
  36. package/src/mailbox-management.ts +1 -2
  37. package/src/mailbox-presence.test.ts +36 -34
  38. package/src/mailbox-queue.ts +2 -20
  39. package/src/mailbox-rename-intent.test.ts +156 -325
  40. package/src/mailbox-rename-subtree.test.ts +205 -582
  41. package/src/mailbox-sync.test.ts +182 -744
  42. package/src/message-move.test.ts +726 -0
  43. package/src/message-move.ts +227 -57
  44. package/src/message-read.ts +87 -0
  45. package/src/message-save-retry.test.ts +90 -0
  46. package/src/message-sync-flags.test.ts +10 -1
  47. package/src/message-sync.test.ts +762 -0
  48. package/src/message-sync.ts +409 -239
  49. package/src/mime-walker.ts +16 -15
  50. package/src/organize-job.ts +93 -0
  51. package/src/outbox-attachment.test.ts +97 -209
  52. package/src/outbox-failed-recovery.test.ts +198 -269
  53. package/src/outbox-queue.test.ts +24 -0
  54. package/src/outbox-queue.ts +25 -8
  55. package/src/ownership.ts +140 -0
  56. package/src/placement-move-terminal.ts +9 -12
  57. package/src/placement-move.ts +33 -6
  58. package/src/placement-settled.ts +28 -4
  59. package/src/placement.test.ts +404 -0
  60. package/src/quarantine.test.ts +90 -148
  61. package/src/quarantine.ts +3 -2
  62. package/src/refusal.ts +29 -0
  63. package/src/restore-source-placement.ts +0 -4
  64. package/src/spam-report.test.ts +238 -710
  65. package/src/sync-warning.test.ts +213 -0
  66. package/src/sync-warning.ts +56 -0
  67. package/src/test-helpers/fake-imap.ts +690 -0
  68. package/src/test-helpers/test-queue.ts +56 -0
  69. package/src/test-helpers/test-store.ts +299 -0
  70. package/src/thread-root.test.ts +29 -0
  71. package/src/thread-root.ts +51 -0
  72. package/src/types/mailparser-augment.d.ts +8 -0
  73. package/src/adversarial-mime.e2e.test.ts +0 -307
  74. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  75. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  76. package/src/body-sync-category-denormalize.test.ts +0 -352
  77. package/src/body-sync-category-override.test.ts +0 -501
  78. package/src/body-sync-category-writeonce.test.ts +0 -234
  79. package/src/body-sync-filter.test.ts +0 -237
  80. package/src/body-sync-message-gone.test.ts +0 -139
  81. package/src/body-sync-placement-flags.test.ts +0 -347
  82. package/src/body-sync-placement-writeonce.test.ts +0 -337
  83. package/src/body-sync-quarantine.test.ts +0 -276
  84. package/src/body-sync-resync.test.ts +0 -605
  85. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  86. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  87. package/src/filters/anchor-drift.ts +0 -55
  88. package/src/filters/message-embedder.test.ts +0 -39
  89. package/src/filters/message-embedder.ts +0 -33
  90. package/src/flag-push-terminal.test.ts +0 -209
  91. package/src/flag-queue-stars.test.ts +0 -171
  92. package/src/imapflow-connection.e2e.test.ts +0 -335
  93. package/src/imapflow-connection.integ.test.ts +0 -316
  94. package/src/message-delete-junk-reconcile.test.ts +0 -117
  95. package/src/message-delete-no-trash.test.ts +0 -245
  96. package/src/message-delete-unsettled-placement.test.ts +0 -396
  97. package/src/message-move-copy.test.ts +0 -562
  98. package/src/message-move-empty-trash.test.ts +0 -326
  99. package/src/message-move-same-mailbox.test.ts +0 -139
  100. package/src/message-move-unsettled-placement.test.ts +0 -378
  101. package/src/message-sync-address-spoof.test.ts +0 -293
  102. package/src/message-sync-category.test.ts +0 -142
  103. package/src/message-sync-changedsince.test.ts +0 -840
  104. package/src/message-sync-junk-harvest.test.ts +0 -244
  105. package/src/message-sync-quarantine.test.ts +0 -416
  106. package/src/message-sync-repoint.test.ts +0 -419
  107. package/src/outbox-queue-settled-row.test.ts +0 -140
  108. package/src/pass-through-unit-of-work.ts +0 -17
  109. package/src/placement-move-terminal.test.ts +0 -300
  110. package/src/placement-move-unsettled.test.ts +0 -275
  111. package/src/placement-transition-race.test.ts +0 -482
  112. package/src/stale-message-reconcile.test.ts +0 -82
  113. package/src/stale-message-reconcile.ts +0 -55
  114. package/src/test-helpers/isolated-mailbox.ts +0 -44
  115. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
@@ -0,0 +1,140 @@
1
+ import type { AccountItem, MailboxItem } from "@remit/data-ports";
2
+ import { MailboxSyncStatus } from "@remit/domain-enums";
3
+ import {
4
+ type AccessMode,
5
+ accept,
6
+ invalid,
7
+ type Refusable,
8
+ refuse,
9
+ } from "./refusal.js";
10
+
11
+ // A read refuses with NotFound so another tenant's account does not leak its
12
+ // existence; an action names the resource already, so it is refused outright.
13
+ export const ownedAccount = <
14
+ A extends Pick<AccountItem, "accountId" | "accountConfigId">,
15
+ >(
16
+ account: A,
17
+ accountConfigId: string,
18
+ mode: AccessMode,
19
+ ): Refusable<A> => {
20
+ if (account.accountConfigId === accountConfigId) return accept(account);
21
+ if (mode === "read") {
22
+ return refuse({
23
+ kind: "NotFound",
24
+ message: `Account not found: ${account.accountId}`,
25
+ });
26
+ }
27
+ return refuse({
28
+ kind: "Forbidden",
29
+ message: `Account ${account.accountId} not in account config`,
30
+ });
31
+ };
32
+
33
+ // Owning the account in a path proves nothing about the mailboxId beside it.
34
+ export const folderInAccount = <
35
+ M extends Pick<MailboxItem, "mailboxId" | "accountId">,
36
+ >(
37
+ mailbox: M,
38
+ accountId: string,
39
+ mode: AccessMode,
40
+ ): Refusable<M> => {
41
+ if (mailbox.accountId === accountId) return accept(mailbox);
42
+ if (mode === "read") {
43
+ return refuse({
44
+ kind: "NotFound",
45
+ message: `Mailbox not found: ${mailbox.mailboxId}`,
46
+ });
47
+ }
48
+ return refuse({
49
+ kind: "Forbidden",
50
+ message: `Mailbox ${mailbox.mailboxId} not in account`,
51
+ });
52
+ };
53
+
54
+ const UNSETTLED_REASON: Partial<
55
+ Record<MailboxItem["syncStatus"], (path: string) => string>
56
+ > = {
57
+ [MailboxSyncStatus.pending]: (path) =>
58
+ `“${path}” isn’t ready yet — the mail server hasn’t confirmed it.`,
59
+ [MailboxSyncStatus.deleting]: (path) => `“${path}” is being deleted.`,
60
+ [MailboxSyncStatus.failed]: (path) =>
61
+ `The last change to “${path}” failed. Retry or dismiss it first.`,
62
+ };
63
+
64
+ // A durable reference binds only to a folder the mail server has confirmed
65
+ // (imap-mutations R2: wait). This is the server-side floor under the client's
66
+ // own wait, because a script calling the API directly gets no wait at all.
67
+ export const settledFolder = <
68
+ M extends Pick<MailboxItem, "mailboxId" | "fullPath" | "syncStatus">,
69
+ >(
70
+ target: M,
71
+ ): Refusable<M> => {
72
+ const reason = UNSETTLED_REASON[target.syncStatus];
73
+ if (!reason) return accept(target);
74
+ return refuse({
75
+ kind: "NotSettled",
76
+ message: reason(target.fullPath),
77
+ mailboxId: target.mailboxId,
78
+ syncStatus: target.syncStatus,
79
+ });
80
+ };
81
+
82
+ export interface MessageOwnershipClient {
83
+ message: { get(messageId: string): Promise<{ mailboxId: string }> };
84
+ mailbox: {
85
+ get(
86
+ accountId: string,
87
+ mailboxIds: string[],
88
+ ): Promise<{ mailboxId: string; accountId: string }[]>;
89
+ };
90
+ account: {
91
+ listAllByAccountConfig(
92
+ accountConfigId: string,
93
+ ): Promise<{ accountId: string }[]>;
94
+ };
95
+ }
96
+
97
+ // Messages carry no tenant, so ownership resolves through the caller's own
98
+ // accounts: a mailbox resolves only under the account that owns it, and the
99
+ // whole batch must land on one account because the downstream services take
100
+ // a single accountId.
101
+ export const ownedMessages = async (
102
+ client: MessageOwnershipClient,
103
+ messageIds: string[],
104
+ callerAccountConfigId: string,
105
+ mode: AccessMode,
106
+ ): Promise<Refusable<string>> => {
107
+ const messages = await Promise.all(
108
+ messageIds.map((id) => client.message.get(id)),
109
+ );
110
+ const mailboxIds = [...new Set(messages.map((m) => m.mailboxId))];
111
+
112
+ const ownedAccounts = await client.account.listAllByAccountConfig(
113
+ callerAccountConfigId,
114
+ );
115
+
116
+ const accountByMailbox = new Map<string, string>();
117
+ for (const { accountId } of ownedAccounts) {
118
+ const rows = await client.mailbox.get(accountId, mailboxIds);
119
+ for (const row of rows) {
120
+ accountByMailbox.set(row.mailboxId, row.accountId);
121
+ }
122
+ }
123
+
124
+ if (mailboxIds.some((id) => !accountByMailbox.has(id))) {
125
+ if (mode === "read") {
126
+ return refuse({ kind: "NotFound", message: "Message not found" });
127
+ }
128
+ return refuse({
129
+ kind: "Forbidden",
130
+ message: "Message not in account config",
131
+ });
132
+ }
133
+
134
+ const accountIds = [...new Set(accountByMailbox.values())];
135
+ const [accountId] = accountIds;
136
+ if (accountIds.length !== 1 || accountId === undefined) {
137
+ return invalid("All messages must belong to the same account");
138
+ }
139
+ return accept(accountId);
140
+ };
@@ -1,3 +1,4 @@
1
+ import type { IMessageRepository, MessageItem } from "@remit/data-ports";
1
2
  import { MessageMutation, MessageSyncStatus } from "@remit/domain-enums";
2
3
  import { isMessageGoneFromOpenMailbox } from "./message-presence.js";
3
4
  import type { PlacementMoveLogger } from "./placement-move.js";
@@ -5,22 +6,19 @@ import {
5
6
  type RestoreSourcePlacementDeps,
6
7
  restoreSourcePlacement,
7
8
  } from "./restore-source-placement.js";
8
- import {
9
- reconcileStaleMessage,
10
- type StaleMessageReconcileDeps,
11
- } from "./stale-message-reconcile.js";
12
9
  import type { IImapConnection } from "./types.js";
13
10
 
14
11
  export interface ResolveExhaustedPlacementMoveDeps {
15
- messageService: StaleMessageReconcileDeps["messageService"] &
12
+ messageService: Pick<IMessageRepository, "deleteIfPlacement"> &
16
13
  RestoreSourcePlacementDeps["messageService"];
17
- threadMessageService: StaleMessageReconcileDeps["threadMessageService"] &
18
- RestoreSourcePlacementDeps["threadMessageService"];
14
+ threadMessageService: RestoreSourcePlacementDeps["threadMessageService"];
19
15
  markerService: { delete(messageId: string): Promise<void> };
20
16
  log: PlacementMoveLogger;
21
17
  }
22
18
 
23
19
  export interface ResolveExhaustedPlacementMoveInput {
20
+ /** Where the row stood when the caller read it; the delete holds only while it still does. */
21
+ placement: Pick<MessageItem, "status" | "mailboxId" | "uid">;
24
22
  accountId: string;
25
23
  accountConfigId: string;
26
24
  messageId: string;
@@ -47,7 +45,7 @@ export interface ResolveExhaustedPlacementMoveResult {
47
45
  * than by a FETCH coming back empty. Per invariant 2, an external delete
48
46
  * supersedes the marker entirely: the marker is dropped and the stale
49
47
  * Message/ThreadMessage rows are deleted via
50
- * {@link reconcileStaleMessage}. This is also the
48
+ * `deleteIfPlacement`. This is also the
51
49
  * outcome for the (rarer, functionally indistinguishable from here) case
52
50
  * where a foreign client moved the message elsewhere — either way, our
53
51
  * prediction no longer holds, and the marker cannot be honoured. Metric
@@ -94,10 +92,9 @@ export const resolveExhaustedPlacementMoveFailure = async (
94
92
 
95
93
  if (await isMessageGoneFromOpenMailbox(connection, uid)) {
96
94
  await deps.markerService.delete(messageId);
97
- const { threadMessagesDeleted } = await reconcileStaleMessage(
98
- deps,
99
- accountConfigId,
95
+ const reconciled = await deps.messageService.deleteIfPlacement(
100
96
  messageId,
97
+ input.placement,
101
98
  );
102
99
  deps.log.info(
103
100
  {
@@ -107,7 +104,7 @@ export const resolveExhaustedPlacementMoveFailure = async (
107
104
  messageId,
108
105
  uid,
109
106
  sourceMailboxPath,
110
- threadMessagesDeleted,
107
+ reconciled,
111
108
  },
112
109
  "Message no longer at its pending-move source after retry exhaustion (external delete or move); marker dropped, stale row reconciled",
113
110
  );
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { SendMessageCommand, type SQSClient } from "@aws-sdk/client-sqs";
3
3
  import type {
4
4
  IAddressRepository,
5
+ IMailboxRepository,
5
6
  IMailboxSpecialUseRepository,
6
7
  IMessagePlacementMoveRepository,
7
8
  IMessageRepository,
@@ -34,6 +35,9 @@ export interface PlacementMovePushEvent {
34
35
  messageId: string;
35
36
  }
36
37
 
38
+ /** Whether a filing happened. `refused` names a destination outside the message's account. */
39
+ export type PlacementMoveOutcome = "filed" | "skipped" | "refused";
40
+
37
41
  export interface PlacementMoveLogger {
38
42
  info(obj: Record<string, unknown>, msg: string): void;
39
43
  error(obj: Record<string, unknown>, msg: string): void;
@@ -55,6 +59,7 @@ export interface PlacementMoveConfig {
55
59
  * every auto-filed spam suggestible in the compose picker.
56
60
  */
57
61
  mailboxSpecialUseService: IMailboxSpecialUseRepository;
62
+ mailboxService: Pick<IMailboxRepository, "resolveAccountId">;
58
63
  sqsQueueUrl: string;
59
64
  sqsEndpoint?: string;
60
65
  logger?: PlacementMoveLogger;
@@ -99,6 +104,7 @@ export class PlacementMoveService {
99
104
  private markerService: IMessagePlacementMoveRepository;
100
105
  private addressService: IAddressRepository;
101
106
  private mailboxSpecialUseService: IMailboxSpecialUseRepository;
107
+ private mailboxService: Pick<IMailboxRepository, "resolveAccountId">;
102
108
  private sqs: SQSClient;
103
109
  private queueUrl: string;
104
110
  private log: PlacementMoveLogger;
@@ -111,6 +117,7 @@ export class PlacementMoveService {
111
117
  this.markerService = config.markerService;
112
118
  this.addressService = config.addressService;
113
119
  this.mailboxSpecialUseService = config.mailboxSpecialUseService;
120
+ this.mailboxService = config.mailboxService;
114
121
  this.queueUrl = config.sqsQueueUrl;
115
122
  this.log = config.logger ?? noopLogger;
116
123
  this.moveSettleTimeoutMs =
@@ -128,7 +135,7 @@ export class PlacementMoveService {
128
135
  messageId: string,
129
136
  destinationMailboxId: string,
130
137
  accountId: string,
131
- ): Promise<void> => {
138
+ ): Promise<PlacementMoveOutcome> => {
132
139
  let message = await this.messageService.get(messageId);
133
140
 
134
141
  // Recovery: a marker already exists for THIS message and destination —
@@ -156,7 +163,7 @@ export class PlacementMoveService {
156
163
  // queued / processing / processed already has its own driver in
157
164
  // flight (the SQS message, or the reconciler currently running) —
158
165
  // nothing to do here either way.
159
- return;
166
+ return "filed";
160
167
  }
161
168
 
162
169
  // A different destination, decided while an earlier move is still
@@ -176,7 +183,7 @@ export class PlacementMoveService {
176
183
  { messageId, accountId, destinationMailboxId },
177
184
  "Placement move not applied: this message is being deleted",
178
185
  );
179
- return;
186
+ return "skipped";
180
187
  }
181
188
 
182
189
  if (isPlacementUnsettled(message)) {
@@ -189,7 +196,7 @@ export class PlacementMoveService {
189
196
  { messageId, accountId, destinationMailboxId },
190
197
  "Placement move not applied: a delete claimed this message while the earlier move settled",
191
198
  );
192
- return;
199
+ return "skipped";
193
200
  }
194
201
  if (isPlacementUnsettled(message)) {
195
202
  throw new Error(
@@ -203,7 +210,26 @@ export class PlacementMoveService {
203
210
  // Genuine no-op: nothing pending for this destination, and the message
204
211
  // is already there (a duplicate verdict recomputed after a confirmed
205
212
  // move).
206
- if (sourceMailboxId === destinationMailboxId) return;
213
+ if (sourceMailboxId === destinationMailboxId) return "skipped";
214
+
215
+ // IMAP has no MOVE between accounts, so a filing into a folder this
216
+ // account does not own would only ever be handed back.
217
+ if (
218
+ (await this.mailboxService.resolveAccountId(destinationMailboxId)) !==
219
+ accountId
220
+ ) {
221
+ this.log.error(
222
+ {
223
+ alert: "placement_move_foreign_folder",
224
+ messageId,
225
+ accountId,
226
+ sourceMailboxId,
227
+ destinationMailboxId,
228
+ },
229
+ "Placement move refused: the destination is not a folder of this message's account",
230
+ );
231
+ return "refused";
232
+ }
207
233
 
208
234
  // Fresh move. Strictly sequential — nothing here is parallel: the local
209
235
  // move (marker + ThreadMessage + Message row) fully commits BEFORE the
@@ -256,7 +282,7 @@ export class PlacementMoveService {
256
282
  { messageId, accountId, sourceMailboxId, destinationMailboxId },
257
283
  "Placement move not applied: something else claimed this message's placement first",
258
284
  );
259
- return;
285
+ return "skipped";
260
286
  }
261
287
 
262
288
  await this.updateThreadMessageMailbox(
@@ -286,6 +312,7 @@ export class PlacementMoveService {
286
312
  { messageId, accountId, sourceMailboxId, destinationMailboxId },
287
313
  "Placement move applied locally; pending marker queued for IMAP push",
288
314
  );
315
+ return "filed";
289
316
  };
290
317
 
291
318
  private updateThreadMessageMailbox = async (
@@ -46,17 +46,32 @@ export const isPlacementUnsettled = (
46
46
  * untouched, so anything that resolves a uid off a row it did not settle
47
47
  * itself asks this rather than the binding below (issue #1217).
48
48
  */
49
- export const carriesForeignUid = (
50
- message: Pick<
49
+ export const carriesForeignUid = <
50
+ T extends Pick<
51
51
  MessageItem,
52
52
  "mailboxId" | "uid" | "originalMailboxId" | "originalUid"
53
53
  >,
54
- ): boolean =>
54
+ >(
55
+ message: T,
56
+ ): message is T & { originalMailboxId: string; originalUid: number } =>
55
57
  message.originalUid !== undefined &&
56
58
  message.originalUid === message.uid &&
57
59
  message.originalMailboxId !== undefined &&
58
60
  message.originalMailboxId !== message.mailboxId;
59
61
 
62
+ /**
63
+ * The folder the row's `uid` is a uid in: the source while a move has not
64
+ * confirmed, otherwise the folder the row is in. A fetch by uid is only safe
65
+ * from this folder; from any other the same number names a different message.
66
+ */
67
+ export const uidMailboxIdOf = (
68
+ message: Pick<
69
+ MessageItem,
70
+ "mailboxId" | "uid" | "originalMailboxId" | "originalUid"
71
+ >,
72
+ ): string =>
73
+ carriesForeignUid(message) ? message.originalMailboxId : message.mailboxId;
74
+
60
75
  export const bindsForeignUid = (
61
76
  message: Pick<
62
77
  MessageItem,
@@ -64,12 +79,18 @@ export const bindsForeignUid = (
64
79
  >,
65
80
  ): boolean => isPlacementUnsettled(message) && carriesForeignUid(message);
66
81
 
82
+ /** A copy at `uid: 0`, which names nothing on the server until COPYUID lands (#1387). */
83
+ const awaitsCopiedUid = (
84
+ message: Pick<MessageItem, "status" | "uid">,
85
+ ): boolean => message.status === MessageStatus.moving && message.uid === 0;
86
+
67
87
  /**
68
88
  * `in_flight` — a mutation is still working on the row, so waiting resolves it.
69
89
  * That includes a row mid-retry: `message-move`, `message-delete` and
70
90
  * `message-copy` write `syncStatus: failed` on an ordinary transient attempt
71
91
  * and re-throw for redelivery, so a failed attempt is a mutation about to
72
92
  * succeed and the settle ceiling is exactly what it is for (imap-mutations R3).
93
+ * It also includes a fresh copy, whose uid names nothing until COPYUID lands.
73
94
  *
74
95
  * There is no give-up member, because a give-up cannot reach here: `abandoned`
75
96
  * is written only alongside `status: active` (a hand-back) or `deleted` (a copy
@@ -85,7 +106,10 @@ export const placementBindingOf = (
85
106
  MessageItem,
86
107
  "status" | "mailboxId" | "uid" | "originalMailboxId" | "originalUid"
87
108
  >,
88
- ): PlacementBinding => (bindsForeignUid(message) ? "in_flight" : "consistent");
109
+ ): PlacementBinding =>
110
+ bindsForeignUid(message) || awaitsCopiedUid(message)
111
+ ? "in_flight"
112
+ : "consistent";
89
113
 
90
114
  export interface PlacementSettleOptions {
91
115
  timeoutMs: number;