@remit/imap-worker 0.0.77 → 0.0.79

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/imap-worker",
3
- "version": "0.0.77",
3
+ "version": "0.0.79",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
package/src/events.ts CHANGED
@@ -1,4 +1,7 @@
1
- import type { MUTATION_EVENT_SCHEMA_VERSION } from "@remit/data-ports/mutation-events";
1
+ import type {
2
+ MUTATION_EVENT_SCHEMA_VERSION,
3
+ PATHLESS_MUTATION_EVENT_SCHEMA_VERSION,
4
+ } from "@remit/data-ports/mutation-events";
2
5
 
3
6
  export interface BaseEvent {
4
7
  accountId: string;
@@ -88,39 +91,56 @@ export type MailboxManagementEvent =
88
91
  /**
89
92
  * Event for deleting a message (move to trash or permanent delete).
90
93
  */
91
- export interface MessageDeleteEvent extends BaseEvent {
94
+ interface MessageDeleteEventFields extends BaseEvent {
92
95
  type: "MESSAGE_DELETE";
93
- schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
94
96
  messageId: string;
95
97
  mailboxId: string;
96
- mailboxPath: string;
97
98
  uid: number;
98
99
  operation: "move_to_trash" | "permanent_delete";
99
100
  destinationMailboxId?: string;
101
+ }
102
+
103
+ export interface MessageDeleteEventV2 extends MessageDeleteEventFields {
104
+ schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
105
+ mailboxPath: string;
100
106
  destinationMailboxPath?: string;
101
107
  }
102
108
 
109
+ export interface MessageDeleteEventV3 extends MessageDeleteEventFields {
110
+ schemaVersion: typeof PATHLESS_MUTATION_EVENT_SCHEMA_VERSION;
111
+ }
112
+
113
+ export type MessageDeleteEvent = MessageDeleteEventV2 | MessageDeleteEventV3;
114
+
103
115
  /**
104
116
  * Event for moving a message to another mailbox.
105
117
  */
106
- export interface MessageMoveEvent extends BaseEvent {
118
+ interface MessageMoveEventFields extends BaseEvent {
107
119
  type: "MESSAGE_MOVE";
108
120
  messageId: string;
109
121
  sourceMailboxId: string;
110
- sourceMailboxPath: string;
111
122
  destinationMailboxId: string;
112
- destinationMailboxPath: string;
113
123
  uid: number;
114
124
  }
115
125
 
126
+ export interface MessageMoveEventV1 extends MessageMoveEventFields {
127
+ schemaVersion?: undefined;
128
+ sourceMailboxPath: string;
129
+ destinationMailboxPath: string;
130
+ }
131
+
132
+ export interface MessageMoveEventV3 extends MessageMoveEventFields {
133
+ schemaVersion: typeof PATHLESS_MUTATION_EVENT_SCHEMA_VERSION;
134
+ }
135
+
136
+ export type MessageMoveEvent = MessageMoveEventV1 | MessageMoveEventV3;
137
+
116
138
  /**
117
139
  * Event for emptying the Trash mailbox.
118
140
  */
119
- export interface EmptyTrashEvent extends BaseEvent {
141
+ interface EmptyTrashEventFields extends BaseEvent {
120
142
  type: "EMPTY_TRASH";
121
- schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
122
143
  trashMailboxId: string;
123
- trashMailboxPath: string;
124
144
  /**
125
145
  * The Trash folder's UIDVALIDITY at the moment the user consented. The
126
146
  * handler compares it against what the SELECT serves: a path reused by a
@@ -130,20 +150,41 @@ export interface EmptyTrashEvent extends BaseEvent {
130
150
  trashUidValidity: number;
131
151
  }
132
152
 
153
+ export interface EmptyTrashEventV2 extends EmptyTrashEventFields {
154
+ schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
155
+ trashMailboxPath: string;
156
+ }
157
+
158
+ export interface EmptyTrashEventV3 extends EmptyTrashEventFields {
159
+ schemaVersion: typeof PATHLESS_MUTATION_EVENT_SCHEMA_VERSION;
160
+ }
161
+
162
+ export type EmptyTrashEvent = EmptyTrashEventV2 | EmptyTrashEventV3;
163
+
133
164
  /**
134
165
  * Event for copying a message to another mailbox.
135
166
  */
136
- export interface MessageCopyEvent extends BaseEvent {
167
+ interface MessageCopyEventFields extends BaseEvent {
137
168
  type: "MESSAGE_COPY";
138
169
  sourceMessageId: string;
139
170
  newMessageId: string;
140
171
  sourceMailboxId: string;
141
- sourceMailboxPath: string;
142
172
  destinationMailboxId: string;
143
- destinationMailboxPath: string;
144
173
  uid: number;
145
174
  }
146
175
 
176
+ export interface MessageCopyEventV1 extends MessageCopyEventFields {
177
+ schemaVersion?: undefined;
178
+ sourceMailboxPath: string;
179
+ destinationMailboxPath: string;
180
+ }
181
+
182
+ export interface MessageCopyEventV3 extends MessageCopyEventFields {
183
+ schemaVersion: typeof PATHLESS_MUTATION_EVENT_SCHEMA_VERSION;
184
+ }
185
+
186
+ export type MessageCopyEvent = MessageCopyEventV1 | MessageCopyEventV3;
187
+
147
188
  /**
148
189
  * Event for appending a sent message to the Sent mailbox via IMAP APPEND.
149
190
  */
@@ -38,7 +38,12 @@ interface Harness {
38
38
  accountConfigId: string;
39
39
  deletedAt?: number;
40
40
  } | null;
41
- mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
41
+ mailbox: {
42
+ mailboxId: string;
43
+ fullPath: string;
44
+ uidValidity: number;
45
+ cursorState?: string;
46
+ };
42
47
  mailboxError?: Error;
43
48
  trashResolution: RoleResolution<TrashMailbox>;
44
49
  connection: Connection;
@@ -118,7 +123,12 @@ const markedMidMove = (messageId: string, uid: number): LocalMessage => ({
118
123
  const fresh = (): Harness => ({
119
124
  calls: [],
120
125
  account: { accountId: "acc-1", accountConfigId: "cfg-1" },
121
- mailbox: { mailboxId: "trash-mbx", uidValidity: 1, cursorState: undefined },
126
+ mailbox: {
127
+ mailboxId: "trash-mbx",
128
+ fullPath: "Trash",
129
+ uidValidity: 1,
130
+ cursorState: undefined,
131
+ },
122
132
  trashResolution: {
123
133
  kind: "flagged",
124
134
  mailbox: { mailboxId: "trash-mbx", fullPath: "Trash" },
@@ -406,7 +416,7 @@ describe("handleEmptyTrash", () => {
406
416
  // The path was reused: a third-party client renamed Trash away and made a
407
417
  // fresh one. Same path, different folder, and nobody consented to empty it.
408
418
  h.connection.openBox = async () => ({ uidvalidity: 77 });
409
- h.mailbox = { mailboxId: "trash-mbx", uidValidity: 77 };
419
+ h.mailbox = { mailboxId: "trash-mbx", fullPath: "Trash", uidValidity: 77 };
410
420
 
411
421
  await handleEmptyTrash(event, noopLogger, deps());
412
422
 
@@ -422,7 +432,7 @@ describe("handleEmptyTrash", () => {
422
432
  { messageId: "msg-arrived", uid: 12, status: "active" },
423
433
  ];
424
434
  h.connection.openBox = async () => ({ uidvalidity: 77 });
425
- h.mailbox = { mailboxId: "trash-mbx", uidValidity: 77 };
435
+ h.mailbox = { mailboxId: "trash-mbx", fullPath: "Trash", uidValidity: 77 };
426
436
 
427
437
  await handleEmptyTrash(event, noopLogger, deps());
428
438
 
@@ -450,6 +460,7 @@ describe("handleEmptyTrash", () => {
450
460
  it("reverts the marks without connecting when the cursor is rebuilding", async () => {
451
461
  h.mailbox = {
452
462
  mailboxId: "trash-mbx",
463
+ fullPath: "Trash",
453
464
  uidValidity: 1,
454
465
  cursorState: "rebuilding",
455
466
  };
@@ -663,3 +674,55 @@ describe("handleEmptyTrash and an unsettled placement", () => {
663
674
  );
664
675
  });
665
676
  });
677
+
678
+ describe("handleEmptyTrash — the Trash path comes from its mailbox row", () => {
679
+ beforeEach(() => {
680
+ h = fresh();
681
+ });
682
+
683
+ const openedPaths = (): string[] => {
684
+ const opened: string[] = [];
685
+ h.connection.openBox = async (path: string) => {
686
+ opened.push(path);
687
+ return { uidvalidity: 1 };
688
+ };
689
+ return opened;
690
+ };
691
+
692
+ it("runs an empty enqueued with the version 2 payload that carries the folder path", async () => {
693
+ const opened = openedPaths();
694
+
695
+ await handleEmptyTrash(event, noopLogger, deps());
696
+
697
+ assert.deepEqual(opened, ["Trash"]);
698
+ assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10, 11]]);
699
+ });
700
+
701
+ it("runs an empty enqueued with the pathless payload", async () => {
702
+ const opened = openedPaths();
703
+ const pathless: EmptyTrashEvent = {
704
+ type: "EMPTY_TRASH",
705
+ schemaVersion: 3,
706
+ eventId: "evt-1",
707
+ timestamp: 1,
708
+ accountId: "acc-1",
709
+ trashMailboxId: "trash-mbx",
710
+ trashUidValidity: 1,
711
+ };
712
+
713
+ await handleEmptyTrash(pathless, noopLogger, deps());
714
+
715
+ assert.deepEqual(opened, ["Trash"]);
716
+ assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10, 11]]);
717
+ });
718
+
719
+ it("empties Trash under its current path when the folder was renamed after enqueue", async () => {
720
+ const opened = openedPaths();
721
+ h.mailbox = { ...h.mailbox, fullPath: "Deleted Items" };
722
+
723
+ await handleEmptyTrash(event, noopLogger, deps());
724
+
725
+ assert.deepEqual(opened, ["Deleted Items"]);
726
+ assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10, 11]]);
727
+ });
728
+ });
@@ -1,8 +1,7 @@
1
1
  import { getClient } from "@remit/backend/client";
2
2
  import type { MessageItem } from "@remit/data-ports";
3
- import { isNotFoundError } from "@remit/data-ports/errors";
4
3
  import { trashMailboxAt } from "@remit/data-ports/folder-role";
5
- import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
4
+ import { isAcceptedSchemaVersion } from "@remit/data-ports/mutation-events";
6
5
  import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
7
6
  import type { Logger } from "@remit/logger-lambda";
8
7
  import {
@@ -15,6 +14,7 @@ import {
15
14
  import { isAccountDeleted } from "../account-check.js";
16
15
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
17
16
  import type { EmptyTrashEvent } from "../events.js";
17
+ import { findMailboxRow } from "../mailbox-row.js";
18
18
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
19
19
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
20
20
 
@@ -64,12 +64,9 @@ export const handleEmptyTrash = async (
64
64
  secrets,
65
65
  } = await getClient();
66
66
 
67
- const { accountId, trashMailboxId, trashMailboxPath } = event;
67
+ const { accountId, trashMailboxId } = event;
68
68
 
69
- log.info(
70
- { event: event.type, accountId, trashMailboxPath },
71
- "Handling event",
72
- );
69
+ log.info({ event: event.type, accountId, trashMailboxId }, "Handling event");
73
70
 
74
71
  const account = await accountService.get(accountId);
75
72
  if (!account) {
@@ -151,16 +148,13 @@ export const handleEmptyTrash = async (
151
148
  alert: string,
152
149
  context: Record<string, unknown> = {},
153
150
  ): Promise<void> => {
154
- log.error(
155
- { alert, accountId, trashMailboxId, trashMailboxPath, ...context },
156
- reason,
157
- );
151
+ log.error({ alert, accountId, trashMailboxId, ...context }, reason);
158
152
  await handBackMarkedRows(
159
153
  await messageService.listAllByMailbox(trashMailboxId),
160
154
  );
161
155
  };
162
156
 
163
- if (!isCurrentSchemaVersion(event.schemaVersion)) {
157
+ if (!isAcceptedSchemaVersion(event.schemaVersion)) {
164
158
  await abandonEmptyTrash(
165
159
  "Refused to empty trash: event was minted under an unknown contract",
166
160
  "empty_trash_unknown_schema_version",
@@ -199,19 +193,19 @@ export const handleEmptyTrash = async (
199
193
  // NotFoundError forever, and on the account's per-group FIFO that head
200
194
  // message stalls the whole pipeline (issues #287, #289, #290). A deleted
201
195
  // Trash makes the empty moot: ack with a WARN.
202
- const mailbox = await mailboxService
203
- .get(accountId, trashMailboxId)
204
- .catch((error: unknown) => {
205
- if (isNotFoundError(error)) return null;
206
- throw error;
207
- });
208
- if (!mailbox) {
196
+ const trash = await findMailboxRow(
197
+ mailboxService,
198
+ accountId,
199
+ trashMailboxId,
200
+ );
201
+ if (trash.kind === "gone") {
209
202
  log.warn(
210
203
  { accountId, mailboxId: trashMailboxId },
211
204
  "Skipping EMPTY_TRASH: mailbox no longer exists (deleted)",
212
205
  );
213
206
  return;
214
207
  }
208
+ const mailbox = trash.mailbox;
215
209
 
216
210
  // A paused cursor is not a wait here: this return acks the event and
217
211
  // nothing re-issues it, so the marks have to come back or the folder
@@ -239,7 +233,7 @@ export const handleEmptyTrash = async (
239
233
  accountId,
240
234
  mailbox,
241
235
  );
242
- const boxStatus = await connection.openBox(trashMailboxPath, false);
236
+ const boxStatus = await connection.openBox(mailbox.fullPath, false);
243
237
 
244
238
  // The path is not the folder. A third-party client that renames
245
239
  // Trash and creates a fresh one leaves this event pointing at a
@@ -133,7 +133,12 @@ const handleCreate = async (
133
133
  .then((result) => {
134
134
  if (result.success) {
135
135
  log.info(
136
- { accountId, mailboxId, path },
136
+ {
137
+ accountId,
138
+ mailboxId,
139
+ path,
140
+ existingMailboxId: result.existing?.mailboxId,
141
+ },
137
142
  "Mailbox created on IMAP",
138
143
  );
139
144
  } else {
@@ -40,6 +40,8 @@ interface Harness {
40
40
  } | null;
41
41
  mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
42
42
  mailboxError?: Error;
43
+ folderPaths: Record<string, string>;
44
+ goneMailboxIds: string[];
43
45
  copyRow: CopyRow | null;
44
46
  destinationHolds: number[];
45
47
  connection: Connection;
@@ -89,6 +91,8 @@ const fresh = (): Harness => ({
89
91
  calls: [],
90
92
  account: { accountId: "acc-1", accountConfigId: "cfg-1" },
91
93
  mailbox: { mailboxId: "src-mbx", uidValidity: 1, cursorState: undefined },
94
+ folderPaths: { "src-mbx": "INBOX", "dst-mbx": "Archive" },
95
+ goneMailboxIds: [],
92
96
  copyRow: unsettledCopyRow(),
93
97
  destinationHolds: [],
94
98
  connection: buildConnection(),
@@ -136,9 +140,14 @@ const deps = (): MessageCopyDeps =>
136
140
  update: record("threadMessage.update"),
137
141
  },
138
142
  mailbox: {
139
- get: async () => {
143
+ get: async (_accountId: string, mailboxId: string) => {
140
144
  if (h.mailboxError) throw h.mailboxError;
141
- return h.mailbox;
145
+ if (h.goneMailboxIds.includes(mailboxId)) throw notFoundError();
146
+ const row =
147
+ mailboxId === h.mailbox.mailboxId
148
+ ? h.mailbox
149
+ : { mailboxId, uidValidity: 1, cursorState: "normal" };
150
+ return { ...row, fullPath: h.folderPaths[mailboxId] };
142
151
  },
143
152
  update: record("mailbox.update"),
144
153
  },
@@ -589,4 +598,57 @@ describe("handleMessageCopy", () => {
589
598
  assert.equal(called("message.delete")[0]?.args[0], "new-msg");
590
599
  assert.equal(called("threadMessage.deleteMany").length, 1);
591
600
  });
601
+
602
+ it("runs a copy enqueued with the unversioned payload that carries folder paths", async () => {
603
+ await handleMessageCopy(event, noopLogger, 1, deps());
604
+
605
+ assert.deepEqual(called("copyMessages")[0]?.args, [[10], "Archive"]);
606
+ assert.deepEqual(called("message.updateUid")[0]?.args, [
607
+ "new-msg",
608
+ 20,
609
+ "dst-mbx",
610
+ ]);
611
+ });
612
+
613
+ it("runs a copy enqueued with the pathless payload", async () => {
614
+ const pathless: MessageCopyEvent = {
615
+ type: "MESSAGE_COPY",
616
+ schemaVersion: 3,
617
+ eventId: "evt-1",
618
+ timestamp: 1,
619
+ accountId: "acc-1",
620
+ sourceMessageId: "src-msg",
621
+ newMessageId: "new-msg",
622
+ sourceMailboxId: "src-mbx",
623
+ destinationMailboxId: "dst-mbx",
624
+ uid: 10,
625
+ };
626
+
627
+ await handleMessageCopy(pathless, noopLogger, 1, deps());
628
+
629
+ assert.deepEqual(called("openBox")[0]?.args, ["INBOX", true]);
630
+ assert.deepEqual(called("copyMessages")[0]?.args, [[10], "Archive"]);
631
+ });
632
+
633
+ it("copies between the paths the rows hold when both folders were renamed after enqueue", async () => {
634
+ h.folderPaths = { "src-mbx": "Old/INBOX", "dst-mbx": "Records/Archive" };
635
+
636
+ await handleMessageCopy(event, noopLogger, 1, deps());
637
+
638
+ assert.deepEqual(called("openBox")[0]?.args, ["Old/INBOX", true]);
639
+ assert.deepEqual(called("copyMessages")[0]?.args, [
640
+ [10],
641
+ "Records/Archive",
642
+ ]);
643
+ });
644
+
645
+ it("reconciles the copy row away without connecting when the destination mailbox was deleted", async () => {
646
+ h.goneMailboxIds = ["dst-mbx"];
647
+
648
+ await handleMessageCopy(event, noopLogger, 1, deps());
649
+
650
+ assert.equal(h.getConnectionCount, 0);
651
+ assert.equal(called("copyMessages").length, 0);
652
+ assert.deepEqual(called("message.delete")[0]?.args, ["new-msg"]);
653
+ });
592
654
  });
@@ -1,5 +1,4 @@
1
1
  import { getClient } from "@remit/backend/client";
2
- import { isNotFoundError } from "@remit/data-ports/errors";
3
2
  import {
4
3
  MessageMutation,
5
4
  MessageStatus,
@@ -18,6 +17,7 @@ import { isAccountDeleted } from "../account-check.js";
18
17
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
19
18
  import { emitEvent } from "../emit.js";
20
19
  import type { MessageCopyEvent } from "../events.js";
20
+ import { findMailboxRow } from "../mailbox-row.js";
21
21
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
22
22
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
23
23
  import {
@@ -108,8 +108,6 @@ export const handleMessageCopy = async (
108
108
  sourceMessageId,
109
109
  newMessageId,
110
110
  sourceMailboxId,
111
- sourceMailboxPath,
112
- destinationMailboxPath,
113
111
  destinationMailboxId,
114
112
  uid,
115
113
  } = event;
@@ -120,8 +118,8 @@ export const handleMessageCopy = async (
120
118
  accountId,
121
119
  sourceMessageId,
122
120
  newMessageId,
123
- from: sourceMailboxPath,
124
- to: destinationMailboxPath,
121
+ from: sourceMailboxId,
122
+ to: destinationMailboxId,
125
123
  },
126
124
  "Handling event",
127
125
  );
@@ -173,19 +171,46 @@ export const handleMessageCopy = async (
173
171
  // NotFoundError forever, and on the account's per-group FIFO that head
174
172
  // message stalls the whole pipeline (issues #287, #289, #290). A deleted
175
173
  // source mailbox makes the copy moot: ack with a WARN.
176
- const mailbox = await mailboxService
177
- .get(accountId, sourceMailboxId)
178
- .catch((error: unknown) => {
179
- if (isNotFoundError(error)) return null;
180
- throw error;
181
- });
182
- if (!mailbox) {
174
+ const source = await findMailboxRow(
175
+ mailboxService,
176
+ accountId,
177
+ sourceMailboxId,
178
+ );
179
+ if (source.kind === "gone") {
183
180
  log.warn(
184
181
  { accountId, sourceMessageId, mailboxId: sourceMailboxId },
185
182
  "Skipping MESSAGE_COPY: source mailbox no longer exists (deleted)",
186
183
  );
187
184
  return;
188
185
  }
186
+ const mailbox = source.mailbox;
187
+ const sourceMailboxPath = mailbox.fullPath;
188
+
189
+ const destination = await findMailboxRow(
190
+ mailboxService,
191
+ accountId,
192
+ destinationMailboxId,
193
+ );
194
+ if (destination.kind === "gone") {
195
+ const { threadMessagesDeleted } = await reconcileStaleMessage(
196
+ { messageService, threadMessageService },
197
+ account.accountConfigId,
198
+ newMessageId,
199
+ );
200
+ log.warn(
201
+ {
202
+ metric: "message_copy_destination_gone",
203
+ accountId,
204
+ sourceMessageId,
205
+ newMessageId,
206
+ mailboxId: destinationMailboxId,
207
+ threadMessagesDeleted,
208
+ },
209
+ "Skipping MESSAGE_COPY: destination mailbox no longer exists (deleted); optimistic copy row reconciled away",
210
+ );
211
+ return;
212
+ }
213
+ const destinationMailboxPath = destination.mailbox.fullPath;
189
214
 
190
215
  // The copy row carries the source's Message-ID header, so the
191
216
  // destination can be asked where this copy is. Always on the UNGUARDED
@@ -291,6 +291,8 @@ interface Harness {
291
291
  } | null;
292
292
  mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
293
293
  mailboxError?: Error;
294
+ folderPaths: Record<string, string>;
295
+ goneMailboxIds: string[];
294
296
  connection: Connection;
295
297
  threadMessageUpdateError?: Error;
296
298
  messageRow: { messageIdHeader?: string; status?: string } | undefined;
@@ -398,6 +400,8 @@ const fresh = (): Harness => ({
398
400
  calls: [],
399
401
  account: { accountId: "acc-1", accountConfigId: "cfg-1" },
400
402
  mailbox: { mailboxId: "src-mbx", uidValidity: 1, cursorState: undefined },
403
+ folderPaths: { "src-mbx": "INBOX", "trash-mbx": "Trash" },
404
+ goneMailboxIds: [],
401
405
  // What the API writes before it enqueues: a move to Trash records `moving`,
402
406
  // a permanent delete records `deleting`, and the handler's guard asks one
403
407
  // question of both — is a mutation still outstanding on this row.
@@ -457,9 +461,18 @@ const deps = (): MessageDeleteDeps =>
457
461
  deleteMany: record("threadMessage.deleteMany"),
458
462
  },
459
463
  mailbox: {
460
- get: async () => {
464
+ get: async (_accountId: string, mailboxId: string) => {
461
465
  if (h.mailboxError) throw h.mailboxError;
462
- return h.mailbox;
466
+ if (h.goneMailboxIds.includes(mailboxId)) {
467
+ throw Object.assign(new Error(`Mailbox not found: ${mailboxId}`), {
468
+ name: "NotFoundError",
469
+ });
470
+ }
471
+ const row =
472
+ mailboxId === h.mailbox.mailboxId
473
+ ? h.mailbox
474
+ : { mailboxId, uidValidity: 1, cursorState: "normal" };
475
+ return { ...row, fullPath: h.folderPaths[mailboxId] };
463
476
  },
464
477
  update: record("mailbox.update"),
465
478
  },
@@ -1739,3 +1752,118 @@ describe("handleMessageDelete", () => {
1739
1752
  );
1740
1753
  });
1741
1754
  });
1755
+
1756
+ describe("handleMessageDelete — folder paths come from the mailbox rows", () => {
1757
+ beforeEach(() => {
1758
+ h = fresh();
1759
+ });
1760
+
1761
+ const movedTo = (): string[] => {
1762
+ const destinations: string[] = [];
1763
+ h.connection.moveMessages = async (
1764
+ _uids: number[],
1765
+ destination: string,
1766
+ ) => {
1767
+ destinations.push(destination);
1768
+ return { uidMap: new Map([[10, 20]]) };
1769
+ };
1770
+ return destinations;
1771
+ };
1772
+
1773
+ it("runs a delete enqueued with the version 2 payload that carries folder paths", async () => {
1774
+ const destinations = movedTo();
1775
+
1776
+ await handleMessageDelete(moveEvent, noopLogger, 1, deps());
1777
+
1778
+ assert.deepEqual(destinations, ["Trash"]);
1779
+ assert.deepEqual(called("message.updateUid")[0]?.args, [
1780
+ "msg-1",
1781
+ 20,
1782
+ "trash-mbx",
1783
+ ]);
1784
+ });
1785
+
1786
+ it("runs a delete enqueued with the pathless payload", async () => {
1787
+ const destinations = movedTo();
1788
+ const pathless: MessageDeleteEvent = {
1789
+ type: "MESSAGE_DELETE",
1790
+ schemaVersion: 3,
1791
+ eventId: "evt-1",
1792
+ timestamp: 1,
1793
+ accountId: "acc-1",
1794
+ messageId: "msg-1",
1795
+ mailboxId: "src-mbx",
1796
+ uid: 10,
1797
+ operation: "move_to_trash",
1798
+ destinationMailboxId: "trash-mbx",
1799
+ };
1800
+
1801
+ await handleMessageDelete(pathless, noopLogger, 1, deps());
1802
+
1803
+ assert.deepEqual(destinations, ["Trash"]);
1804
+ assert.deepEqual(called("message.updateUid")[0]?.args, [
1805
+ "msg-1",
1806
+ 20,
1807
+ "trash-mbx",
1808
+ ]);
1809
+ });
1810
+
1811
+ it("moves to Trash under its current path when the folder was renamed after enqueue", async () => {
1812
+ const destinations = movedTo();
1813
+ const opened: string[] = [];
1814
+ const openBox = h.connection.openBox;
1815
+ h.connection.openBox = async (path: string) => {
1816
+ opened.push(path);
1817
+ return openBox(path);
1818
+ };
1819
+ h.folderPaths = { "src-mbx": "Mail/INBOX", "trash-mbx": "Deleted Items" };
1820
+
1821
+ await handleMessageDelete(moveEvent, noopLogger, 1, deps());
1822
+
1823
+ assert.equal(opened[0], "Mail/INBOX");
1824
+ assert.deepEqual(destinations, ["Deleted Items"]);
1825
+ });
1826
+
1827
+ it("opens the source under its current path when it was renamed after enqueue", async () => {
1828
+ const destinations = movedTo();
1829
+ const opened: string[] = [];
1830
+ const openBox = h.connection.openBox;
1831
+ h.connection.openBox = async (path: string) => {
1832
+ opened.push(path);
1833
+ return openBox(path);
1834
+ };
1835
+ h.folderPaths = { "src-mbx": "Old/Inbox-renamed", "trash-mbx": "Trash" };
1836
+
1837
+ await handleMessageDelete(moveEvent, noopLogger, 1, deps());
1838
+
1839
+ assert.equal(opened[0], "Old/Inbox-renamed");
1840
+ assert.deepEqual(destinations, ["Trash"]);
1841
+ });
1842
+
1843
+ it("resyncs the source when a redelivered delete finds the Trash mailbox deleted", async () => {
1844
+ h.goneMailboxIds = ["trash-mbx"];
1845
+
1846
+ await handleMessageDelete(moveEvent, noopLogger, 2, deps());
1847
+
1848
+ assert.deepEqual(called("emitEvent")[0]?.args, [
1849
+ { type: "SYNC_MESSAGES", accountId: "acc-1", mailboxId: "src-mbx" },
1850
+ ]);
1851
+ });
1852
+
1853
+ it("hands the row back without connecting when the Trash mailbox was deleted", async () => {
1854
+ h.goneMailboxIds = ["trash-mbx"];
1855
+ const destinations = movedTo();
1856
+
1857
+ await handleMessageDelete(moveEvent, capturingLog(), 1, deps());
1858
+
1859
+ assert.equal(h.getConnectionCount, 0);
1860
+ assert.deepEqual(destinations, []);
1861
+ assert.equal(called("connection.deleteMessages").length, 0);
1862
+ assert.equal(called("message.delete").length, 0);
1863
+ assert.deepEqual(restoreCalls()[0]?.args, restoredToSource("abandoned"));
1864
+ assert.equal(
1865
+ loggedWith("alert", "message_delete_destination_gone").length,
1866
+ 1,
1867
+ );
1868
+ });
1869
+ });
@@ -4,8 +4,7 @@ import type {
4
4
  IThreadMessageRepository,
5
5
  MessageItem,
6
6
  } from "@remit/data-ports";
7
- import { isNotFoundError } from "@remit/data-ports/errors";
8
- import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
7
+ import { isAcceptedSchemaVersion } from "@remit/data-ports/mutation-events";
9
8
  import {
10
9
  MessageMutation,
11
10
  MessageStatus,
@@ -27,6 +26,7 @@ import { isAccountDeleted } from "../account-check.js";
27
26
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
28
27
  import { emitEvent } from "../emit.js";
29
28
  import type { MessageDeleteEvent } from "../events.js";
29
+ import { findMailboxRow } from "../mailbox-row.js";
30
30
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
31
31
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
32
32
  import { resolveExhaustedMessageDeleteFailure } from "./message-delete-terminal.js";
@@ -38,6 +38,11 @@ import {
38
38
  searchMailboxForHighestMessageIdUid,
39
39
  } from "./message-move.js";
40
40
 
41
+ interface FolderPaths {
42
+ source: string;
43
+ destination: string | undefined;
44
+ }
45
+
41
46
  export const getMessageDeleteMaxAttempts = (
42
47
  processEnv: NodeJS.ProcessEnv = process.env,
43
48
  ): number => attemptBudget("MESSAGE_DELETE_MAX_ATTEMPTS", 3, processEnv);
@@ -179,15 +184,13 @@ export const handleMessageDelete = async (
179
184
  accountId,
180
185
  messageId,
181
186
  mailboxId,
182
- mailboxPath,
183
187
  uid,
184
188
  operation,
185
189
  destinationMailboxId,
186
- destinationMailboxPath,
187
190
  } = event;
188
191
 
189
192
  log.info(
190
- { event: event.type, accountId, messageId, mailboxPath, operation },
193
+ { event: event.type, accountId, messageId, mailboxId, operation },
191
194
  "Handling event",
192
195
  );
193
196
 
@@ -308,7 +311,7 @@ export const handleMessageDelete = async (
308
311
  accountId,
309
312
  messageId,
310
313
  uid,
311
- mailboxPath,
314
+ mailboxId,
312
315
  operation,
313
316
  receiveCount,
314
317
  reason,
@@ -431,8 +434,10 @@ export const handleMessageDelete = async (
431
434
  const settlePausedDelete = async (
432
435
  getRawConnection: () => Promise<IImapConnection>,
433
436
  commandIssued: boolean,
437
+ paths: FolderPaths,
434
438
  ): Promise<void> => {
435
439
  const mayHaveIssued = commandIssued || receiveCount > 1;
440
+ const destinationMailboxPath = paths.destination;
436
441
  const isProbeableTrashMove =
437
442
  mayHaveIssued &&
438
443
  operation === "move_to_trash" &&
@@ -443,7 +448,7 @@ export const handleMessageDelete = async (
443
448
  ? await probePausedPlacement(await getRawConnection(), {
444
449
  messageIdHeader: (await messageService.get([messageId]))[0]
445
450
  ?.messageIdHeader,
446
- sourceMailboxPath: mailboxPath,
451
+ sourceMailboxPath: paths.source,
447
452
  destinationMailboxPath,
448
453
  })
449
454
  : { kind: "at-source" };
@@ -486,6 +491,7 @@ export const handleMessageDelete = async (
486
491
 
487
492
  const settleExhaustedDelete = async (
488
493
  accountConfigId: string,
494
+ sourceMailboxPath: string,
489
495
  getConnection: () => Promise<IImapConnection>,
490
496
  settlePaused: () => Promise<void>,
491
497
  ): Promise<void> => {
@@ -497,7 +503,7 @@ export const handleMessageDelete = async (
497
503
  messageId,
498
504
  sourceMailboxId: mailboxId,
499
505
  uid,
500
- sourceMailboxPath: mailboxPath,
506
+ sourceMailboxPath,
501
507
  getConnection,
502
508
  },
503
509
  ).catch(async (settleError: unknown) => {
@@ -542,6 +548,7 @@ export const handleMessageDelete = async (
542
548
  const settleUnconfirmedTrashMove = async (
543
549
  confirmation: Exclude<TrashMoveConfirmation, { outcome: "confirmed" }>,
544
550
  accountConfigId: string,
551
+ sourceMailboxPath: string,
545
552
  getConnection: () => Promise<IImapConnection>,
546
553
  settlePaused: () => Promise<void>,
547
554
  ): Promise<void> => {
@@ -550,7 +557,7 @@ export const handleMessageDelete = async (
550
557
  accountConfigId,
551
558
  messageId,
552
559
  uid,
553
- mailboxPath,
560
+ mailboxId,
554
561
  receiveCount,
555
562
  confirmation: confirmation.outcome,
556
563
  };
@@ -569,7 +576,12 @@ export const handleMessageDelete = async (
569
576
  context,
570
577
  "Move to trash carries no Message-ID header to probe the destination with; settling on the source's answer alone",
571
578
  );
572
- await settleExhaustedDelete(accountConfigId, getConnection, settlePaused);
579
+ await settleExhaustedDelete(
580
+ accountConfigId,
581
+ sourceMailboxPath,
582
+ getConnection,
583
+ settlePaused,
584
+ );
573
585
  return;
574
586
  }
575
587
 
@@ -578,7 +590,7 @@ export const handleMessageDelete = async (
578
590
  );
579
591
  };
580
592
 
581
- if (!isCurrentSchemaVersion(event.schemaVersion)) {
593
+ if (!isAcceptedSchemaVersion(event.schemaVersion)) {
582
594
  await abandonDelete(
583
595
  "Refused to delete: event was minted under an unknown contract",
584
596
  "message_delete_unknown_schema_version",
@@ -596,19 +608,39 @@ export const handleMessageDelete = async (
596
608
  // NotFoundError forever, and on the account's per-group FIFO that head
597
609
  // message stalls the whole pipeline (issues #287, #289, #290). A deleted
598
610
  // mailbox makes the delete moot: ack with a WARN.
599
- const mailbox = await mailboxService
600
- .get(accountId, mailboxId)
601
- .catch((error: unknown) => {
602
- if (isNotFoundError(error)) return null;
603
- throw error;
604
- });
605
- if (!mailbox) {
611
+ const source = await findMailboxRow(mailboxService, accountId, mailboxId);
612
+ if (source.kind === "gone") {
606
613
  log.warn(
607
614
  { accountId, messageId, mailboxId },
608
615
  "Skipping MESSAGE_DELETE: mailbox no longer exists (deleted)",
609
616
  );
610
617
  return;
611
618
  }
619
+ const mailbox = source.mailbox;
620
+
621
+ const destination =
622
+ operation === "move_to_trash" && destinationMailboxId !== undefined
623
+ ? await findMailboxRow(
624
+ mailboxService,
625
+ accountId,
626
+ destinationMailboxId,
627
+ )
628
+ : undefined;
629
+ if (destination?.kind === "gone") {
630
+ await abandonDelete(
631
+ "Refused to delete: the destination mailbox no longer exists (deleted)",
632
+ "message_delete_destination_gone",
633
+ );
634
+ await emitMailboxResync(emitEvent, { accountId, mailboxId });
635
+ return;
636
+ }
637
+
638
+ const mailboxPath = mailbox.fullPath;
639
+ const destinationMailboxPath = destination?.mailbox.fullPath;
640
+ const paths: FolderPaths = {
641
+ source: mailboxPath,
642
+ destination: destinationMailboxPath,
643
+ };
612
644
 
613
645
  const scope = createConnectionScopeWithCredentials(account, credentials);
614
646
 
@@ -628,7 +660,7 @@ export const handleMessageDelete = async (
628
660
  );
629
661
 
630
662
  const settlePaused = (commandIssued: boolean): Promise<void> =>
631
- settlePausedDelete(scope.getConnection, commandIssued);
663
+ settlePausedDelete(scope.getConnection, commandIssued, paths);
632
664
 
633
665
  // Flipped the instant the MOVE or the EXPUNGE leaves, so every settle
634
666
  // downstream of it knows the server was asked.
@@ -734,6 +766,7 @@ export const handleMessageDelete = async (
734
766
  await settleUnconfirmedTrashMove(
735
767
  confirmation,
736
768
  account.accountConfigId,
769
+ mailboxPath,
737
770
  getGuardedConnection,
738
771
  () => settlePaused(true),
739
772
  );
@@ -843,6 +876,7 @@ export const handleMessageDelete = async (
843
876
  // failure this budget exists for.
844
877
  await settleExhaustedDelete(
845
878
  account.accountConfigId,
879
+ mailboxPath,
846
880
  getGuardedConnection,
847
881
  () => settlePaused(commandIssued),
848
882
  );
@@ -423,7 +423,19 @@ describe("handleMessageMove — the move's own pending state gates every attempt
423
423
  messageIdHeader: options.probeable ? "<moved@example.com>" : undefined,
424
424
  },
425
425
  ]);
426
- mock.method(client.mailbox, "get", async () => pausedSource());
426
+ mock.method(
427
+ client.mailbox,
428
+ "get",
429
+ async (_accountId: string, id: string) =>
430
+ id === "mm-src-zzz"
431
+ ? { ...pausedSource(), fullPath: "INBOX" }
432
+ : {
433
+ mailboxId: id,
434
+ uidValidity: 1,
435
+ cursorState: "normal",
436
+ fullPath: "Archive",
437
+ },
438
+ );
427
439
  const transitionCalls: unknown[][] = [];
428
440
  mock.method(
429
441
  client.message,
@@ -616,6 +628,161 @@ describe("handleMessageMove — the move's own pending state gates every attempt
616
628
  });
617
629
  });
618
630
 
631
+ const arrangeLiveMove = async (
632
+ folders: Record<string, string>,
633
+ ): Promise<{
634
+ opened: string[];
635
+ movedTo: string[];
636
+ updateUidCalls: unknown[][];
637
+ transitionCalls: unknown[][];
638
+ connection: IImapConnection;
639
+ }> => {
640
+ const client = await getClient();
641
+ mock.method(client.account, "get", async () => cappedAccount());
642
+ mock.method(client.secrets, "decrypt", async () => "fake-password");
643
+ mock.method(client.message, "get", async () => [pendingRow()]);
644
+ mock.method(
645
+ client.mailbox,
646
+ "get",
647
+ async (_accountId: string, id: string) => {
648
+ const fullPath = folders[id];
649
+ if (fullPath === undefined) {
650
+ throw Object.assign(new Error(`Mailbox not found: ${id}`), {
651
+ name: "NotFoundError",
652
+ });
653
+ }
654
+ return {
655
+ mailboxId: id,
656
+ uidValidity: 1,
657
+ cursorState: "normal",
658
+ fullPath,
659
+ };
660
+ },
661
+ );
662
+ const updateUidCalls: unknown[][] = [];
663
+ mock.method(client.message, "updateUid", async (...args: unknown[]) => {
664
+ updateUidCalls.push(args);
665
+ });
666
+ const transitionCalls: unknown[][] = [];
667
+ mock.method(
668
+ client.message,
669
+ "transitionPlacement",
670
+ async (...args: unknown[]) => {
671
+ transitionCalls.push(args);
672
+ return { messageId: "mm-msg-zzz" };
673
+ },
674
+ );
675
+ const opened: string[] = [];
676
+ const movedTo: string[] = [];
677
+ const connection = {
678
+ openBox: async (path: string) => {
679
+ opened.push(path);
680
+ return { uidvalidity: 1 } as never;
681
+ },
682
+ moveMessages: async (_uids: number[], destination: string) => {
683
+ movedTo.push(destination);
684
+ return { uidMap: new Map([[10, 44]]) };
685
+ },
686
+ } as unknown as IImapConnection;
687
+ return { opened, movedTo, updateUidCalls, transitionCalls, connection };
688
+ };
689
+
690
+ it("runs a move enqueued with the unversioned payload that carries folder paths", async () => {
691
+ const { movedTo, updateUidCalls, connection } = await arrangeLiveMove({
692
+ "mm-src-zzz": "INBOX",
693
+ "mm-dst-zzz": "Archive",
694
+ });
695
+
696
+ await handleMessageMove(event, silentLogger, 1, moveDeps(connection));
697
+
698
+ assert.deepEqual(movedTo, ["Archive"]);
699
+ assert.deepEqual(updateUidCalls[0], ["mm-msg-zzz", 44, "mm-dst-zzz"]);
700
+ });
701
+
702
+ it("runs a move enqueued with the pathless payload", async () => {
703
+ const { opened, movedTo, connection } = await arrangeLiveMove({
704
+ "mm-src-zzz": "INBOX",
705
+ "mm-dst-zzz": "Archive",
706
+ });
707
+ const pathless: MessageMoveEvent = {
708
+ type: "MESSAGE_MOVE",
709
+ schemaVersion: 3,
710
+ accountId: acctId,
711
+ messageId: "mm-msg-zzz",
712
+ sourceMailboxId: "mm-src-zzz",
713
+ destinationMailboxId: "mm-dst-zzz",
714
+ uid: 10,
715
+ eventId: "mm-evt-zzz",
716
+ timestamp: 1700000000000,
717
+ };
718
+
719
+ await handleMessageMove(pathless, silentLogger, 1, moveDeps(connection));
720
+
721
+ assert.deepEqual(opened, ["INBOX"]);
722
+ assert.deepEqual(movedTo, ["Archive"]);
723
+ });
724
+
725
+ it("moves under the destination's current path when the folder was renamed after enqueue", async () => {
726
+ const { opened, movedTo, updateUidCalls, connection } =
727
+ await arrangeLiveMove({
728
+ "mm-src-zzz": "INBOX",
729
+ "mm-dst-zzz": "Records",
730
+ });
731
+
732
+ await handleMessageMove(event, silentLogger, 1, moveDeps(connection));
733
+
734
+ assert.deepEqual(opened, ["INBOX"]);
735
+ assert.deepEqual(
736
+ movedTo,
737
+ ["Records"],
738
+ "the payload still says Archive; the row says where the folder is now",
739
+ );
740
+ assert.deepEqual(updateUidCalls[0], ["mm-msg-zzz", 44, "mm-dst-zzz"]);
741
+ });
742
+
743
+ it("opens the source under its current path when it was renamed after enqueue", async () => {
744
+ const { opened, movedTo, connection } = await arrangeLiveMove({
745
+ "mm-src-zzz": "Old/Inbox-renamed",
746
+ "mm-dst-zzz": "Archive",
747
+ });
748
+
749
+ await handleMessageMove(event, silentLogger, 1, moveDeps(connection));
750
+
751
+ assert.deepEqual(
752
+ opened,
753
+ ["Old/Inbox-renamed"],
754
+ "the payload still says INBOX; the row says where the folder is now",
755
+ );
756
+ assert.deepEqual(movedTo, ["Archive"]);
757
+ });
758
+
759
+ it("hands the row back to its source without connecting when the destination mailbox was deleted", async () => {
760
+ const { transitionCalls, updateUidCalls } = await arrangeLiveMove({
761
+ "mm-src-zzz": "INBOX",
762
+ });
763
+
764
+ await handleMessageMove(event, capturingLogger(), 1, moveDeps());
765
+
766
+ assert.equal(connectCount, 0);
767
+ assert.equal(updateUidCalls.length, 0);
768
+ assert.deepEqual(transitionCalls[0]?.[2], {
769
+ mailboxId: "mm-src-zzz",
770
+ uid: 10,
771
+ status: "active",
772
+ syncStatus: "synced",
773
+ abandonedMutation: "none",
774
+ });
775
+ assert.equal(
776
+ logLines.filter(
777
+ (line) => line.fields.metric === "message_move_destination_gone",
778
+ ).length,
779
+ 1,
780
+ );
781
+ assert.deepEqual(emitted, [
782
+ { type: "SYNC_MESSAGES", accountId: acctId, mailboxId: "mm-src-zzz" },
783
+ ]);
784
+ });
785
+
619
786
  it("acks without connecting when the message row is already gone", async () => {
620
787
  const client = await getClient();
621
788
  mock.method(client.account, "get", async () => cappedAccount());
@@ -1,6 +1,5 @@
1
1
  import { getClient } from "@remit/backend/client";
2
2
  import type { MessageItem, ThreadMessageItem } from "@remit/data-ports";
3
- import { isNotFoundError } from "@remit/data-ports/errors";
4
3
  import {
5
4
  MessageMutation,
6
5
  MessageStatus,
@@ -20,6 +19,7 @@ import { isAccountDeleted } from "../account-check.js";
20
19
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
21
20
  import { emitEvent } from "../emit.js";
22
21
  import type { MessageMoveEvent, SyncMessagesEvent } from "../events.js";
22
+ import { findMailboxRow } from "../mailbox-row.js";
23
23
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
24
24
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
25
25
  import { resolveExhaustedMessageMoveFailure } from "./message-move-terminal.js";
@@ -278,23 +278,16 @@ export const handleMessageMove = async (
278
278
  secrets,
279
279
  } = await getClient();
280
280
 
281
- const {
282
- accountId,
283
- messageId,
284
- sourceMailboxId,
285
- sourceMailboxPath,
286
- destinationMailboxPath,
287
- destinationMailboxId,
288
- uid,
289
- } = event;
281
+ const { accountId, messageId, sourceMailboxId, destinationMailboxId, uid } =
282
+ event;
290
283
 
291
284
  log.info(
292
285
  {
293
286
  event: event.type,
294
287
  accountId,
295
288
  messageId,
296
- from: sourceMailboxPath,
297
- to: destinationMailboxPath,
289
+ from: sourceMailboxId,
290
+ to: destinationMailboxId,
298
291
  },
299
292
  "Handling event",
300
293
  );
@@ -349,19 +342,25 @@ export const handleMessageMove = async (
349
342
  // NotFoundError forever, and on the account's per-group FIFO that head
350
343
  // message stalls the whole pipeline (issues #287, #289, #290). A deleted
351
344
  // source mailbox makes the move moot: ack with a WARN.
352
- const mailbox = await mailboxService
353
- .get(accountId, sourceMailboxId)
354
- .catch((error: unknown) => {
355
- if (isNotFoundError(error)) return null;
356
- throw error;
357
- });
358
- if (!mailbox) {
345
+ const source = await findMailboxRow(
346
+ mailboxService,
347
+ accountId,
348
+ sourceMailboxId,
349
+ );
350
+ if (source.kind === "gone") {
359
351
  log.warn(
360
352
  { accountId, messageId, mailboxId: sourceMailboxId },
361
353
  "Skipping MESSAGE_MOVE: source mailbox no longer exists (deleted)",
362
354
  );
363
355
  return;
364
356
  }
357
+ const mailbox = source.mailbox;
358
+ const sourceMailboxPath = mailbox.fullPath;
359
+ const destination = await findMailboxRow(
360
+ mailboxService,
361
+ accountId,
362
+ destinationMailboxId,
363
+ );
365
364
 
366
365
  const settleMoved = async (newUid: number): Promise<void> => {
367
366
  await messageService.updateUid(messageId, newUid, destinationMailboxId);
@@ -389,7 +388,7 @@ export const handleMessageMove = async (
389
388
  messageId,
390
389
  oldUid: uid,
391
390
  newUid,
392
- destination: destinationMailboxPath,
391
+ destination: destinationMailboxId,
393
392
  },
394
393
  "Message moved successfully",
395
394
  );
@@ -413,8 +412,8 @@ export const handleMessageMove = async (
413
412
  accountId,
414
413
  messageId,
415
414
  uid,
416
- sourceMailboxPath,
417
- destinationMailboxPath,
415
+ sourceMailboxId,
416
+ destinationMailboxId,
418
417
  receiveCount,
419
418
  reason,
420
419
  };
@@ -449,6 +448,20 @@ export const handleMessageMove = async (
449
448
  );
450
449
  };
451
450
 
451
+ if (destination.kind === "gone") {
452
+ await handBackToSource(
453
+ MessageSyncStatus.synced,
454
+ { metric: "message_move_destination_gone" },
455
+ "destination mailbox no longer exists (deleted)",
456
+ );
457
+ await emitMailboxResync(emitEvent, {
458
+ accountId,
459
+ mailboxId: sourceMailboxId,
460
+ });
461
+ return;
462
+ }
463
+ const destinationMailboxPath = destination.mailbox.fullPath;
464
+
452
465
  const scope = createConnectionScopeWithCredentials(account, credentials);
453
466
 
454
467
  // The terminal resolver opens the source itself, and its answer now
@@ -0,0 +1,20 @@
1
+ import type { IMailboxRepository, MailboxItem } from "@remit/data-ports";
2
+ import { isNotFoundError } from "@remit/data-ports/errors";
3
+
4
+ export type MailboxRow =
5
+ | { kind: "present"; mailbox: MailboxItem }
6
+ | { kind: "gone"; mailboxId: string };
7
+
8
+ export const findMailboxRow = (
9
+ mailboxService: Pick<IMailboxRepository, "get">,
10
+ accountId: string,
11
+ mailboxId: string,
12
+ ): Promise<MailboxRow> =>
13
+ mailboxService.get(accountId, mailboxId).then(
14
+ (mailbox): MailboxRow =>
15
+ mailbox ? { kind: "present", mailbox } : { kind: "gone", mailboxId },
16
+ (error: unknown): MailboxRow => {
17
+ if (isNotFoundError(error)) return { kind: "gone", mailboxId };
18
+ throw error;
19
+ },
20
+ );