@remit/imap-worker 0.0.50 → 0.0.52

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.50",
3
+ "version": "0.0.52",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
package/src/events.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { MUTATION_EVENT_SCHEMA_VERSION } from "@remit/data-ports/mutation-events";
2
+
1
3
  export interface BaseEvent {
2
4
  accountId: string;
3
5
  eventId: string; // Idempotency key
@@ -88,6 +90,7 @@ export type MailboxManagementEvent =
88
90
  */
89
91
  export interface MessageDeleteEvent extends BaseEvent {
90
92
  type: "MESSAGE_DELETE";
93
+ schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
91
94
  messageId: string;
92
95
  mailboxId: string;
93
96
  mailboxPath: string;
@@ -115,8 +118,16 @@ export interface MessageMoveEvent extends BaseEvent {
115
118
  */
116
119
  export interface EmptyTrashEvent extends BaseEvent {
117
120
  type: "EMPTY_TRASH";
121
+ schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
118
122
  trashMailboxId: string;
119
123
  trashMailboxPath: string;
124
+ /**
125
+ * The Trash folder's UIDVALIDITY at the moment the user consented. The
126
+ * handler compares it against what the SELECT serves: a path reused by a
127
+ * different folder (rename plus recreate) answers with a different value,
128
+ * and this is the only comparison the mutating connection cannot race.
129
+ */
130
+ trashUidValidity: number;
120
131
  }
121
132
 
122
133
  /**
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { beforeEach, describe, it } from "node:test";
3
+ import type { RoleResolution } from "@remit/data-ports/folder-role";
3
4
  import type { Logger } from "@remit/logger-lambda";
4
5
  import type { EmptyTrashEvent } from "../events.js";
5
6
  import { type EmptyTrashDeps, handleEmptyTrash } from "./empty-trash.js";
@@ -28,6 +29,14 @@ interface Connection {
28
29
  deleteMessages: (uids: number[]) => Promise<void>;
29
30
  }
30
31
 
32
+ interface LocalMessage {
33
+ messageId: string;
34
+ uid: number;
35
+ status: string;
36
+ }
37
+
38
+ type TrashMailbox = { mailboxId: string; fullPath: string };
39
+
31
40
  interface Harness {
32
41
  calls: Call[];
33
42
  account: {
@@ -37,9 +46,11 @@ interface Harness {
37
46
  } | null;
38
47
  mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
39
48
  mailboxError?: Error;
49
+ trashResolution: RoleResolution<TrashMailbox>;
40
50
  connection: Connection;
41
- localMessages: { messageId: string }[];
42
- threadMessage: { accountConfigId: string; threadMessageId: string } | null;
51
+ localMessages: LocalMessage[];
52
+ threadMessage: boolean;
53
+ messagesWithoutListingRow: string[];
43
54
  getConnectionCount: number;
44
55
  disconnectCount: number;
45
56
  }
@@ -60,17 +71,45 @@ const buildConnection = (): Connection => ({
60
71
  ) as Connection["deleteMessages"],
61
72
  });
62
73
 
74
+ const deleting = (messageId: string, uid: number): LocalMessage => ({
75
+ messageId,
76
+ uid,
77
+ status: "deleting",
78
+ });
79
+
63
80
  const fresh = (): Harness => ({
64
81
  calls: [],
65
82
  account: { accountId: "acc-1", accountConfigId: "cfg-1" },
66
83
  mailbox: { mailboxId: "trash-mbx", uidValidity: 1, cursorState: undefined },
84
+ trashResolution: {
85
+ kind: "flagged",
86
+ mailbox: { mailboxId: "trash-mbx", fullPath: "Trash" },
87
+ },
67
88
  connection: buildConnection(),
68
- localMessages: [{ messageId: "msg-1" }, { messageId: "msg-2" }],
69
- threadMessage: { accountConfigId: "cfg-1", threadMessageId: "tm-1" },
89
+ localMessages: [deleting("msg-1", 10), deleting("msg-2", 11)],
90
+ threadMessage: true,
91
+ messagesWithoutListingRow: [],
70
92
  getConnectionCount: 0,
71
93
  disconnectCount: 0,
72
94
  });
73
95
 
96
+ // Empty Trash only flips `isDeleted` on the listing row, so its presence is how
97
+ // the revert tells its own marks from a permanent delete's (which removes them
98
+ // up front).
99
+ const listingRow = (messageId: string) =>
100
+ h.messagesWithoutListingRow.includes(messageId) || !h.threadMessage
101
+ ? null
102
+ : {
103
+ accountConfigId: "cfg-1",
104
+ threadMessageId: `tm-${messageId}`,
105
+ sentDate: 1_700_000_000_000,
106
+ mailboxId: "trash-mbx",
107
+ isRead: false,
108
+ isDeleted: true,
109
+ hasStars: false,
110
+ hasAttachment: false,
111
+ };
112
+
74
113
  const deps = (): EmptyTrashDeps =>
75
114
  ({
76
115
  getClient: async () => ({
@@ -83,10 +122,17 @@ const deps = (): EmptyTrashDeps =>
83
122
  message: {
84
123
  listAllByMailbox: async () => h.localMessages,
85
124
  delete: record("message.delete"),
125
+ update: record("message.update"),
86
126
  },
87
127
  threadMessage: {
88
- findByMessageId: async () => h.threadMessage,
128
+ findByMessageId: async (_cfg: string, messageId: string) =>
129
+ listingRow(messageId),
130
+ findAllByMessageId: async (_cfg: string, messageId: string) => {
131
+ const row = listingRow(messageId);
132
+ return row ? [row] : [];
133
+ },
89
134
  delete: record("threadMessage.delete"),
135
+ update: record("threadMessage.update"),
90
136
  },
91
137
  mailbox: {
92
138
  get: async () => {
@@ -95,6 +141,9 @@ const deps = (): EmptyTrashDeps =>
95
141
  },
96
142
  update: record("mailbox.update"),
97
143
  },
144
+ mailboxSpecialUse: {
145
+ resolveTrashRole: async () => h.trashResolution,
146
+ },
98
147
  secrets: {},
99
148
  }),
100
149
  buildLifecycleDeps: () => ({}),
@@ -117,14 +166,31 @@ const deps = (): EmptyTrashDeps =>
117
166
 
118
167
  const event: EmptyTrashEvent = {
119
168
  type: "EMPTY_TRASH",
169
+ schemaVersion: 2,
120
170
  accountId: "acc-1",
121
171
  trashMailboxId: "trash-mbx",
122
172
  trashMailboxPath: "Trash",
173
+ trashUidValidity: 1,
123
174
  } as EmptyTrashEvent;
124
175
 
125
176
  const called = (method: string): Call[] =>
126
177
  h.calls.filter((c) => c.method === method);
127
178
 
179
+ const revertedMessageIds = (): string[] =>
180
+ called("message.update")
181
+ .filter(
182
+ (c) =>
183
+ (c.args[1] as { status?: string; syncStatus?: string }).status ===
184
+ "active" &&
185
+ (c.args[1] as { syncStatus?: string }).syncStatus === "synced",
186
+ )
187
+ .map((c) => c.args[0] as string);
188
+
189
+ const undeletedThreadMessageIds = (): string[] =>
190
+ called("threadMessage.update")
191
+ .filter((c) => (c.args[2] as { isDeleted?: boolean }).isDeleted === false)
192
+ .map((c) => c.args[1] as string);
193
+
128
194
  describe("handleEmptyTrash", () => {
129
195
  beforeEach(() => {
130
196
  h = fresh();
@@ -142,21 +208,66 @@ describe("handleEmptyTrash", () => {
142
208
  assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
143
209
  });
144
210
 
145
- it("skips the IMAP expunge when the trash is already empty on the server", async () => {
211
+ it("keeps the local row for a uid the expunge never covered", async () => {
212
+ // Mail that reached Trash after the SEARCH — or a move the unordered dev
213
+ // queue let outrun this event — is still on the server, so deleting its
214
+ // rows would hide mail the user can still see in another client.
215
+ h.localMessages = [
216
+ deleting("msg-1", 10),
217
+ deleting("msg-2", 11),
218
+ { messageId: "msg-late", uid: 12, status: "active" },
219
+ ];
220
+
221
+ await handleEmptyTrash(event, noopLog, deps());
222
+
223
+ assert.deepEqual(
224
+ called("message.delete").map((c) => c.args[0]),
225
+ ["msg-1", "msg-2"],
226
+ );
227
+ });
228
+
229
+ it("hands every row back when another client emptied the trash first", async () => {
230
+ // Apple Mail got there first, so the SEARCH is empty and this expunge
231
+ // covers nothing. Leaving the rows `deleting` hides mail that no longer
232
+ // exists anywhere, with nothing left to clear the mark.
146
233
  h.connection.search = async () => [];
147
234
 
148
235
  await handleEmptyTrash(event, noopLog, deps());
149
236
 
150
237
  assert.equal(called("connection.deleteMessages").length, 0);
151
- assert.equal(
152
- called("message.delete").length,
153
- 2,
154
- "local rows are still cleaned up",
155
- );
238
+ assert.equal(called("message.delete").length, 0);
239
+ assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
240
+ assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-1", "tm-msg-2"]);
241
+ });
242
+
243
+ it("hands back what a partial sweep left when the event is redelivered", async () => {
244
+ // The first attempt expunged and cleaned up msg-1, then died before
245
+ // msg-2. On redelivery the server has nothing left to find, and msg-2
246
+ // would otherwise sit marked for a deletion that will never come.
247
+ h.localMessages = [deleting("msg-2", 11)];
248
+ h.connection.search = async () => [];
249
+
250
+ await handleEmptyTrash(event, noopLog, deps());
251
+
252
+ assert.equal(called("message.delete").length, 0);
253
+ assert.deepEqual(revertedMessageIds(), ["msg-2"]);
254
+ });
255
+
256
+ it("leaves a row whose listing rows another operation already removed", async () => {
257
+ // A permanent delete inside Trash removes its listing rows up front and
258
+ // marks the Message `deleting`. Reverting it here would resurrect a row
259
+ // that operation is about to remove.
260
+ h.localMessages = [deleting("msg-1", 10), deleting("msg-expunging", 12)];
261
+ h.messagesWithoutListingRow = ["msg-expunging"];
262
+ h.connection.search = async () => [];
263
+
264
+ await handleEmptyTrash(event, noopLog, deps());
265
+
266
+ assert.deepEqual(revertedMessageIds(), ["msg-1"]);
156
267
  });
157
268
 
158
269
  it("deletes the message even when it has no thread row", async () => {
159
- h.threadMessage = null;
270
+ h.threadMessage = false;
160
271
 
161
272
  await handleEmptyTrash(event, noopLog, deps());
162
273
 
@@ -182,12 +293,10 @@ describe("handleEmptyTrash", () => {
182
293
  await assert.rejects(handleEmptyTrash(event, noopLog, deps()), /not found/);
183
294
  });
184
295
 
185
- it("skips without opening a connection when the cursor is rebuilding", async () => {
186
- h.mailbox = {
187
- mailboxId: "trash-mbx",
188
- uidValidity: 1,
189
- cursorState: "rebuilding",
190
- };
296
+ it("acks terminally without connecting when the Trash mailbox was deleted", async () => {
297
+ h.mailboxError = Object.assign(new Error("Mailbox not found: trash-mbx"), {
298
+ name: "NotFoundError",
299
+ });
191
300
 
192
301
  await handleEmptyTrash(event, noopLog, deps());
193
302
 
@@ -195,18 +304,78 @@ describe("handleEmptyTrash", () => {
195
304
  assert.equal(called("message.delete").length, 0);
196
305
  });
197
306
 
198
- it("acks terminally without connecting when the Trash mailbox was deleted", async () => {
199
- h.mailboxError = Object.assign(new Error("Mailbox not found: trash-mbx"), {
200
- name: "NotFoundError",
201
- });
307
+ it("abandons and reverts an event minted under an unknown contract", async () => {
308
+ const unversioned = {
309
+ type: "EMPTY_TRASH",
310
+ accountId: "acc-1",
311
+ trashMailboxId: "trash-mbx",
312
+ trashMailboxPath: "Trash",
313
+ } as unknown as EmptyTrashEvent;
314
+
315
+ await handleEmptyTrash(unversioned, noopLog, deps());
316
+
317
+ assert.equal(h.getConnectionCount, 0, "no connection is ever opened");
318
+ assert.equal(called("connection.deleteMessages").length, 0);
319
+ assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
320
+ assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-1", "tm-msg-2"]);
321
+ });
322
+
323
+ it("abandons when the Trash role now names a different folder", async () => {
324
+ h.trashResolution = {
325
+ kind: "appointed",
326
+ mailbox: { mailboxId: "other-mbx", fullPath: "INBOX/Bak" },
327
+ };
202
328
 
203
329
  await handleEmptyTrash(event, noopLog, deps());
204
330
 
205
331
  assert.equal(h.getConnectionCount, 0);
206
- assert.equal(called("message.delete").length, 0);
332
+ assert.equal(called("connection.deleteMessages").length, 0);
333
+ assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
207
334
  });
208
335
 
209
- it("pauses quietly when openBox trips a UIDVALIDITY mismatch", async () => {
336
+ it("abandons when the Trash role no longer rests on confirmed evidence", async () => {
337
+ h.trashResolution = {
338
+ kind: "proposed",
339
+ mailbox: { mailboxId: "trash-mbx", fullPath: "Trash" },
340
+ };
341
+
342
+ await handleEmptyTrash(event, noopLog, deps());
343
+
344
+ assert.equal(called("connection.deleteMessages").length, 0);
345
+ assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
346
+ });
347
+
348
+ it("refuses the expunge when the served UIDVALIDITY is not the one consented to", async () => {
349
+ // The path was reused: a third-party client renamed Trash away and made a
350
+ // fresh one. Same path, different folder, and nobody consented to empty it.
351
+ h.connection.openBox = async () => ({ uidvalidity: 77 });
352
+ h.mailbox = { mailboxId: "trash-mbx", uidValidity: 77 };
353
+
354
+ await handleEmptyTrash(event, noopLog, deps());
355
+
356
+ assert.equal(called("connection.deleteMessages").length, 0);
357
+ assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
358
+ assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-1", "tm-msg-2"]);
359
+ assert.equal(h.disconnectCount, 1);
360
+ });
361
+
362
+ it("reverts only the rows this empty marked, never a freshly synced one", async () => {
363
+ h.localMessages = [
364
+ deleting("msg-1", 10),
365
+ { messageId: "msg-arrived", uid: 12, status: "active" },
366
+ ];
367
+ h.connection.openBox = async () => ({ uidvalidity: 77 });
368
+ h.mailbox = { mailboxId: "trash-mbx", uidValidity: 77 };
369
+
370
+ await handleEmptyTrash(event, noopLog, deps());
371
+
372
+ assert.deepEqual(revertedMessageIds(), ["msg-1"]);
373
+ assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-1"]);
374
+ });
375
+
376
+ it("reverts the marks when openBox trips a UIDVALIDITY mismatch", async () => {
377
+ // The event is acked and nothing re-issues it, so leaving the folder
378
+ // marked `deleting` hides healthy mail until the user notices.
210
379
  h.connection.openBox = async () => ({ uidvalidity: 999 });
211
380
 
212
381
  await handleEmptyTrash(event, noopLog, deps());
@@ -217,9 +386,24 @@ describe("handleEmptyTrash", () => {
217
386
  "cursor_invalid",
218
387
  );
219
388
  assert.equal(called("message.delete").length, 0);
389
+ assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
220
390
  assert.equal(h.disconnectCount, 1);
221
391
  });
222
392
 
393
+ it("reverts the marks without connecting when the cursor is rebuilding", async () => {
394
+ h.mailbox = {
395
+ mailboxId: "trash-mbx",
396
+ uidValidity: 1,
397
+ cursorState: "rebuilding",
398
+ };
399
+
400
+ await handleEmptyTrash(event, noopLog, deps());
401
+
402
+ assert.equal(h.getConnectionCount, 0);
403
+ assert.equal(called("message.delete").length, 0);
404
+ assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
405
+ });
406
+
223
407
  it("rethrows an unclassified IMAP error so the event is retried", async () => {
224
408
  h.connection.search = async () => {
225
409
  throw new Error("server exploded");
@@ -1,4 +1,8 @@
1
1
  import { getClient } from "@remit/backend/client";
2
+ import type { MessageItem } from "@remit/data-ports";
3
+ import { trashMailboxAt } from "@remit/data-ports/folder-role";
4
+ import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
5
+ import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
2
6
  import type { Logger } from "@remit/logger-lambda";
3
7
  import {
4
8
  guardConnectionCursor,
@@ -11,6 +15,7 @@ import type { EmptyTrashEvent } from "../events.js";
11
15
  import { isNotFoundError } from "../is-not-found.js";
12
16
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
13
17
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
18
+ import { buildThreadMessageUndelete } from "./message-delete.js";
14
19
 
15
20
  export interface EmptyTrashDeps {
16
21
  getClient: typeof getClient;
@@ -29,6 +34,13 @@ const defaultDeps: EmptyTrashDeps = {
29
34
  /**
30
35
  * Handle EMPTY_TRASH events.
31
36
  * Permanently deletes all messages in the Trash mailbox.
37
+ *
38
+ * The only unrecoverable mutation reader issues, so the folder's identity is
39
+ * confirmed here, on the connection that does the expunging: the role still
40
+ * names this mailbox on confirmed evidence, and the UIDVALIDITY the SELECT
41
+ * serves still matches the one the user consented to. Every refusal reverts
42
+ * the optimistic local marks and acks — a throw would stall the account's FIFO
43
+ * group behind an event no retry can fix (issues #287, #289, #290).
32
44
  */
33
45
  export const handleEmptyTrash = async (
34
46
  event: EmptyTrashEvent,
@@ -47,6 +59,7 @@ export const handleEmptyTrash = async (
47
59
  message: messageService,
48
60
  threadMessage: threadMessageService,
49
61
  mailbox: mailboxService,
62
+ mailboxSpecialUse: mailboxSpecialUseService,
50
63
  secrets,
51
64
  } = await getClient();
52
65
 
@@ -66,6 +79,92 @@ export const handleEmptyTrash = async (
66
79
  return;
67
80
  }
68
81
 
82
+ // `emptyTrash` marks every row in the folder `deleting` + `isDeleted` before
83
+ // enqueueing, so any row this expunge does not carry through is hidden from
84
+ // every listing with nothing else to unhide it: no sync clears it, a repeat
85
+ // Empty Trash skips it again, and listings filter `isDeleted`. Handing it
86
+ // back is right whichever way it got left behind — mail already gone from
87
+ // the server becomes visible and the next cursor rebuild reconciles it, and
88
+ // an unsettled move is rewritten by its own MESSAGE_DELETE. Not `failed`:
89
+ // the mail is intact, and saying otherwise about a whole folder is a lie.
90
+ const handBackMarkedRows = async (
91
+ rows: readonly { messageId: string; status: MessageItem["status"] }[],
92
+ ): Promise<number> => {
93
+ let revertedCount = 0;
94
+ for (const message of rows) {
95
+ if (message.status !== MessageStatus.deleting) continue;
96
+
97
+ // Empty Trash only flips `isDeleted` on the listing rows; a permanent
98
+ // delete removes them up front. So a `deleting` row with no listing row
99
+ // was marked by some other in-flight operation, and reverting the
100
+ // Message under it would resurrect what that one is about to remove.
101
+ const threadMessages = await threadMessageService.findAllByMessageId(
102
+ account.accountConfigId,
103
+ message.messageId,
104
+ );
105
+ if (threadMessages.length === 0) continue;
106
+
107
+ await messageService.update(message.messageId, {
108
+ status: MessageStatus.active,
109
+ syncStatus: MessageSyncStatus.synced,
110
+ });
111
+ for (const threadMessage of threadMessages) {
112
+ const args = buildThreadMessageUndelete(threadMessage);
113
+ await threadMessageService.update(
114
+ threadMessage.accountConfigId,
115
+ threadMessage.threadMessageId,
116
+ args.set,
117
+ { composites: args.composites },
118
+ );
119
+ }
120
+ revertedCount += 1;
121
+ }
122
+ return revertedCount;
123
+ };
124
+
125
+ const abandonEmptyTrash = async (
126
+ reason: string,
127
+ alert: string,
128
+ context: Record<string, unknown> = {},
129
+ ): Promise<void> => {
130
+ log.error(
131
+ { alert, accountId, trashMailboxId, trashMailboxPath, ...context },
132
+ reason,
133
+ );
134
+ await handBackMarkedRows(
135
+ await messageService.listAllByMailbox(trashMailboxId),
136
+ );
137
+ };
138
+
139
+ if (!isCurrentSchemaVersion(event.schemaVersion)) {
140
+ await abandonEmptyTrash(
141
+ "Refused to empty trash: event was minted under an unknown contract",
142
+ "empty_trash_unknown_schema_version",
143
+ { schemaVersion: event.schemaVersion },
144
+ );
145
+ return;
146
+ }
147
+
148
+ // The role can be re-appointed between consent and this run, and a queued
149
+ // event then expunges the folder the user just stopped using as Trash.
150
+ // Confirmed evidence only: what the user appointed, or what the server
151
+ // flagged.
152
+ const resolution = await mailboxSpecialUseService.resolveTrashRole(accountId);
153
+ const trashGate = trashMailboxAt(resolution, "confirmed");
154
+ if (!trashGate.allowed || trashGate.mailbox.mailboxId !== trashMailboxId) {
155
+ await abandonEmptyTrash(
156
+ "Refused to empty trash: this account's Trash is no longer that folder",
157
+ "empty_trash_role_moved",
158
+ {
159
+ resolvedKind: resolution.kind,
160
+ resolvedMailboxId: trashGate.allowed
161
+ ? trashGate.mailbox.mailboxId
162
+ : undefined,
163
+ },
164
+ );
165
+ return;
166
+ }
167
+
69
168
  await withOAuthLifecycle(
70
169
  buildLifecycleDeps(secrets, accountService),
71
170
  account,
@@ -90,13 +189,14 @@ export const handleEmptyTrash = async (
90
189
  return;
91
190
  }
92
191
 
93
- // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
94
- // paused never even opens a connection. Optimization only the
95
- // guardConnectionCursor openBox wrap below is the structural guarantee.
192
+ // A paused cursor is not a wait here: this return acks the event and
193
+ // nothing re-issues it, so the marks have to come back or the folder
194
+ // stays hidden forever. The user retries.
96
195
  if (isCursorRebuildNeeded(mailbox.cursorState)) {
97
- log.info(
98
- { accountId, mailboxId: trashMailboxId },
99
- "Mailbox cursor not normal; pausing empty-trash this round",
196
+ await abandonEmptyTrash(
197
+ "Abandoned empty trash: mailbox cursor is not normal",
198
+ "empty_trash_cursor_paused",
199
+ { cursorState: mailbox.cursorState },
100
200
  );
101
201
  return;
102
202
  }
@@ -108,21 +208,34 @@ export const handleEmptyTrash = async (
108
208
  .then(async (rawConnection) => {
109
209
  // Guard at the openBox choke point (epic #1281 invariants 3 & 5):
110
210
  // a fresh mismatch trips the mailbox and throws once the SELECT
111
- // reveals it. Local rows stay marked for deletion and are picked
112
- // up once the mailbox returns to normal.
211
+ // reveals it.
113
212
  const connection = guardConnectionCursor(
114
213
  rawConnection,
115
214
  { mailboxService },
116
215
  accountId,
117
216
  mailbox,
118
217
  );
119
- await connection.openBox(trashMailboxPath, false);
218
+ const boxStatus = await connection.openBox(trashMailboxPath, false);
219
+
220
+ // The path is not the folder. A third-party client that renames
221
+ // Trash and creates a fresh one leaves this event pointing at a
222
+ // path now served by a folder the user never consented to empty;
223
+ // UIDVALIDITY is what tells them apart (RFC 9051 2.3.1.1).
224
+ if (boxStatus.uidvalidity !== event.trashUidValidity) {
225
+ await abandonEmptyTrash(
226
+ "Refused to empty trash: the folder at this path is not the one the user emptied",
227
+ "empty_trash_uidvalidity_mismatch",
228
+ {
229
+ servedUidValidity: boxStatus.uidvalidity,
230
+ consentedUidValidity: event.trashUidValidity,
231
+ },
232
+ );
233
+ return;
234
+ }
120
235
 
121
- // Search for all messages in Trash
122
236
  const uids = await connection.search(["ALL"]);
123
237
 
124
238
  if (uids.length > 0) {
125
- // Delete all messages on IMAP
126
239
  await connection.deleteMessages(uids);
127
240
  log.info(
128
241
  { count: uids.length },
@@ -130,15 +243,19 @@ export const handleEmptyTrash = async (
130
243
  );
131
244
  }
132
245
 
133
- // Delete all local messages in trash
246
+ // Local cleanup follows the expunge, uid by uid. What was expunged
247
+ // is a fact this connection observed, and only those rows go.
248
+ const expunged = new Set(uids);
134
249
  const localMessages =
135
250
  await messageService.listAllByMailbox(trashMailboxId);
251
+ let deletedCount = 0;
136
252
 
137
253
  for (const message of localMessages) {
138
- // Delete the Message entity
254
+ if (!expunged.has(message.uid)) continue;
255
+ deletedCount += 1;
256
+
139
257
  await messageService.delete(message.messageId);
140
258
 
141
- // Delete the ThreadMessage entity
142
259
  const threadMessage = await threadMessageService.findByMessageId(
143
260
  account.accountConfigId,
144
261
  message.messageId,
@@ -151,20 +268,25 @@ export const handleEmptyTrash = async (
151
268
  }
152
269
  }
153
270
 
271
+ // Everything the SEARCH did not name is still marked for a deletion
272
+ // that will never come: mail another client already emptied, mail
273
+ // that reached Trash after the SEARCH, or a redelivery finishing a
274
+ // partial sweep. All three must come back rather than sit invisible.
275
+ const revertedCount = await handBackMarkedRows(
276
+ localMessages.filter((message) => !expunged.has(message.uid)),
277
+ );
278
+
154
279
  log.info(
155
- { accountId, deletedCount: localMessages.length },
280
+ { accountId, deletedCount, revertedCount },
156
281
  "Trash emptied successfully",
157
282
  );
158
283
  })
159
- .catch((error: unknown) => {
284
+ .catch(async (error: unknown) => {
160
285
  if (error instanceof MailboxCursorPausedError) {
161
- log.info(
162
- {
163
- accountId,
164
- mailboxId: trashMailboxId,
165
- cursorState: error.state,
166
- },
167
- "Mailbox cursor not normal; pausing empty-trash this round",
286
+ await abandonEmptyTrash(
287
+ "Abandoned empty trash: mailbox cursor is not normal",
288
+ "empty_trash_cursor_paused",
289
+ { cursorState: error.state },
168
290
  );
169
291
  return;
170
292
  }
@@ -5,6 +5,7 @@ import type { Logger } from "@remit/logger-lambda";
5
5
  import type { MessageDeleteEvent } from "../events.js";
6
6
  import {
7
7
  buildThreadMessageTrashUpdate,
8
+ buildThreadMessageUndelete,
8
9
  deleteAllThreadMessagesForMessage,
9
10
  handleMessageDelete,
10
11
  type MessageDeleteDeps,
@@ -113,6 +114,45 @@ describe("buildThreadMessageTrashUpdate", () => {
113
114
  });
114
115
  });
115
116
 
117
+ describe("buildThreadMessageUndelete", () => {
118
+ // Every `set` payload here is hand-written, so the three builders can drift
119
+ // apart silently. Undelete is the exact inverse of the trash update's
120
+ // deletion mark, and it must move nothing else: the mail is still in Trash
121
+ // at the uid the row already carries.
122
+
123
+ it("clears only the deletion mark that buildThreadMessageTrashUpdate set", () => {
124
+ const trashed = buildThreadMessageTrashUpdate(
125
+ baseThreadMessage,
126
+ 42,
127
+ trashMailboxId,
128
+ );
129
+ const undelete = buildThreadMessageUndelete(baseThreadMessage);
130
+
131
+ assert.strictEqual(trashed.set.isDeleted, true);
132
+ assert.strictEqual(undelete.set.isDeleted, false);
133
+ assert.deepStrictEqual(
134
+ Object.keys(undelete.set),
135
+ ["isDeleted"],
136
+ "undelete must not move the row's uid or mailbox",
137
+ );
138
+ });
139
+
140
+ it("carries the CURRENT row state in composites, like every other builder", () => {
141
+ const undelete = buildThreadMessageUndelete(baseThreadMessage);
142
+
143
+ assert.deepStrictEqual(
144
+ undelete.composites,
145
+ buildThreadMessageTrashUpdate(baseThreadMessage, 42, trashMailboxId)
146
+ .composites,
147
+ );
148
+ assert.strictEqual(
149
+ undelete.composites.isDeleted,
150
+ baseThreadMessage.isDeleted,
151
+ "composites hold the state to check against, never the new value",
152
+ );
153
+ });
154
+ });
155
+
116
156
  describe("deleteAllThreadMessagesForMessage (#212)", () => {
117
157
  // Regression for the multi-mailbox cleanup gap in #212. A single Message
118
158
  // can have ThreadMessage rows in multiple mailboxes (e.g. INBOX + a label
@@ -205,6 +245,8 @@ interface Connection {
205
245
  ) => Promise<{ uidMap: Map<number, number> }>;
206
246
  deleteMessages: (uids: number[]) => Promise<void>;
207
247
  createMailbox: (path: string) => Promise<void>;
248
+ fetchMessages: (uids: number[]) => Promise<{ uid: number }[]>;
249
+ search: (criteria: unknown[]) => Promise<number[]>;
208
250
  }
209
251
 
210
252
  interface Harness {
@@ -217,8 +259,9 @@ interface Harness {
217
259
  mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
218
260
  mailboxError?: Error;
219
261
  connection: Connection;
262
+ threadMessageUpdateError?: Error;
220
263
  threadMessage: Record<string, unknown> | null;
221
- allThreadMessages: { accountConfigId: string; threadMessageId: string }[];
264
+ allThreadMessages: Record<string, unknown>[];
222
265
  getConnectionCount: number;
223
266
  disconnectCount: number;
224
267
  }
@@ -240,8 +283,23 @@ const buildConnection = (): Connection => ({
240
283
  createMailbox: record(
241
284
  "connection.createMailbox",
242
285
  ) as Connection["createMailbox"],
286
+ // The source box still holds the uid unless a case says otherwise, so the
287
+ // presence probe only reports "gone" where a test made it true.
288
+ fetchMessages: async (uids: number[]) => uids.map((uid) => ({ uid })),
289
+ search: async (...args: unknown[]) => {
290
+ h.calls.push({ method: "connection.search", args });
291
+ return [10];
292
+ },
243
293
  });
244
294
 
295
+ const sourceNoLongerHoldsTheUid = (): void => {
296
+ h.connection.fetchMessages = async () => [];
297
+ h.connection.search = async (...args: unknown[]) => {
298
+ h.calls.push({ method: "connection.search", args });
299
+ return [];
300
+ };
301
+ };
302
+
245
303
  const fresh = (): Harness => ({
246
304
  calls: [],
247
305
  account: { accountId: "acc-1", accountConfigId: "cfg-1" },
@@ -253,8 +311,8 @@ const fresh = (): Harness => ({
253
311
  threadMessageId: "tm-1",
254
312
  },
255
313
  allThreadMessages: [
256
- { accountConfigId: "cfg-1", threadMessageId: "tm-1" },
257
- { accountConfigId: "cfg-1", threadMessageId: "tm-2" },
314
+ { ...baseThreadMessage, accountConfigId: "cfg-1", threadMessageId: "tm-1" },
315
+ { ...baseThreadMessage, accountConfigId: "cfg-1", threadMessageId: "tm-2" },
258
316
  ],
259
317
  getConnectionCount: 0,
260
318
  disconnectCount: 0,
@@ -277,7 +335,10 @@ const deps = (): MessageDeleteDeps =>
277
335
  threadMessage: {
278
336
  findByMessageId: async () => h.threadMessage,
279
337
  findAllByMessageId: async () => h.allThreadMessages,
280
- update: record("threadMessage.update"),
338
+ update: async (...args: unknown[]) => {
339
+ h.calls.push({ method: "threadMessage.update", args });
340
+ if (h.threadMessageUpdateError) throw h.threadMessageUpdateError;
341
+ },
281
342
  delete: record("threadMessage.delete"),
282
343
  },
283
344
  mailbox: {
@@ -309,6 +370,7 @@ const deps = (): MessageDeleteDeps =>
309
370
 
310
371
  const moveEvent: MessageDeleteEvent = {
311
372
  type: "MESSAGE_DELETE",
373
+ schemaVersion: 2,
312
374
  accountId: "acc-1",
313
375
  messageId: "msg-1",
314
376
  mailboxId: "src-mbx",
@@ -321,6 +383,7 @@ const moveEvent: MessageDeleteEvent = {
321
383
 
322
384
  const permanentEvent: MessageDeleteEvent = {
323
385
  type: "MESSAGE_DELETE",
386
+ schemaVersion: 2,
324
387
  accountId: "acc-1",
325
388
  messageId: "msg-1",
326
389
  mailboxId: "src-mbx",
@@ -440,10 +503,13 @@ describe("handleMessageDelete", () => {
440
503
  );
441
504
  });
442
505
 
443
- it("cleans up locally and swallows the error when the message is already gone on IMAP", async () => {
506
+ // Regression on #212: a permanent delete the server has already applied must
507
+ // still reconcile the local rows, now that the text alone no longer decides.
508
+ it("cleans up locally and swallows the error when the message is confirmed gone on IMAP", async () => {
444
509
  h.connection.deleteMessages = async () => {
445
510
  throw new Error("NONEXISTENT uid");
446
511
  };
512
+ sourceNoLongerHoldsTheUid();
447
513
 
448
514
  await handleMessageDelete(permanentEvent, noopLog, deps());
449
515
 
@@ -451,18 +517,192 @@ describe("handleMessageDelete", () => {
451
517
  assert.equal(called("threadMessage.delete").length, 2);
452
518
  });
453
519
 
454
- it("creates the trash mailbox and rethrows on TRYCREATE", async () => {
520
+ // Issue #845. The three cases below are the same bug from three directions:
521
+ // "not found" in an error string is never on its own a reason to destroy the
522
+ // only local record of live mail.
523
+
524
+ it("keeps the rows when a move-to-trash fails after the MOVE landed", async () => {
525
+ // The MOVE succeeded; the ThreadMessage write behind it did not. The
526
+ // message is sitting in Trash, and the NotFoundError names a local row,
527
+ // not a mail-server one.
528
+ h.threadMessageUpdateError = Object.assign(
529
+ new Error("Thread message not found"),
530
+ { name: "NotFoundError" },
531
+ );
532
+ sourceNoLongerHoldsTheUid();
533
+
534
+ await assert.rejects(
535
+ handleMessageDelete(moveEvent, noopLog, deps()),
536
+ /not found/,
537
+ );
538
+
539
+ assert.equal(
540
+ called("message.delete").length,
541
+ 0,
542
+ "the message is in Trash; deleting its row loses the only handle on it",
543
+ );
544
+ assert.equal(called("threadMessage.delete").length, 0);
545
+ assert.equal(
546
+ called("connection.search").length,
547
+ 0,
548
+ "a move-to-trash never probes: absence from the source is its success signature",
549
+ );
550
+ assert.equal(
551
+ (called("message.update")[0]?.args[1] as { syncStatus?: string })
552
+ ?.syncStatus,
553
+ "failed",
554
+ );
555
+ });
556
+
557
+ it("keeps the rows on a NONEXISTENT move-to-trash whose source still holds the uid", async () => {
455
558
  h.connection.moveMessages = async () => {
456
- throw new Error("TRYCREATE: no such mailbox");
559
+ throw new Error("NONEXISTENT: mailbox does not exist");
457
560
  };
458
561
 
459
562
  await assert.rejects(
460
563
  handleMessageDelete(moveEvent, noopLog, deps()),
461
- /TRYCREATE/,
564
+ /NONEXISTENT/,
565
+ );
566
+
567
+ assert.equal(called("message.delete").length, 0);
568
+ assert.equal(called("threadMessage.delete").length, 0);
569
+ assert.equal(
570
+ (called("message.update")[0]?.args[1] as { syncStatus?: string })
571
+ ?.syncStatus,
572
+ "failed",
573
+ );
574
+ });
575
+
576
+ it("keeps the rows when a permanent delete's source still holds the uid", async () => {
577
+ h.connection.deleteMessages = async () => {
578
+ throw new Error("NONEXISTENT uid");
579
+ };
580
+ // imapflow drops rows on back-to-back FETCHes (#408); the SEARCH is what
581
+ // the verdict rests on, and it still lists the uid.
582
+ h.connection.fetchMessages = async () => [];
583
+
584
+ await assert.rejects(
585
+ handleMessageDelete(permanentEvent, noopLog, deps()),
586
+ /NONEXISTENT/,
587
+ );
588
+
589
+ assert.equal(
590
+ called("connection.search").length,
591
+ 1,
592
+ "the error text only selects a candidate; the source box decides",
593
+ );
594
+ assert.equal(called("message.delete").length, 0);
595
+ assert.equal(called("threadMessage.delete").length, 0);
596
+ });
597
+
598
+ it("keeps the rows and rethrows the original error when the probe cannot answer", async () => {
599
+ // The SELECT is what failed, so there is no open box left for the probe
600
+ // to ask. The IMAP error must still be the one that rejects, and the row
601
+ // must not be stranded mid-delete for the record to carry to the DLQ.
602
+ h.connection.openBox = async () => {
603
+ throw new Error("NONEXISTENT mailbox does not exist");
604
+ };
605
+ h.connection.fetchMessages = async () => {
606
+ throw new Error("No mailbox selected");
607
+ };
608
+
609
+ await assert.rejects(
610
+ handleMessageDelete(permanentEvent, noopLog, deps()),
611
+ /NONEXISTENT mailbox does not exist/,
462
612
  );
463
613
 
464
- assert.equal(called("connection.createMailbox")[0]?.args[0], "Trash");
465
- assert.equal(h.getConnectionCount, 2, "reconnects to create the mailbox");
614
+ assert.equal(called("message.delete").length, 0);
615
+ assert.equal(called("threadMessage.delete").length, 0);
616
+ assert.equal(
617
+ (called("message.update")[0]?.args[1] as { syncStatus?: string })
618
+ ?.syncStatus,
619
+ "failed",
620
+ );
621
+ });
622
+
623
+ it("abandons rather than creating the destination on TRYCREATE", async () => {
624
+ // Creating it resurrects an empty `Trash` beside the real one, and the
625
+ // name-hint rule then has two folders to choose between. The picker
626
+ // offers the ones the account actually has.
627
+ h.connection.moveMessages = async () => {
628
+ throw new Error("TRYCREATE: no such mailbox");
629
+ };
630
+
631
+ await handleMessageDelete(moveEvent, noopLog, deps());
632
+
633
+ assert.equal(called("connection.createMailbox").length, 0);
634
+ assert.deepEqual(called("message.updateUid")[0]?.args, [
635
+ "msg-1",
636
+ 10,
637
+ "src-mbx",
638
+ ]);
639
+ assert.deepEqual(called("message.update")[0]?.args[1], {
640
+ status: "active",
641
+ syncStatus: "failed",
642
+ });
643
+ assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
644
+ uid: 10,
645
+ mailboxId: "src-mbx",
646
+ isDeleted: false,
647
+ });
648
+ });
649
+
650
+ it("abandons an event minted under an unknown contract, before connecting", async () => {
651
+ const unversioned = {
652
+ ...moveEvent,
653
+ schemaVersion: undefined,
654
+ } as unknown as MessageDeleteEvent;
655
+
656
+ await handleMessageDelete(unversioned, noopLog, deps());
657
+
658
+ assert.equal(h.getConnectionCount, 0);
659
+ assert.equal(called("connection.deleteMessages").length, 0);
660
+ assert.deepEqual(called("message.updateUid")[0]?.args, [
661
+ "msg-1",
662
+ 10,
663
+ "src-mbx",
664
+ ]);
665
+ assert.deepEqual(called("threadMessage.update")[0]?.args[2], {
666
+ uid: 10,
667
+ mailboxId: "src-mbx",
668
+ isDeleted: false,
669
+ });
670
+ });
671
+
672
+ it("hands back every listing row a message has, not just the first", async () => {
673
+ const unversioned = {
674
+ ...moveEvent,
675
+ schemaVersion: undefined,
676
+ } as unknown as MessageDeleteEvent;
677
+
678
+ await handleMessageDelete(unversioned, noopLog, deps());
679
+
680
+ assert.deepEqual(
681
+ called("threadMessage.update").map((c) => c.args[1]),
682
+ ["tm-1", "tm-2"],
683
+ );
684
+ });
685
+
686
+ it("finishes the removal when an abandoned delete has no listing rows left", async () => {
687
+ // A permanent delete removes them before it enqueues, and they cannot be
688
+ // rebuilt here. Restoring the Message alone leaves mail nothing can list,
689
+ // which is worse than either consistent state; the server copy survives
690
+ // and a full sync brings it back.
691
+ h.allThreadMessages = [];
692
+ const unversioned = {
693
+ ...permanentEvent,
694
+ schemaVersion: undefined,
695
+ } as unknown as MessageDeleteEvent;
696
+
697
+ await handleMessageDelete(unversioned, noopLog, deps());
698
+
699
+ assert.equal(called("connection.deleteMessages").length, 0);
700
+ assert.deepEqual(called("message.delete")[0]?.args, ["msg-1"]);
701
+ assert.equal(
702
+ called("message.update").length,
703
+ 0,
704
+ "never leave a Message row no listing can reach",
705
+ );
466
706
  });
467
707
 
468
708
  it("marks failed and rethrows on an unclassified IMAP error", async () => {
@@ -3,11 +3,13 @@ import type {
3
3
  IThreadMessageRepository,
4
4
  ThreadMessageItem,
5
5
  } from "@remit/data-ports";
6
+ import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
6
7
  import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
7
8
  import type { Logger } from "@remit/logger-lambda";
8
9
  import {
9
10
  guardConnectionCursor,
10
11
  isCursorRebuildNeeded,
12
+ isMessageGoneFromOpenMailbox,
11
13
  MailboxCursorPausedError,
12
14
  } from "@remit/mailbox-service";
13
15
  import { isAccountDeleted } from "../account-check.js";
@@ -108,6 +110,18 @@ export const buildThreadMessageMoveRevert = (
108
110
  composites: currentComposites(threadMessage),
109
111
  });
110
112
 
113
+ /**
114
+ * Hand a row back after an abandoned expunge. The mail never left Trash, so
115
+ * only the deletion mark reverts — the uid and mailbox on the row are still
116
+ * where the server has it.
117
+ */
118
+ export const buildThreadMessageUndelete = (
119
+ threadMessage: ThreadMessageRowState,
120
+ ) => ({
121
+ set: { isDeleted: false },
122
+ composites: currentComposites(threadMessage),
123
+ });
124
+
111
125
  export interface MessageDeleteDeps {
112
126
  getClient: typeof getClient;
113
127
  buildLifecycleDeps: typeof buildLifecycleDeps;
@@ -171,6 +185,75 @@ export const handleMessageDelete = async (
171
185
  return;
172
186
  }
173
187
 
188
+ // Only an operation that explicitly says so destroys mail. The event is
189
+ // `JSON.parse`d and cast in the queue handler with no validation, so a
190
+ // missing, misspelled or future field must abandon the delete — the
191
+ // "anything that is not move_to_trash is an expunge" inference is the same
192
+ // one that destroyed mail in the service, and an unrecoverable EXPUNGE is
193
+ // not a default. Abandoning hands the row back where the server still has
194
+ // it: an invisible `failed` on a row the user cannot see is the shape of
195
+ // the incident this whole change is about.
196
+ const abandonDelete = async (
197
+ reason: string,
198
+ alert: string,
199
+ ): Promise<void> => {
200
+ log.error(
201
+ { alert, accountId, messageId, uid, mailboxPath, operation },
202
+ reason,
203
+ );
204
+
205
+ const threadMessages = await threadMessageService.findAllByMessageId(
206
+ account.accountConfigId,
207
+ messageId,
208
+ );
209
+
210
+ // A permanent delete removes the listing rows before it enqueues, and
211
+ // they cannot be rebuilt from here — the row is denormalized off an
212
+ // envelope only the sync path shapes. Restoring the Message alone would
213
+ // leave mail nothing can list, which is the silent vanish rather than a
214
+ // visible failure, so the local removal finishes instead. The server copy
215
+ // survives (nothing was expunged) and a full sync of the mailbox brings
216
+ // it back. Reachable only through the rollout window, where a v1 event
217
+ // carries no `schemaVersion`.
218
+ if (threadMessages.length === 0) {
219
+ log.error(
220
+ {
221
+ alert: "message_delete_abandoned_after_local_cleanup",
222
+ accountId,
223
+ messageId,
224
+ uid,
225
+ mailboxPath,
226
+ },
227
+ "Abandoned delete had no listing rows left to restore; the server copy was not expunged",
228
+ );
229
+ await messageService.delete(messageId);
230
+ return;
231
+ }
232
+
233
+ await messageService.updateUid(messageId, uid, mailboxId);
234
+ await messageService.update(messageId, {
235
+ status: MessageStatus.active,
236
+ syncStatus: MessageSyncStatus.failed,
237
+ });
238
+ for (const threadMessage of threadMessages) {
239
+ const args = buildThreadMessageMoveRevert(threadMessage, uid, mailboxId);
240
+ await threadMessageService.update(
241
+ threadMessage.accountConfigId,
242
+ threadMessage.threadMessageId,
243
+ args.set,
244
+ { composites: args.composites },
245
+ );
246
+ }
247
+ };
248
+
249
+ if (!isCurrentSchemaVersion(event.schemaVersion)) {
250
+ await abandonDelete(
251
+ "Refused to delete: event was minted under an unknown contract",
252
+ "message_delete_unknown_schema_version",
253
+ );
254
+ return;
255
+ }
256
+
174
257
  await withOAuthLifecycle(
175
258
  buildLifecycleDeps(secrets, accountService),
176
259
  account,
@@ -222,43 +305,6 @@ export const handleMessageDelete = async (
222
305
  );
223
306
  await connection.openBox(mailboxPath, false);
224
307
 
225
- // Only an operation that explicitly says so destroys mail. The
226
- // event is `JSON.parse`d and cast in the queue handler with no
227
- // validation, so a missing, misspelled or future `operation` must
228
- // abandon the delete — the "anything that is not move_to_trash is
229
- // an expunge" inference is the same one that destroyed mail in the
230
- // service, and an unrecoverable EXPUNGE is not a default.
231
- const abandonDelete = async (
232
- reason: string,
233
- alert: string,
234
- ): Promise<void> => {
235
- log.error(
236
- { alert, accountId, messageId, uid, mailboxPath, operation },
237
- reason,
238
- );
239
- await messageService.updateUid(messageId, uid, mailboxId);
240
- await messageService.update(messageId, {
241
- status: MessageStatus.active,
242
- syncStatus: MessageSyncStatus.failed,
243
- });
244
- const threadMessage = await threadMessageService.findByMessageId(
245
- account.accountConfigId,
246
- messageId,
247
- );
248
- if (!threadMessage) return;
249
- const args = buildThreadMessageMoveRevert(
250
- threadMessage,
251
- uid,
252
- mailboxId,
253
- );
254
- await threadMessageService.update(
255
- threadMessage.accountConfigId,
256
- threadMessage.threadMessageId,
257
- args.set,
258
- { composites: args.composites },
259
- );
260
- };
261
-
262
308
  if (
263
309
  operation !== "move_to_trash" &&
264
310
  operation !== "permanent_delete"
@@ -359,14 +405,32 @@ export const handleMessageDelete = async (
359
405
  const errorMessage =
360
406
  error instanceof Error ? error.message : String(error);
361
407
 
362
- // Message not found on IMAP - already deleted (idempotent)
363
- if (
364
- errorMessage.includes("not found") ||
365
- errorMessage.includes("NONEXISTENT")
366
- ) {
408
+ // Reconcile a permanent delete the server already applied (#212).
409
+ // The error text only nominates a candidate — the source box
410
+ // decides, since a landed move-to-trash reports that same text from
411
+ // a LOCAL lookup (#845) — and a probe that cannot answer counts as
412
+ // not-confirmed, leaving the original error to the rethrow below.
413
+ const isGoneFromSource =
414
+ operation === "permanent_delete" &&
415
+ (errorMessage.includes("not found") ||
416
+ errorMessage.includes("NONEXISTENT")) &&
417
+ (await scope
418
+ .getConnection()
419
+ .then((connection) =>
420
+ isMessageGoneFromOpenMailbox(connection, uid),
421
+ )
422
+ .catch((probeError: unknown) => {
423
+ log.warn(
424
+ { messageId, uid, mailboxPath, probeError },
425
+ "Could not confirm whether the message is gone; keeping local rows",
426
+ );
427
+ return false;
428
+ }));
429
+
430
+ if (isGoneFromSource) {
367
431
  log.info(
368
432
  { messageId, uid },
369
- "Message not found on IMAP, cleaning up local",
433
+ "Message confirmed gone from IMAP, cleaning up local",
370
434
  );
371
435
  // Clean up local entities. Multi-mailbox copies are handled by the
372
436
  // helper so we don't leave orphan rows (see issue #212).
@@ -379,16 +443,16 @@ export const handleMessageDelete = async (
379
443
  return;
380
444
  }
381
445
 
382
- // TRYCREATE - Trash doesn't exist
446
+ // TRYCREATE the destination this event names is not on the
447
+ // server. Creating it would resurrect an empty `Trash` beside the
448
+ // real one and hand the name-hint rule two folders to choose
449
+ // between; the folder picker offers the ones that exist instead.
383
450
  if (errorMessage.includes("TRYCREATE") && destinationMailboxPath) {
384
- log.info(
385
- { destinationMailboxPath },
386
- "Trash mailbox doesn't exist, creating",
451
+ await abandonDelete(
452
+ "Refused to delete: the destination mailbox does not exist on the server",
453
+ "message_delete_destination_missing",
387
454
  );
388
- const connection = await scope.getConnection();
389
- await connection.createMailbox(destinationMailboxPath);
390
- // Re-throw to let the event be retried
391
- throw error;
455
+ return;
392
456
  }
393
457
 
394
458
  // Mark as failed for other errors