@remit/imap-worker 0.0.72 → 0.0.74

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.
@@ -1,7 +1,12 @@
1
1
  import { getClient } from "@remit/backend/client";
2
+ import type { IMailboxRepository } from "@remit/data-ports";
3
+ import { isNotFoundError, NotFoundError } from "@remit/data-ports/errors";
2
4
  import { MailboxSyncStatus } from "@remit/domain-enums";
3
5
  import type { Logger } from "@remit/logger-lambda";
4
- import { MailboxManagementService } from "@remit/mailbox-service";
6
+ import {
7
+ EVERY_MAILBOX_STATE,
8
+ MailboxManagementService,
9
+ } from "@remit/mailbox-service";
5
10
  import { isAccountDeleted } from "../account-check.js";
6
11
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
7
12
  import type {
@@ -10,7 +15,6 @@ import type {
10
15
  MailboxManagementEvent,
11
16
  MailboxRenameEvent,
12
17
  } from "../events.js";
13
- import { isNotFoundError } from "../is-not-found.js";
14
18
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
15
19
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
16
20
 
@@ -59,6 +63,36 @@ const isMailboxAbsentUpstream = (error: unknown): boolean => {
59
63
  return /not found|does ?n.?t exist/i.test(saidByServer(error));
60
64
  };
61
65
 
66
+ /**
67
+ * Write the outcome of a folder operation back, as the conditional write that
68
+ * is the only way to write a folder's state
69
+ * (docs/architecture/folder-rename-and-delete.md D3).
70
+ *
71
+ * The from-set is every state because that is what these write-backs decide
72
+ * against today — they are the unconditional writes they replace, moved onto
73
+ * the door. #363 narrows each to the state its intent recorded, and gives a
74
+ * lost predicate its `superseded` outcome. A null means the row is gone, which
75
+ * is the NotFoundError the whole-chain guards below classify as the user having
76
+ * deleted the folder mid-sync.
77
+ */
78
+ const recordOutcome = async (
79
+ mailboxService: Pick<IMailboxRepository, "transition">,
80
+ accountId: string,
81
+ mailboxId: string,
82
+ to: (typeof MailboxSyncStatus)[keyof typeof MailboxSyncStatus],
83
+ confirmedPath?: string,
84
+ ): Promise<void> => {
85
+ const written = await mailboxService.transition(accountId, mailboxId, {
86
+ from: EVERY_MAILBOX_STATE,
87
+ to,
88
+ // `failed` keeps whatever rename target the row carries, so the UI can
89
+ // name what the rename was aiming at (T6); every other outcome drops it,
90
+ // which the transition does on its own.
91
+ set: confirmedPath !== undefined ? { fullPath: confirmedPath } : {},
92
+ });
93
+ if (!written) throw new NotFoundError(`Mailbox not found: ${mailboxId}`);
94
+ };
95
+
62
96
  /** The folder is already on the server: a create has nothing left to do. */
63
97
  const isMailboxPresentUpstream = (error: unknown): boolean => {
64
98
  if (!(error instanceof Error)) return false;
@@ -158,13 +192,19 @@ const handleCreate = async (
158
192
  { accountId, mailboxId, path },
159
193
  "Mailbox already exists, marking as synced",
160
194
  );
161
- await mailboxService.update(accountId, mailboxId, {
162
- syncStatus: MailboxSyncStatus.synced,
163
- });
195
+ await recordOutcome(
196
+ mailboxService,
197
+ accountId,
198
+ mailboxId,
199
+ MailboxSyncStatus.synced,
200
+ );
164
201
  } else {
165
- await mailboxService.update(accountId, mailboxId, {
166
- syncStatus: MailboxSyncStatus.failed,
167
- });
202
+ await recordOutcome(
203
+ mailboxService,
204
+ accountId,
205
+ mailboxId,
206
+ MailboxSyncStatus.failed,
207
+ );
168
208
  throw error;
169
209
  }
170
210
  })
@@ -269,11 +309,13 @@ const handleRename = async (
269
309
  await mailboxService.delete(accountId, mailboxId);
270
310
  } else {
271
311
  // Rollback local rename by restoring old path
272
- await mailboxService.update(accountId, mailboxId, {
273
- fullPath: oldPath,
274
- oldPath: undefined,
275
- syncStatus: MailboxSyncStatus.failed,
276
- });
312
+ await recordOutcome(
313
+ mailboxService,
314
+ accountId,
315
+ mailboxId,
316
+ MailboxSyncStatus.failed,
317
+ oldPath,
318
+ );
277
319
  throw error;
278
320
  }
279
321
  })
@@ -370,9 +412,12 @@ const handleDelete = async (
370
412
  error.message.includes("Cannot delete INBOX")
371
413
  ) {
372
414
  // Restore the mailbox
373
- await mailboxService.update(accountId, mailboxId, {
374
- syncStatus: MailboxSyncStatus.synced,
375
- });
415
+ await recordOutcome(
416
+ mailboxService,
417
+ accountId,
418
+ mailboxId,
419
+ MailboxSyncStatus.synced,
420
+ );
376
421
  log.error(
377
422
  { accountId, mailboxId, path },
378
423
  "Cannot delete INBOX, restoring mailbox",
@@ -380,9 +425,12 @@ const handleDelete = async (
380
425
  // Don't rethrow - this is an expected error
381
426
  } else {
382
427
  // Restore the mailbox on other errors
383
- await mailboxService.update(accountId, mailboxId, {
384
- syncStatus: MailboxSyncStatus.failed,
385
- });
428
+ await recordOutcome(
429
+ mailboxService,
430
+ accountId,
431
+ mailboxId,
432
+ MailboxSyncStatus.failed,
433
+ );
386
434
  throw error;
387
435
  }
388
436
  })
@@ -112,6 +112,10 @@ const deps = (): MessageCopyDeps =>
112
112
  },
113
113
  updateUid: record("message.updateUid"),
114
114
  update: record("message.update"),
115
+ transitionPlacement: async (...args: unknown[]) => {
116
+ h.calls.push({ method: "message.transitionPlacement", args });
117
+ return { messageId: "new-msg" };
118
+ },
115
119
  delete: record("message.delete"),
116
120
  },
117
121
  threadMessage: {
@@ -191,11 +195,9 @@ describe("handleMessageCopy", () => {
191
195
  20,
192
196
  "dst-mbx",
193
197
  ]);
194
- const statusUpdate = called("message.update")[0];
195
- assert.equal(
196
- (statusUpdate?.args[1] as { syncStatus?: string })?.syncStatus,
197
- "synced",
198
- );
198
+ // `updateUid` writes the confirmed uid, `active` and `synced` in one
199
+ // statement, so there is no second, unpredicated write behind it.
200
+ assert.equal(called("message.update").length, 0);
199
201
  assert.equal(called("threadMessage.update").length, 1);
200
202
  assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
201
203
  });
@@ -212,11 +214,7 @@ describe("handleMessageCopy", () => {
212
214
  42,
213
215
  "dst-mbx",
214
216
  ]);
215
- assert.equal(
216
- (called("message.update")[0]?.args[1] as { syncStatus?: string })
217
- ?.syncStatus,
218
- "synced",
219
- );
217
+ assert.equal(called("message.update").length, 0);
220
218
  assert.equal(called("threadMessage.update").length, 1);
221
219
  });
222
220
 
@@ -263,11 +261,15 @@ describe("handleMessageCopy", () => {
263
261
 
264
262
  assert.equal(copies, 1, "the COPY is issued once");
265
263
  assert.equal(called("search").length, 0, "there is nothing to ask with");
266
- const update = called("message.update")[0];
267
- assert.equal((update?.args[1] as { status?: string })?.status, "deleted");
268
- assert.equal(
269
- (update?.args[1] as { syncStatus?: string })?.syncStatus,
270
- "failed",
264
+ const [givenUp] = called("message.transitionPlacement");
265
+ assert.deepEqual(
266
+ givenUp?.args[2],
267
+ {
268
+ status: "deleted",
269
+ syncStatus: "abandoned",
270
+ abandonedMutation: "copy",
271
+ },
272
+ "a copy that gave up carries the give-up value and names itself",
271
273
  );
272
274
  assert.equal(called("message.delete").length, 0, "no row is thrown away");
273
275
  });
@@ -302,8 +304,8 @@ describe("handleMessageCopy", () => {
302
304
 
303
305
  assert.equal(copies, 1, "the redelivery issues no second COPY");
304
306
  assert.equal(called("search").length, 0);
305
- const settled = called("message.update").at(-1);
306
- assert.equal((settled?.args[1] as { status?: string })?.status, "deleted");
307
+ const settled = called("message.transitionPlacement").at(-1);
308
+ assert.equal((settled?.args[2] as { status?: string })?.status, "deleted");
307
309
  });
308
310
 
309
311
  it("acks a copy that already settled without touching IMAP", async () => {
@@ -327,6 +329,7 @@ describe("handleMessageCopy", () => {
327
329
 
328
330
  assert.equal(h.getConnectionCount, 0);
329
331
  assert.equal(called("message.update").length, 0);
332
+ assert.equal(called("message.transitionPlacement").length, 0);
330
333
  });
331
334
 
332
335
  it("returns early without connecting when the account is soft-deleted", async () => {
@@ -358,6 +361,7 @@ describe("handleMessageCopy", () => {
358
361
  assert.equal(h.getConnectionCount, 0);
359
362
  assert.equal(called("message.updateUid").length, 0);
360
363
  assert.equal(called("message.update").length, 0);
364
+ assert.equal(called("message.transitionPlacement").length, 0);
361
365
  });
362
366
 
363
367
  // Issue #1203. Both pauses are reached before `copyMessages`, so the COPY
@@ -427,7 +431,11 @@ describe("handleMessageCopy", () => {
427
431
  "silence is never grounds to delete a row that may describe real mail",
428
432
  );
429
433
  assert.equal(
430
- (called("message.update")[0]?.args[1] as { status?: string })?.status,
434
+ (
435
+ called("message.transitionPlacement")[0]?.args[2] as {
436
+ status?: string;
437
+ }
438
+ )?.status,
431
439
  "deleted",
432
440
  );
433
441
  });
@@ -505,11 +513,33 @@ describe("handleMessageCopy", () => {
505
513
 
506
514
  await handleMessageCopy(event, noopLogger, 1, deps());
507
515
 
508
- const update = called("message.update")[0];
509
- assert.equal((update?.args[1] as { status?: string })?.status, "deleted");
516
+ const [givenUp] = called("message.transitionPlacement");
517
+ assert.equal((givenUp?.args[2] as { status?: string })?.status, "deleted");
518
+ assert.equal(
519
+ (givenUp?.args[2] as { abandonedMutation?: string })?.abandonedMutation,
520
+ "copy",
521
+ );
510
522
  assert.equal(called("createMailbox").length, 0);
511
523
  });
512
524
 
525
+ // The guard asks whether THIS copy is still outstanding, not whether anything
526
+ // is. A row a delete has claimed is `deleting`, and reading that as unsettled
527
+ // let a redelivery COPY the message a second time — COPY has no source-side
528
+ // effect to make that a no-op, so the duplicate is permanent.
529
+ it("skips a redelivered copy whose row a delete has claimed", async () => {
530
+ let copies = 0;
531
+ h.connection.copyMessages = async () => {
532
+ copies += 1;
533
+ return { uidMap: new Map([[10, 20]]) };
534
+ };
535
+ h.copyRow = { ...unsettledCopyRow(), status: "deleting" };
536
+
537
+ await handleMessageCopy(event, noopLogger, 2, deps());
538
+
539
+ assert.equal(copies, 0, "no second copy is issued");
540
+ assert.equal(called("message.updateUid").length, 0);
541
+ });
542
+
513
543
  it("marks failed and rethrows on an unclassified IMAP error within the budget", async () => {
514
544
  h.connection.copyMessages = async () => {
515
545
  throw new Error("server exploded");
@@ -520,11 +550,12 @@ describe("handleMessageCopy", () => {
520
550
  /server exploded/,
521
551
  );
522
552
 
523
- const update = called("message.update")[0];
524
- assert.equal(
525
- (update?.args[1] as { syncStatus?: string })?.syncStatus,
526
- "failed",
527
- );
553
+ // The attempt marker is a transition too, predicated on this copy still
554
+ // being outstanding: a row another mutation has claimed keeps its own
555
+ // placement rather than picking up this attempt's failure.
556
+ const [marker] = called("message.transitionPlacement");
557
+ assert.deepEqual(marker?.args[1], { status: "moving" });
558
+ assert.deepEqual(marker?.args[2], { syncStatus: "failed" });
528
559
  });
529
560
 
530
561
  it("settles on the destination UID instead of dead-lettering the last attempt", async () => {
@@ -1,11 +1,15 @@
1
1
  import { getClient } from "@remit/backend/client";
2
- import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
2
+ import { isNotFoundError } from "@remit/data-ports/errors";
3
+ import {
4
+ MessageMutation,
5
+ MessageStatus,
6
+ MessageSyncStatus,
7
+ } from "@remit/domain-enums";
3
8
  import type { Logger } from "@remit/logger-lambda";
4
9
  import {
5
10
  guardConnectionCursor,
6
11
  type IImapConnection,
7
12
  isCursorRebuildNeeded,
8
- isPlacementUnsettled,
9
13
  MailboxCursorPausedError,
10
14
  reconcileStaleMessage,
11
15
  } from "@remit/mailbox-service";
@@ -14,7 +18,6 @@ import { isAccountDeleted } from "../account-check.js";
14
18
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
15
19
  import { emitEvent } from "../emit.js";
16
20
  import type { MessageCopyEvent } from "../events.js";
17
- import { isNotFoundError } from "../is-not-found.js";
18
21
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
19
22
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
20
23
  import {
@@ -147,7 +150,12 @@ export const handleMessageCopy = async (
147
150
  // This copy already settled — `updateUid` cleared `status: moving` when the
148
151
  // server confirmed it. A redelivery reaching here would COPY the message a
149
152
  // second time, and COPY has no source-side effect to make that a no-op.
150
- if (!isPlacementUnsettled(copyRow)) {
153
+ //
154
+ // `moving` specifically, not the shared unsettled predicate: the question is
155
+ // whether THIS copy is still outstanding, and a `deleting` row is somebody
156
+ // else's work — reading it as unsettled is what would let the second COPY
157
+ // through.
158
+ if (copyRow.status !== MessageStatus.moving) {
151
159
  log.info(
152
160
  { accountId, newMessageId, uid: copyRow.uid, status: copyRow.status },
153
161
  "Skipping MESSAGE_COPY: the copy already settled against confirmed IMAP state",
@@ -206,17 +214,15 @@ export const handleMessageCopy = async (
206
214
  };
207
215
 
208
216
  const settleCopied = async (newUid: number): Promise<void> => {
217
+ // `updateUid` writes the confirmed uid, `active` and `synced` in one
218
+ // statement, so the second write this used to make said nothing the
219
+ // first had not — and said it without a predicate.
209
220
  await messageService.updateUid(
210
221
  newMessageId,
211
222
  newUid,
212
223
  destinationMailboxId,
213
224
  );
214
225
 
215
- await messageService.update(newMessageId, {
216
- status: MessageStatus.active,
217
- syncStatus: MessageSyncStatus.synced,
218
- });
219
-
220
226
  const threadMessage = await threadMessageService.findByMessageId(
221
227
  account.accountConfigId,
222
228
  newMessageId,
@@ -293,10 +299,20 @@ export const handleMessageCopy = async (
293
299
  // that turns out to be there. Silence buys the safer half of one pair
294
300
  // and the worse half of the other.
295
301
  const settleBroken = async (reason: string): Promise<void> => {
296
- await messageService.update(newMessageId, {
297
- status: MessageStatus.deleted,
298
- syncStatus: MessageSyncStatus.failed,
299
- });
302
+ // A transition, not a plain update (imap-mutations R3): the copy row
303
+ // is only this handler's to settle while its own placement is still
304
+ // unsettled. The row it leaves is `deleted`, so no listing carries it
305
+ // and the marker is read by nothing — it is written anyway, because a
306
+ // give-up that does not name itself is the gap #1229 came from.
307
+ await messageService.transitionPlacement(
308
+ newMessageId,
309
+ { status: MessageStatus.moving },
310
+ {
311
+ status: MessageStatus.deleted,
312
+ syncStatus: MessageSyncStatus.abandoned,
313
+ abandonedMutation: MessageMutation.copy,
314
+ },
315
+ );
300
316
  log.error(
301
317
  {
302
318
  alert: "message_copy_unconfirmed",
@@ -495,10 +511,15 @@ export const handleMessageCopy = async (
495
511
  { sourceMessageId, uid },
496
512
  "Source message not found on IMAP, marking copy as failed",
497
513
  );
498
- await messageService.update(newMessageId, {
499
- status: MessageStatus.deleted,
500
- syncStatus: MessageSyncStatus.failed,
501
- });
514
+ await messageService.transitionPlacement(
515
+ newMessageId,
516
+ { status: MessageStatus.moving },
517
+ {
518
+ status: MessageStatus.deleted,
519
+ syncStatus: MessageSyncStatus.abandoned,
520
+ abandonedMutation: MessageMutation.copy,
521
+ },
522
+ );
502
523
  return;
503
524
  }
504
525
 
@@ -506,9 +527,11 @@ export const handleMessageCopy = async (
506
527
  // Transient copy failure — expected (connections drop). Queue
507
528
  // redelivery retries, and the probe above keeps the retry from
508
529
  // copying the message twice.
509
- await messageService.update(newMessageId, {
510
- syncStatus: MessageSyncStatus.failed,
511
- });
530
+ await messageService.transitionPlacement(
531
+ newMessageId,
532
+ { status: MessageStatus.moving },
533
+ { syncStatus: MessageSyncStatus.failed },
534
+ );
512
535
  throw error;
513
536
  }
514
537
 
@@ -2,14 +2,14 @@ import type {
2
2
  IMessageRepository,
3
3
  IThreadMessageRepository,
4
4
  } from "@remit/data-ports";
5
- import { MessageSyncStatus } from "@remit/domain-enums";
5
+ import { MessageMutation, MessageSyncStatus } from "@remit/domain-enums";
6
6
  import {
7
7
  type IImapConnection,
8
8
  isMessageGoneFromOpenMailbox,
9
9
  reconcileStaleMessage,
10
+ restoreSourcePlacement,
10
11
  type StaleMessageReconcileDeps,
11
12
  } from "@remit/mailbox-service";
12
- import { restoreSourcePlacement } from "./restore-source-placement.js";
13
13
 
14
14
  export interface MessageDeleteTerminalLogger {
15
15
  info(obj: Record<string, unknown>, msg: string): void;
@@ -18,7 +18,7 @@ export interface MessageDeleteTerminalLogger {
18
18
 
19
19
  export interface ResolveExhaustedMessageDeleteDeps
20
20
  extends StaleMessageReconcileDeps {
21
- messageService: Pick<IMessageRepository, "delete" | "updateForMove">;
21
+ messageService: Pick<IMessageRepository, "delete" | "transitionPlacement">;
22
22
  threadMessageService: Pick<
23
23
  IThreadMessageRepository,
24
24
  "findAllByMessageId" | "deleteMany" | "update"
@@ -60,7 +60,7 @@ export interface ResolveExhaustedMessageDeleteResult {
60
60
  * effect, but it keeps failing: broken code or a broken account, not a
61
61
  * transient blip. The row is put back where the server has just said the
62
62
  * message is: `mailboxId` and `uid` return to the delete's source pair, and
63
- * the thread rows lose the deletion mark the optimistic `updateForMove`
63
+ * the thread rows lose the deletion mark the optimistic transition
64
64
  * wrote (issue #1098).
65
65
  *
66
66
  * Settling `status` to `active` while leaving `mailboxId` on Trash is what
@@ -132,9 +132,13 @@ export const resolveExhaustedMessageDeleteFailure = async (
132
132
  sourceMailboxId,
133
133
  uid,
134
134
  // The probe above just confirmed the pair, so the row is a faithful
135
- // projection of the source again. The delete's own failure is the alert,
136
- // not a `failed` left on a row nothing is still trying to delete.
137
- syncStatus: MessageSyncStatus.synced,
135
+ // projection of the source again — and the delete the user asked for did
136
+ // not happen, with nothing left to try it. `abandoned` is the value that
137
+ // says so without claiming a retry is coming (imap-mutations R3); the
138
+ // alert stays for the operator, and the mutation names itself so the
139
+ // reading pane offers the right way out (issue #1229).
140
+ syncStatus: MessageSyncStatus.abandoned,
141
+ abandonedMutation: MessageMutation.delete,
138
142
  });
139
143
 
140
144
  return { outcome: "broken" };