@remit/backend 0.0.108 → 0.0.110
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/derive/enrichThreadRows.test.ts +33 -4
- package/src/derive/enrichThreadRows.ts +9 -2
- package/src/derive/filterThreadCriteria.test.ts +2 -0
- package/src/handlers/message.test.ts +7 -7
- package/src/handlers/message.ts +15 -11
- package/src/handlers/sync-progress.test.ts +47 -0
- package/src/handlers/sync-progress.ts +35 -0
- package/src/handlers/sync.ts +2 -9
package/package.json
CHANGED
|
@@ -9,7 +9,11 @@ import type {
|
|
|
9
9
|
} from "@remit/data-ports";
|
|
10
10
|
import { deriveAddressId } from "@remit/data-ports/id";
|
|
11
11
|
import { hasAbandonedDelete } from "@remit/data-ports/message-settlement";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
MessageMutation,
|
|
14
|
+
MessageStatus,
|
|
15
|
+
MessageSyncStatus,
|
|
16
|
+
} from "@remit/domain-enums";
|
|
13
17
|
import { type EnrichClient, enrichThreadRows } from "./enrichThreadRows.js";
|
|
14
18
|
|
|
15
19
|
const threadRow = (
|
|
@@ -273,22 +277,45 @@ describe("enrichThreadRows — the mutation pair", () => {
|
|
|
273
277
|
return result;
|
|
274
278
|
};
|
|
275
279
|
|
|
276
|
-
test("projects the pair an abandoned delete leaves behind", async () => {
|
|
280
|
+
test("projects the pair an abandoned delete leaves behind, and what it gave up on", async () => {
|
|
277
281
|
const result = await project({
|
|
278
282
|
status: MessageStatus.active,
|
|
279
|
-
syncStatus: MessageSyncStatus.
|
|
283
|
+
syncStatus: MessageSyncStatus.abandoned,
|
|
284
|
+
abandonedMutation: MessageMutation.delete,
|
|
280
285
|
});
|
|
281
286
|
assert.equal(result?.status, MessageStatus.active);
|
|
282
|
-
assert.equal(result?.syncStatus, MessageSyncStatus.
|
|
287
|
+
assert.equal(result?.syncStatus, MessageSyncStatus.abandoned);
|
|
288
|
+
assert.equal(result?.abandonedMutation, MessageMutation.delete);
|
|
283
289
|
assert.equal(
|
|
284
290
|
hasAbandonedDelete({
|
|
285
291
|
status: result?.status,
|
|
286
292
|
syncStatus: result?.syncStatus,
|
|
293
|
+
abandonedMutation: result?.abandonedMutation,
|
|
287
294
|
}),
|
|
288
295
|
true,
|
|
289
296
|
);
|
|
290
297
|
});
|
|
291
298
|
|
|
299
|
+
// Issue #1229. Both give-ups leave the same `status`/`syncStatus` pair, so
|
|
300
|
+
// the projection has to carry the third field or the client cannot tell a
|
|
301
|
+
// move that handed back from a delete that was refused.
|
|
302
|
+
test("projects a move that gave up as a move, never as a delete", async () => {
|
|
303
|
+
const result = await project({
|
|
304
|
+
status: MessageStatus.active,
|
|
305
|
+
syncStatus: MessageSyncStatus.abandoned,
|
|
306
|
+
abandonedMutation: MessageMutation.move,
|
|
307
|
+
});
|
|
308
|
+
assert.equal(result?.abandonedMutation, MessageMutation.move);
|
|
309
|
+
assert.equal(
|
|
310
|
+
hasAbandonedDelete({
|
|
311
|
+
status: result?.status,
|
|
312
|
+
syncStatus: result?.syncStatus,
|
|
313
|
+
abandonedMutation: result?.abandonedMutation,
|
|
314
|
+
}),
|
|
315
|
+
false,
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
292
319
|
test("projects a move mid-retry without calling it a give-up", async () => {
|
|
293
320
|
const result = await project({
|
|
294
321
|
status: MessageStatus.moving,
|
|
@@ -300,6 +327,7 @@ describe("enrichThreadRows — the mutation pair", () => {
|
|
|
300
327
|
hasAbandonedDelete({
|
|
301
328
|
status: result?.status,
|
|
302
329
|
syncStatus: result?.syncStatus,
|
|
330
|
+
abandonedMutation: result?.abandonedMutation,
|
|
303
331
|
}),
|
|
304
332
|
false,
|
|
305
333
|
);
|
|
@@ -324,6 +352,7 @@ describe("enrichThreadRows — the mutation pair", () => {
|
|
|
324
352
|
hasAbandonedDelete({
|
|
325
353
|
status: result?.status,
|
|
326
354
|
syncStatus: result?.syncStatus,
|
|
355
|
+
abandonedMutation: result?.abandonedMutation,
|
|
327
356
|
}),
|
|
328
357
|
false,
|
|
329
358
|
);
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
} from "@remit/data-ports";
|
|
9
9
|
import { deriveAddressId } from "@remit/data-ports/id";
|
|
10
10
|
import {
|
|
11
|
+
MessageMutation,
|
|
11
12
|
MessageStatus,
|
|
12
13
|
MessageSyncStatus,
|
|
13
14
|
SenderTrust,
|
|
@@ -63,6 +64,7 @@ const toResponse = (item: ThreadMessageItem): ThreadMessageResponse => ({
|
|
|
63
64
|
muted: false,
|
|
64
65
|
status: MessageStatus.active,
|
|
65
66
|
syncStatus: MessageSyncStatus.pending,
|
|
67
|
+
abandonedMutation: MessageMutation.none,
|
|
66
68
|
});
|
|
67
69
|
|
|
68
70
|
/**
|
|
@@ -110,7 +112,8 @@ export const planBatchFetch = (rows: ThreadMessageItem[]): BatchPlan => {
|
|
|
110
112
|
* Enrich a page of ThreadMessage rows with `senderTrust` and `muted` (both
|
|
111
113
|
* derived from the From Address's flags map), `authenticity`, `autoMoved` and
|
|
112
114
|
* `spamReport`, and the `status`/`syncStatus` pair an IMAP mutation leaves
|
|
113
|
-
* behind
|
|
115
|
+
* behind together with the mutation an abandoned one names (all projected
|
|
116
|
+
* straight from the Message row — no ThreadMessage
|
|
114
117
|
* column of their own, see `deriveAutoMoved`). `toResponse` seeds that pair
|
|
115
118
|
* with the ordinary inbound values, which the projection then overwrites; a
|
|
116
119
|
* ThreadMessage row whose Message row is missing from the batch is broken in a
|
|
@@ -189,7 +192,11 @@ export const enrichThreadRows = async (
|
|
|
189
192
|
const settlementByMessageId = new Map(
|
|
190
193
|
messages.map((m) => [
|
|
191
194
|
m.messageId,
|
|
192
|
-
{
|
|
195
|
+
{
|
|
196
|
+
status: m.status,
|
|
197
|
+
syncStatus: m.syncStatus,
|
|
198
|
+
abandonedMutation: m.abandonedMutation,
|
|
199
|
+
},
|
|
193
200
|
]),
|
|
194
201
|
);
|
|
195
202
|
const trustByAddressId = new Map(
|
|
@@ -3,6 +3,7 @@ import { describe, it } from "node:test";
|
|
|
3
3
|
import type { ThreadMessageResponse } from "@remit/api-openapi-types";
|
|
4
4
|
import {
|
|
5
5
|
MessageCategory,
|
|
6
|
+
MessageMutation,
|
|
6
7
|
MessageStatus,
|
|
7
8
|
MessageSyncStatus,
|
|
8
9
|
SenderTrust,
|
|
@@ -38,6 +39,7 @@ const row = (
|
|
|
38
39
|
muted: false,
|
|
39
40
|
status: MessageStatus.active,
|
|
40
41
|
syncStatus: MessageSyncStatus.pending,
|
|
42
|
+
abandonedMutation: MessageMutation.none,
|
|
41
43
|
...overrides,
|
|
42
44
|
});
|
|
43
45
|
|
|
@@ -43,7 +43,8 @@ describe("settleSpamReportBulk", () => {
|
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
// A refusal the move gate raises used to collapse to the generic retry copy,
|
|
46
|
-
// which is a dead end
|
|
46
|
+
// which is a dead end (#665). One reason reaches it now — a mutation still in
|
|
47
|
+
// flight — and it words the wait rather than the server's sentence.
|
|
47
48
|
it("words a placement refusal from its reason, never from the server's sentence", async () => {
|
|
48
49
|
const outcome = await settleSpamReportBulk(
|
|
49
50
|
["msg-1", "msg-2"],
|
|
@@ -52,16 +53,15 @@ describe("settleSpamReportBulk", () => {
|
|
|
52
53
|
`Message ${messageId} was not acted on: its folder and uid do not name the same message`,
|
|
53
54
|
"acc-1",
|
|
54
55
|
messageId,
|
|
55
|
-
|
|
56
|
+
"in_flight",
|
|
56
57
|
);
|
|
57
58
|
},
|
|
58
59
|
);
|
|
59
60
|
|
|
60
|
-
const
|
|
61
|
-
assert.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
for (const reason of [inFlight, unverified]) {
|
|
61
|
+
const reasons = outcome.failures?.map((f) => f.reason) ?? [];
|
|
62
|
+
assert.equal(reasons.length, 2);
|
|
63
|
+
for (const reason of reasons) {
|
|
64
|
+
assert.match(reason ?? "", /try again in a moment/i);
|
|
65
65
|
assert.notEqual(reason, GENERIC_FAILURE_REASON);
|
|
66
66
|
assert.doesNotMatch(reason ?? "", /folder and uid/);
|
|
67
67
|
assert.doesNotMatch(reason ?? "", /acc-1/);
|
package/src/handlers/message.ts
CHANGED
|
@@ -368,17 +368,15 @@ const isDesignedFailure = (reason: unknown): reason is Error =>
|
|
|
368
368
|
|
|
369
369
|
/**
|
|
370
370
|
* The placement refusal is the one designed outcome whose own message is not
|
|
371
|
-
* the text to ship: it names a uuid and no remedy
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
371
|
+
* the text to ship: it names a uuid and no remedy. One remedy answers it, and
|
|
372
|
+
* only one refusal reaches it — a mutation still in flight, which settles on
|
|
373
|
+
* its own (imap-mutations R3). Worded here, the way the delete path words the
|
|
374
|
+
* same refusal for the client.
|
|
375
375
|
*/
|
|
376
376
|
const placementRefusalReason = (
|
|
377
|
-
|
|
377
|
+
_error: MessagePlacementUnsettledError,
|
|
378
378
|
): string =>
|
|
379
|
-
|
|
380
|
-
? "An earlier move of this message never finished, so where it sits is unknown. Sync the folder, then report it again."
|
|
381
|
-
: "This message is still being moved on the mail server. Try again in a moment.";
|
|
379
|
+
"This message is still being moved on the mail server. Try again in a moment.";
|
|
382
380
|
|
|
383
381
|
const failureReason = (reason: unknown): string => {
|
|
384
382
|
if (!isDesignedFailure(reason)) return GENERIC_FAILURE_REASON;
|
|
@@ -479,6 +477,7 @@ export const MessageOperations: Record<
|
|
|
479
477
|
authenticity: message.authenticity,
|
|
480
478
|
status: message.status,
|
|
481
479
|
syncStatus: message.syncStatus,
|
|
480
|
+
abandonedMutation: message.abandonedMutation,
|
|
482
481
|
...(autoMoved ? { autoMoved } : {}),
|
|
483
482
|
...(labels.length > 0 ? { labels } : {}),
|
|
484
483
|
...(message.spamReport ? { spamReport: message.spamReport } : {}),
|
|
@@ -899,7 +898,7 @@ export const MessageBulkOperations: Record<
|
|
|
899
898
|
);
|
|
900
899
|
|
|
901
900
|
// MessageMoveService handles: Message + ThreadMessage updates + SQS events
|
|
902
|
-
await client.messageMove.deleteMessages(
|
|
901
|
+
const { refusedMessageIds } = await client.messageMove.deleteMessages(
|
|
903
902
|
accountConfigId,
|
|
904
903
|
messageIds,
|
|
905
904
|
accountId,
|
|
@@ -908,9 +907,14 @@ export const MessageBulkOperations: Record<
|
|
|
908
907
|
},
|
|
909
908
|
);
|
|
910
909
|
|
|
910
|
+
// A row whose placement changed under the batch is reported, not dropped.
|
|
911
|
+
// The response already carries per-row counts, which is the honest shape
|
|
912
|
+
// for a batch that partly applied — a 409 over it would deny the deletes
|
|
913
|
+
// that did happen, and silence leaves the client's optimistic removal to
|
|
914
|
+
// reappear with nothing said (imap-mutations R3).
|
|
911
915
|
return {
|
|
912
|
-
successCount: messageIds.length,
|
|
913
|
-
failureCount:
|
|
916
|
+
successCount: messageIds.length - refusedMessageIds.length,
|
|
917
|
+
failureCount: refusedMessageIds.length,
|
|
914
918
|
};
|
|
915
919
|
},
|
|
916
920
|
|
|
@@ -9,7 +9,9 @@ import { describe, it } from "node:test";
|
|
|
9
9
|
import {
|
|
10
10
|
computeMessagesSynced,
|
|
11
11
|
deriveMailboxPhase,
|
|
12
|
+
type MailboxProgressFields,
|
|
12
13
|
type MailboxSyncFields,
|
|
14
|
+
toMailboxSyncProgress,
|
|
13
15
|
} from "./sync-progress.js";
|
|
14
16
|
|
|
15
17
|
// ─── deriveMailboxPhase tests ───────────────────────────────────────────────
|
|
@@ -156,3 +158,48 @@ describe("computeMessagesSynced", () => {
|
|
|
156
158
|
assert.equal(computeMessagesSynced(mailbox), 50);
|
|
157
159
|
});
|
|
158
160
|
});
|
|
161
|
+
|
|
162
|
+
// ─── toMailboxSyncProgress tests ────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
const mailboxRow = (
|
|
165
|
+
overrides: Partial<MailboxProgressFields> = {},
|
|
166
|
+
): MailboxProgressFields => ({
|
|
167
|
+
mailboxId: "mb-1",
|
|
168
|
+
fullPath: "INBOX",
|
|
169
|
+
lastMessageSyncAt: 1_700_000_000_000,
|
|
170
|
+
initialSyncCompletedAt: 1_700_000_000_001,
|
|
171
|
+
messageCount: 10,
|
|
172
|
+
lastSyncUid: 1,
|
|
173
|
+
highWaterMarkUid: 100,
|
|
174
|
+
...overrides,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("toMailboxSyncProgress", () => {
|
|
178
|
+
// Issue #771: the client cannot tell an arrival from a departure by count —
|
|
179
|
+
// one message in and one out nets to zero. The highest UID only ever moves
|
|
180
|
+
// up, so the projection has to carry it or the arrival stays invisible.
|
|
181
|
+
it("carries the mailbox's highest UID", () => {
|
|
182
|
+
assert.equal(
|
|
183
|
+
toMailboxSyncProgress(mailboxRow({ highWaterMarkUid: 4321 }))
|
|
184
|
+
.highWaterMarkUid,
|
|
185
|
+
4321,
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("reports the sentinel 0 for a mailbox that has never taken a message", () => {
|
|
190
|
+
assert.equal(
|
|
191
|
+
toMailboxSyncProgress(mailboxRow({ highWaterMarkUid: 0 }))
|
|
192
|
+
.highWaterMarkUid,
|
|
193
|
+
0,
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("projects identity, phase and totals alongside it", () => {
|
|
198
|
+
const progress = toMailboxSyncProgress(mailboxRow());
|
|
199
|
+
assert.equal(progress.mailboxId, "mb-1");
|
|
200
|
+
assert.equal(progress.fullPath, "INBOX");
|
|
201
|
+
assert.equal(progress.phase, "complete");
|
|
202
|
+
assert.equal(progress.messagesTotal, 10);
|
|
203
|
+
assert.equal(progress.lastSyncedAt, 1_700_000_000_000);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -10,6 +10,21 @@ export type MailboxSyncFields = Pick<
|
|
|
10
10
|
| "highWaterMarkUid"
|
|
11
11
|
>;
|
|
12
12
|
|
|
13
|
+
/** A mailbox row, as far as the sync-status projection reads it. */
|
|
14
|
+
export type MailboxProgressFields = MailboxSyncFields &
|
|
15
|
+
Pick<MailboxItem, "mailboxId" | "fullPath">;
|
|
16
|
+
|
|
17
|
+
/** The per-mailbox entry `getSyncStatus` returns. */
|
|
18
|
+
export interface MailboxSyncProgress {
|
|
19
|
+
mailboxId: string;
|
|
20
|
+
fullPath: string;
|
|
21
|
+
phase: string;
|
|
22
|
+
messagesTotal: number;
|
|
23
|
+
messagesSynced: number;
|
|
24
|
+
highWaterMarkUid: number;
|
|
25
|
+
lastSyncedAt: number | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
13
28
|
/**
|
|
14
29
|
* Derive a per-mailbox sync phase from worker-written sync state.
|
|
15
30
|
*
|
|
@@ -62,3 +77,23 @@ export const computeMessagesSynced = (mailbox: MailboxSyncFields): number => {
|
|
|
62
77
|
|
|
63
78
|
return Math.min(Math.max(synced, 0), messagesTotal);
|
|
64
79
|
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Project a mailbox row into its sync-status entry.
|
|
83
|
+
*
|
|
84
|
+
* `highWaterMarkUid` is carried through because a total cannot tell an arrival
|
|
85
|
+
* from a departure: a message landing in the same sync round that another one
|
|
86
|
+
* leaves nets to zero, and the client's freshness check saw nothing (#771). The
|
|
87
|
+
* highest UID only ever moves up, so it names the arrival on its own.
|
|
88
|
+
*/
|
|
89
|
+
export const toMailboxSyncProgress = (
|
|
90
|
+
mailbox: MailboxProgressFields,
|
|
91
|
+
): MailboxSyncProgress => ({
|
|
92
|
+
mailboxId: mailbox.mailboxId,
|
|
93
|
+
fullPath: mailbox.fullPath,
|
|
94
|
+
phase: deriveMailboxPhase(mailbox),
|
|
95
|
+
messagesTotal: mailbox.messageCount ?? 0,
|
|
96
|
+
messagesSynced: computeMessagesSynced(mailbox),
|
|
97
|
+
highWaterMarkUid: mailbox.highWaterMarkUid ?? 0,
|
|
98
|
+
lastSyncedAt: mailbox.lastMessageSyncAt || undefined,
|
|
99
|
+
});
|
package/src/handlers/sync.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { sqsClient } from "../service/sqs.js";
|
|
|
12
12
|
import { triggerAccountSync } from "../service/trigger-sync.js";
|
|
13
13
|
import type { OperationHandler, SyncOperationIds } from "../types.js";
|
|
14
14
|
import { assertAccountOwnership } from "./account-ownership.js";
|
|
15
|
-
import {
|
|
15
|
+
import { toMailboxSyncProgress } from "./sync-progress.js";
|
|
16
16
|
|
|
17
17
|
interface SyncTriggerDeps {
|
|
18
18
|
sqsClient: SQSClient;
|
|
@@ -114,14 +114,7 @@ export const SyncOperations: Record<
|
|
|
114
114
|
mailboxCountTotal: account.mailboxCountTotal,
|
|
115
115
|
mailboxCountSynced: account.mailboxCountSynced,
|
|
116
116
|
lastSyncAt: account.lastSyncAt,
|
|
117
|
-
mailboxes: mailboxes.map(
|
|
118
|
-
mailboxId: mailbox.mailboxId,
|
|
119
|
-
fullPath: mailbox.fullPath,
|
|
120
|
-
phase: deriveMailboxPhase(mailbox),
|
|
121
|
-
messagesTotal: mailbox.messageCount ?? 0,
|
|
122
|
-
messagesSynced: computeMessagesSynced(mailbox),
|
|
123
|
-
lastSyncedAt: mailbox.lastMessageSyncAt || undefined,
|
|
124
|
-
})),
|
|
117
|
+
mailboxes: mailboxes.map(toMailboxSyncProgress),
|
|
125
118
|
};
|
|
126
119
|
},
|
|
127
120
|
};
|