@remit/mailbox-service 0.0.91 → 0.0.92

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/mailbox-service",
3
- "version": "0.0.91",
3
+ "version": "0.0.92",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
package/src/index.ts CHANGED
@@ -193,6 +193,7 @@ export {
193
193
  type SyncAccountInfo,
194
194
  } from "./mailbox-sync.js";
195
195
  export {
196
+ type DeleteMessagesOutcome,
196
197
  type DeleteOptions,
197
198
  type MessageMoveConfig,
198
199
  type MessageMoveLogger,
@@ -267,6 +268,12 @@ export {
267
268
  resolveMailboxRole,
268
269
  shapeFromMessageData,
269
270
  } from "./quarantine.js";
271
+ export {
272
+ type RestoreSourcePlacementDeps,
273
+ type RestoreSourcePlacementInput,
274
+ type RestoreSourcePlacementOutcome,
275
+ restoreSourcePlacement,
276
+ } from "./restore-source-placement.js";
270
277
  export {
271
278
  extractSnippetFromEmail,
272
279
  generateSnippet,
@@ -286,6 +293,12 @@ export {
286
293
  type StaleMessageReconcileDeps,
287
294
  type StaleMessageReconcileResult,
288
295
  } from "./stale-message-reconcile.js";
296
+ export {
297
+ buildThreadMessageMoveRevert,
298
+ buildThreadMessageTrashUpdate,
299
+ buildThreadMessageUndelete,
300
+ type ThreadMessageRowState,
301
+ } from "./thread-message-rows.js";
289
302
  export type {
290
303
  FlatMailboxInfo,
291
304
  IImapConnection,
@@ -30,8 +30,11 @@ const buildWorld = (trashExists: boolean) => {
30
30
  get: async () => [message],
31
31
  update: async (_id: string, patch: Record<string, unknown>) =>
32
32
  Object.assign(message, patch),
33
- updateForMove: async (_id: string, patch: Record<string, unknown>) =>
34
- Object.assign(message, patch),
33
+ transitionPlacement: async (
34
+ _id: string,
35
+ _expected: Record<string, unknown>,
36
+ patch: Record<string, unknown>,
37
+ ) => Object.assign(message, patch),
35
38
  } as unknown as IMessageRepository;
36
39
 
37
40
  const threadMessageService = {
@@ -52,7 +52,11 @@ const buildWorld = (
52
52
  patches.push(patch);
53
53
  return Object.assign(message, patch);
54
54
  },
55
- updateForMove: async (_id: string, patch: Record<string, unknown>) => {
55
+ transitionPlacement: async (
56
+ _id: string,
57
+ _expected: Record<string, unknown>,
58
+ patch: Record<string, unknown>,
59
+ ) => {
56
60
  patches.push(patch);
57
61
  return Object.assign(message, patch);
58
62
  },
@@ -68,11 +68,20 @@ const movingRow = () => ({
68
68
  originalUid: INBOX_UID,
69
69
  });
70
70
 
71
- /** The same row after the trash-move handler gave up without confirming. */
71
+ /**
72
+ * A row a delete gave up on. R3 defines the give-up as a hand-back: the row
73
+ * goes back on the placement the mail server confirmed, and only then carries
74
+ * `abandoned`. The pair is honest, so nothing downstream refuses it — the
75
+ * failure is a statement the reading pane makes, never a gate.
76
+ */
72
77
  const strandedRow = () => ({
73
- ...movingRow(),
74
78
  messageId: STRANDED_ID,
75
- syncStatus: "failed",
79
+ mailboxId: INBOX,
80
+ uid: INBOX_UID,
81
+ status: "active",
82
+ syncStatus: "abandoned",
83
+ abandonedMutation: "delete",
84
+ originalMailboxId: INBOX,
76
85
  });
77
86
 
78
87
  /** An ordinary settled row. */
@@ -127,7 +136,11 @@ const buildWorld = (seed: Array<Record<string, unknown>>) => {
127
136
  patches.push({ messageId: id, patch });
128
137
  return Object.assign(rows.get(id) ?? {}, patch);
129
138
  },
130
- updateForMove: async (id: string, patch: Record<string, unknown>) => {
139
+ transitionPlacement: async (
140
+ id: string,
141
+ _expected: Record<string, unknown>,
142
+ patch: Record<string, unknown>,
143
+ ) => {
131
144
  patches.push({ messageId: id, patch });
132
145
  return Object.assign(rows.get(id) ?? {}, patch);
133
146
  },
@@ -329,23 +342,18 @@ describe("the gate refuses only a uid that names somebody else (#845.3)", () =>
329
342
  assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
330
343
  });
331
344
 
332
- it("refuses a row stranded by a move that gave up, without spending the ceiling", async () => {
333
- // `syncStatus: failed` with `status: moving` is the shape every handler's
334
- // give-up path leaves behind, and only `updateUid` clears it. The pair is
335
- // still a lie, so the delete is still refused — but under a reason whose
336
- // remedy is a resync, and without a wait that could never succeed.
345
+ it("acts on a row a delete gave up on, rather than refusing it", async () => {
346
+ // A give-up hands the row back to the placement the server confirmed
347
+ // before it stops (R3), so the "Delete again" the reading pane offers is
348
+ // an ordinary delete. Refusing it — which is what a give-up left `moving`
349
+ // used to earn — made that button a dead end.
337
350
  const { service, events } = buildWorld([strandedRow()]);
338
351
 
339
352
  const startedAt = Date.now();
340
- await assert.rejects(
341
- () => service.deleteMessages(ACCOUNT_CONFIG, [STRANDED_ID], ACCOUNT),
342
- (error: unknown) =>
343
- error instanceof MessagePlacementUnsettledError &&
344
- error.publicApiError?.details?.reason === "unverified",
345
- );
353
+ await service.deleteMessages(ACCOUNT_CONFIG, [STRANDED_ID], ACCOUNT);
346
354
 
347
- assert.deepEqual(events, []);
348
- assert.ok(Date.now() - startedAt < 100, "refused without waiting");
355
+ assert.equal(events.length, 1);
356
+ assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
349
357
  });
350
358
  });
351
359
 
@@ -69,6 +69,11 @@ const buildWorld = () => {
69
69
  category: "primary",
70
70
  classificationState: "Examined",
71
71
  hasListUnsubscribe: false,
72
+ // Settled, which is what a delete's predicate asks for: a row every
73
+ // repository read carries a status, and leaving it off made the
74
+ // fixture answer for a state no row is ever in.
75
+ status: "active",
76
+ syncStatus: "synced",
72
77
  },
73
78
  ],
74
79
  ]);
@@ -130,6 +135,19 @@ const buildWorld = () => {
130
135
  if (m) Object.assign(m, patch);
131
136
  return m;
132
137
  },
138
+ transitionPlacement: async (
139
+ id: string,
140
+ expected: Record<string, unknown>,
141
+ patch: Record<string, unknown>,
142
+ ) => {
143
+ const m = messages.get(id) as Record<string, unknown> | undefined;
144
+ if (!m) return undefined;
145
+ for (const [field, want] of Object.entries(expected)) {
146
+ if (want !== undefined && m[field] !== want) return undefined;
147
+ }
148
+ Object.assign(m, patch);
149
+ return m;
150
+ },
133
151
  } as unknown as IMessageRepository;
134
152
 
135
153
  const threadMessageService = {
@@ -457,7 +475,7 @@ describe("MessageMoveService.copyMessage — deterministic per-folder identity (
457
475
  });
458
476
 
459
477
  it("copy then delete of the copy removes the copy row and leaves the original", async () => {
460
- const { service, threadRows } = buildWorld();
478
+ const { service, threadRows, messages } = buildWorld();
461
479
 
462
480
  await service.copyMessages(
463
481
  ACCOUNT_CONFIG,
@@ -467,6 +485,15 @@ describe("MessageMoveService.copyMessage — deterministic per-folder identity (
467
485
  );
468
486
  const copyId = deriveCopyMessageId(SOURCE_ID, DEST_MAILBOX);
469
487
 
488
+ // A fresh copy row is `moving` until the server hands back its COPYUID, and
489
+ // a delete against it would name uid 0. Settle it the way `updateUid`
490
+ // does, which is also the only state in which a user can see it to delete.
491
+ Object.assign(messages.get(copyId) ?? {}, {
492
+ uid: 77,
493
+ status: "active",
494
+ syncStatus: "synced",
495
+ });
496
+
470
497
  await service.deleteMessages(ACCOUNT_CONFIG, [copyId], ACCOUNT, {
471
498
  permanent: true,
472
499
  });
@@ -79,9 +79,12 @@ const buildWorld = (
79
79
  trashContents: TrashMessage[] = [
80
80
  { messageId: "junk-1", syncStatus: "synced" },
81
81
  ],
82
+ /** Message ids whose placement another lane changed after the read. */
83
+ transitionsLost: string[] = [],
82
84
  ) => {
83
85
  const emptied: string[] = [];
84
86
  const markedDeleting: string[] = [];
87
+ const markPredicates = new Map<string, unknown>();
85
88
  const events: EnqueuedEvent[] = [];
86
89
  const messagesByMailbox = new Map<string, TrashMessage[]>([
87
90
  [DELETED_FOLDER, [{ messageId: "keepsake-1", syncStatus: "synced" }]],
@@ -96,6 +99,12 @@ const buildWorld = (
96
99
  update: async (messageId: string) => {
97
100
  markedDeleting.push(messageId);
98
101
  },
102
+ transitionPlacement: async (messageId: string, expected: unknown) => {
103
+ markPredicates.set(messageId, expected);
104
+ if (transitionsLost.includes(messageId)) return undefined;
105
+ markedDeleting.push(messageId);
106
+ return { messageId };
107
+ },
99
108
  } as unknown as IMessageRepository;
100
109
 
101
110
  const threadMessageService = {
@@ -135,7 +144,7 @@ const buildWorld = (
135
144
  events.push(event);
136
145
  };
137
146
 
138
- return { service, emptied, markedDeleting, events };
147
+ return { service, emptied, markedDeleting, markPredicates, events };
139
148
  };
140
149
 
141
150
  describe("MessageMoveService.emptyTrash", () => {
@@ -245,6 +254,44 @@ describe("MessageMoveService.emptyTrash", () => {
245
254
  assert.equal(deletedCount, 1);
246
255
  });
247
256
 
257
+ it("predicates each mark on the row it just read, and drops the ones it loses", async () => {
258
+ // The rows are held by nothing between the listing and the mark, and
259
+ // PLACEMENT_MOVE_PUSH rides a standard queue this account's FIFO group
260
+ // does not order (imap-mutations R3). A row that moved under the sweep
261
+ // must not be marked `deleting` for an expunge that would bind a uid it
262
+ // no longer has.
263
+ const { service, markedDeleting, markPredicates } = buildWorld(
264
+ appointedTrash,
265
+ [
266
+ {
267
+ messageId: "settled-1",
268
+ syncStatus: "synced",
269
+ status: "active",
270
+ mailboxId: REAL_TRASH,
271
+ uid: 10,
272
+ },
273
+ {
274
+ messageId: "raced-1",
275
+ syncStatus: "synced",
276
+ status: "active",
277
+ mailboxId: REAL_TRASH,
278
+ uid: 11,
279
+ },
280
+ ],
281
+ ["raced-1"],
282
+ );
283
+
284
+ const { deletedCount } = await service.emptyTrash(ACCOUNT_CONFIG, ACCOUNT);
285
+
286
+ assert.deepEqual(markedDeleting, ["settled-1"]);
287
+ assert.equal(deletedCount, 1, "a row that lost is not counted either");
288
+ assert.deepEqual(markPredicates.get("settled-1"), {
289
+ status: "active",
290
+ mailboxId: REAL_TRASH,
291
+ uid: 10,
292
+ });
293
+ });
294
+
248
295
  it("reports the same count when pressed twice before the worker runs", async () => {
249
296
  // The rows it marked are still in that folder, so N stays true and the
250
297
  // re-mark is idempotent. Reporting 0 the second time while still
@@ -52,8 +52,11 @@ const buildWorld = () => {
52
52
  Array.isArray(ids) ? [message] : message,
53
53
  update: async (_id: string, patch: Record<string, unknown>) =>
54
54
  Object.assign(message, patch),
55
- updateForMove: async (_id: string, patch: Record<string, unknown>) =>
56
- Object.assign(message, patch),
55
+ transitionPlacement: async (
56
+ _id: string,
57
+ _expected: Record<string, unknown>,
58
+ patch: Record<string, unknown>,
59
+ ) => Object.assign(message, patch),
57
60
  } as unknown as IMessageRepository;
58
61
 
59
62
  const threadMessageService = {
@@ -28,6 +28,7 @@ const ARCHIVE = "mbx-archive";
28
28
  const MOVING_ID = "msg-moving";
29
29
  const SETTLED_ID = "msg-settled";
30
30
  const STRANDED_ID = "msg-stranded";
31
+ const DELETING_ID = "msg-deleting";
31
32
  const COPY_ID = "msg-copy";
32
33
 
33
34
  const INBOX_UID = 42;
@@ -58,11 +59,36 @@ const movingRow = () => ({
58
59
  originalUid: INBOX_UID,
59
60
  });
60
61
 
61
- /** The same row after the mover gave up without confirming. */
62
+ /**
63
+ * A row a mutation gave up on. R3 defines the give-up as a hand-back: the row
64
+ * goes back on the placement the mail server confirmed, and only then carries
65
+ * `abandoned`. The pair is honest, so nothing downstream refuses it — the
66
+ * failure is a statement the reading pane makes, never a gate.
67
+ */
62
68
  const strandedRow = () => ({
63
- ...movingRow(),
64
69
  messageId: STRANDED_ID,
65
- syncStatus: "failed",
70
+ mailboxId: INBOX,
71
+ uid: INBOX_UID,
72
+ status: "active",
73
+ syncStatus: "abandoned",
74
+ abandonedMutation: "move",
75
+ originalMailboxId: INBOX,
76
+ });
77
+
78
+ /**
79
+ * The same shape a delete leaves: Trash is already written, the uid is still
80
+ * INBOX's, and `deleting` is what says so. R3 calls this in flight exactly as
81
+ * `moving` is — reading only `moving` let a move enqueued behind a delete bind
82
+ * this pair and be dropped when the delete settled.
83
+ */
84
+ const deletingRow = () => ({
85
+ messageId: DELETING_ID,
86
+ mailboxId: ARCHIVE,
87
+ uid: INBOX_UID,
88
+ status: "deleting",
89
+ syncStatus: "pending",
90
+ originalMailboxId: INBOX,
91
+ originalUid: INBOX_UID,
66
92
  });
67
93
 
68
94
  /** An ordinary settled row. */
@@ -118,7 +144,11 @@ const buildWorld = (seed: Array<Record<string, unknown>>) => {
118
144
  patches.push({ messageId: id, patch });
119
145
  return Object.assign(rows.get(id) ?? {}, patch);
120
146
  },
121
- updateForMove: async (id: string, patch: Record<string, unknown>) => {
147
+ transitionPlacement: async (
148
+ id: string,
149
+ _expected: Record<string, unknown>,
150
+ patch: Record<string, unknown>,
151
+ ) => {
122
152
  patches.push({ messageId: id, patch });
123
153
  return Object.assign(rows.get(id) ?? {}, patch);
124
154
  },
@@ -318,22 +348,31 @@ describe("the move gate refuses only a uid that names somebody else (#665)", ()
318
348
  assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
319
349
  });
320
350
 
321
- it("refuses a row stranded by a move that gave up, without spending the ceiling", async () => {
322
- // `syncStatus: failed` with `status: moving` is what every handler's
323
- // give-up path leaves behind, and only `updateUid` clears it. The pair is
324
- // still a lie, so the move is still refused — under a reason whose remedy
325
- // is a resync, and without a wait that could never succeed.
326
- const { service, events } = buildWorld([strandedRow()]);
351
+ it("waits out a delete in flight rather than binding its borrowed uid", async () => {
352
+ const { service, events, patches } = buildWorld([deletingRow()]);
327
353
 
328
- const startedAt = Date.now();
329
354
  await assert.rejects(
330
- () => service.moveMessage(ACCOUNT_CONFIG, STRANDED_ID, INBOX, ACCOUNT),
331
- (error: unknown) =>
332
- error instanceof MessagePlacementUnsettledError &&
333
- error.publicApiError?.details?.reason === "unverified",
355
+ () => service.moveMessage(ACCOUNT_CONFIG, DELETING_ID, INBOX, ACCOUNT),
356
+ (error: unknown) => error instanceof MessagePlacementUnsettledError,
334
357
  );
335
358
 
336
- assert.deepEqual(events, []);
337
- assert.ok(Date.now() - startedAt < 100, "refused without waiting");
359
+ assert.deepEqual(events, [], "nothing was enqueued");
360
+ assert.deepEqual(patches, [], "and no local write was made");
361
+ });
362
+
363
+ it("acts on a row a move gave up on, rather than refusing it", async () => {
364
+ // A give-up hands the row back to the placement the server confirmed
365
+ // before it stops (R3), so the folder and uid name the same message and
366
+ // the user's next move is an ordinary one. Refusing it — which is what a
367
+ // give-up left `moving` used to earn — made the message unmovable for
368
+ // good, with nothing routine to repair it.
369
+ const { service, events } = buildWorld([strandedRow()]);
370
+
371
+ const startedAt = Date.now();
372
+ await service.moveMessage(ACCOUNT_CONFIG, STRANDED_ID, ARCHIVE, ACCOUNT);
373
+
374
+ assert.equal(events.length, 1);
375
+ assert.equal(events[0].type, "MESSAGE_MOVE");
376
+ assert.ok(Date.now() - startedAt < 100, "and never entered the wait");
338
377
  });
339
378
  });