@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.
- package/package.json +1 -1
- package/src/index.ts +16 -2
- package/src/mailbox-management.test.ts +38 -20
- package/src/mailbox-management.ts +47 -22
- package/src/mailbox-presence.test.ts +72 -0
- package/src/mailbox-presence.ts +49 -27
- package/src/mailbox-queue.ts +34 -15
- package/src/mailbox-rename-intent.test.ts +143 -0
- package/src/mailbox-rename-subtree.test.ts +22 -1
- package/src/mailbox-sync.test.ts +170 -28
- package/src/mailbox-sync.ts +57 -18
- 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
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(
|
|
@@ -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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
package/src/placement-settled.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { MessageItem } from "@remit/data-ports";
|
|
2
|
-
import { MessageStatus
|
|
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
|
|
16
|
-
*
|
|
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 =>
|
|
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
|
-
*
|
|
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
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* and
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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"
|
|
81
|
+
export type PlacementBinding = "consistent" | "in_flight";
|
|
69
82
|
|
|
70
83
|
export const placementBindingOf = (
|
|
71
84
|
message: Pick<
|
|
72
85
|
MessageItem,
|
|
73
|
-
| "
|
|
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;
|