@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/backend",
3
- "version": "0.0.108",
3
+ "version": "0.0.110",
4
4
  "description": "Remit Mail Inspector API backend",
5
5
  "license": "MIT",
6
6
  "author": "",
@@ -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 { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
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.failed,
283
+ syncStatus: MessageSyncStatus.abandoned,
284
+ abandonedMutation: MessageMutation.delete,
280
285
  });
281
286
  assert.equal(result?.status, MessageStatus.active);
282
- assert.equal(result?.syncStatus, MessageSyncStatus.failed);
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 (all projected straight from the Message row — no ThreadMessage
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
- { status: m.status, syncStatus: m.syncStatus },
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 for the reason no retry clears (#665).
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
- messageId === "msg-1" ? "in_flight" : "unverified",
56
+ "in_flight",
56
57
  );
57
58
  },
58
59
  );
59
60
 
60
- const [inFlight, unverified] = outcome.failures?.map((f) => f.reason) ?? [];
61
- assert.match(inFlight ?? "", /try again in a moment/i);
62
- assert.match(unverified ?? "", /sync the folder/i);
63
- assert.doesNotMatch(unverified ?? "", /try again in a moment/i);
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/);
@@ -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, and the two reasons do not
372
- * share a remedy — an in-flight move settles on its own, an abandoned one only
373
- * clears once the folder is resynced. Worded here off `details.reason`, the way
374
- * the delete path words the same refusal for the client.
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
- error: MessagePlacementUnsettledError,
377
+ _error: MessagePlacementUnsettledError,
378
378
  ): string =>
379
- error.publicApiError?.details?.reason === "unverified"
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: 0,
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
+ });
@@ -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 { computeMessagesSynced, deriveMailboxPhase } from "./sync-progress.js";
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((mailbox) => ({
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
  };