@remit/imap-worker 0.0.51 → 0.0.53

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.51",
3
+ "version": "0.0.53",
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
@@ -221,7 +261,7 @@ interface Harness {
221
261
  connection: Connection;
222
262
  threadMessageUpdateError?: Error;
223
263
  threadMessage: Record<string, unknown> | null;
224
- allThreadMessages: { accountConfigId: string; threadMessageId: string }[];
264
+ allThreadMessages: Record<string, unknown>[];
225
265
  getConnectionCount: number;
226
266
  disconnectCount: number;
227
267
  }
@@ -271,8 +311,8 @@ const fresh = (): Harness => ({
271
311
  threadMessageId: "tm-1",
272
312
  },
273
313
  allThreadMessages: [
274
- { accountConfigId: "cfg-1", threadMessageId: "tm-1" },
275
- { accountConfigId: "cfg-1", threadMessageId: "tm-2" },
314
+ { ...baseThreadMessage, accountConfigId: "cfg-1", threadMessageId: "tm-1" },
315
+ { ...baseThreadMessage, accountConfigId: "cfg-1", threadMessageId: "tm-2" },
276
316
  ],
277
317
  getConnectionCount: 0,
278
318
  disconnectCount: 0,
@@ -330,6 +370,7 @@ const deps = (): MessageDeleteDeps =>
330
370
 
331
371
  const moveEvent: MessageDeleteEvent = {
332
372
  type: "MESSAGE_DELETE",
373
+ schemaVersion: 2,
333
374
  accountId: "acc-1",
334
375
  messageId: "msg-1",
335
376
  mailboxId: "src-mbx",
@@ -342,6 +383,7 @@ const moveEvent: MessageDeleteEvent = {
342
383
 
343
384
  const permanentEvent: MessageDeleteEvent = {
344
385
  type: "MESSAGE_DELETE",
386
+ schemaVersion: 2,
345
387
  accountId: "acc-1",
346
388
  messageId: "msg-1",
347
389
  mailboxId: "src-mbx",
@@ -578,18 +620,89 @@ describe("handleMessageDelete", () => {
578
620
  );
579
621
  });
580
622
 
581
- it("creates the trash mailbox and rethrows on TRYCREATE", async () => {
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.
582
627
  h.connection.moveMessages = async () => {
583
628
  throw new Error("TRYCREATE: no such mailbox");
584
629
  };
585
630
 
586
- await assert.rejects(
587
- handleMessageDelete(moveEvent, noopLog, deps()),
588
- /TRYCREATE/,
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"],
589
683
  );
684
+ });
590
685
 
591
- assert.equal(called("connection.createMailbox")[0]?.args[0], "Trash");
592
- assert.equal(h.getConnectionCount, 2, "reconnects to create the mailbox");
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
+ );
593
706
  });
594
707
 
595
708
  it("marks failed and rethrows on an unclassified IMAP error", async () => {
@@ -3,6 +3,7 @@ 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 {
@@ -109,6 +110,18 @@ export const buildThreadMessageMoveRevert = (
109
110
  composites: currentComposites(threadMessage),
110
111
  });
111
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
+
112
125
  export interface MessageDeleteDeps {
113
126
  getClient: typeof getClient;
114
127
  buildLifecycleDeps: typeof buildLifecycleDeps;
@@ -172,6 +185,75 @@ export const handleMessageDelete = async (
172
185
  return;
173
186
  }
174
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
+
175
257
  await withOAuthLifecycle(
176
258
  buildLifecycleDeps(secrets, accountService),
177
259
  account,
@@ -223,43 +305,6 @@ export const handleMessageDelete = async (
223
305
  );
224
306
  await connection.openBox(mailboxPath, false);
225
307
 
226
- // Only an operation that explicitly says so destroys mail. The
227
- // event is `JSON.parse`d and cast in the queue handler with no
228
- // validation, so a missing, misspelled or future `operation` must
229
- // abandon the delete — the "anything that is not move_to_trash is
230
- // an expunge" inference is the same one that destroyed mail in the
231
- // service, and an unrecoverable EXPUNGE is not a default.
232
- const abandonDelete = async (
233
- reason: string,
234
- alert: string,
235
- ): Promise<void> => {
236
- log.error(
237
- { alert, accountId, messageId, uid, mailboxPath, operation },
238
- reason,
239
- );
240
- await messageService.updateUid(messageId, uid, mailboxId);
241
- await messageService.update(messageId, {
242
- status: MessageStatus.active,
243
- syncStatus: MessageSyncStatus.failed,
244
- });
245
- const threadMessage = await threadMessageService.findByMessageId(
246
- account.accountConfigId,
247
- messageId,
248
- );
249
- if (!threadMessage) return;
250
- const args = buildThreadMessageMoveRevert(
251
- threadMessage,
252
- uid,
253
- mailboxId,
254
- );
255
- await threadMessageService.update(
256
- threadMessage.accountConfigId,
257
- threadMessage.threadMessageId,
258
- args.set,
259
- { composites: args.composites },
260
- );
261
- };
262
-
263
308
  if (
264
309
  operation !== "move_to_trash" &&
265
310
  operation !== "permanent_delete"
@@ -398,16 +443,16 @@ export const handleMessageDelete = async (
398
443
  return;
399
444
  }
400
445
 
401
- // 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.
402
450
  if (errorMessage.includes("TRYCREATE") && destinationMailboxPath) {
403
- log.info(
404
- { destinationMailboxPath },
405
- "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",
406
454
  );
407
- const connection = await scope.getConnection();
408
- await connection.createMailbox(destinationMailboxPath);
409
- // Re-throw to let the event be retried
410
- throw error;
455
+ return;
411
456
  }
412
457
 
413
458
  // Mark as failed for other errors
@@ -3,6 +3,7 @@ import { afterEach, before, describe, it, mock } from "node:test";
3
3
  import { getClient, type RemitClient, setClient } from "@remit/backend/client";
4
4
  import type { AccountItem, ThreadMessageItem } from "@remit/data-ports";
5
5
  import type { Logger } from "@remit/logger-lambda";
6
+ import type { IImapConnection } from "@remit/mailbox-service";
6
7
  import type { MessageMoveEvent } from "../events.js";
7
8
  import {
8
9
  buildThreadMessageMoveUpdate,
@@ -11,6 +12,7 @@ import {
11
12
  handleMessageMove,
12
13
  MESSAGE_MOVE_MAX_ATTEMPTS,
13
14
  moveThenResync,
15
+ searchMailboxByMessageId,
14
16
  } from "./message-move.js";
15
17
 
16
18
  const silentLogger = (() => {
@@ -340,3 +342,85 @@ describe("handleMessageMove — the move's own pending state gates every attempt
340
342
  assert.equal(mailboxGet.mock.calls.length, 0);
341
343
  });
342
344
  });
345
+
346
+ describe("searchMailboxByMessageId — the probe that binds a move to a UID (#912)", () => {
347
+ const isMessageIdCriterion = (
348
+ criterion: unknown,
349
+ ): criterion is [string, string, string] =>
350
+ Array.isArray(criterion) &&
351
+ criterion.length === 3 &&
352
+ typeof criterion[0] === "string" &&
353
+ criterion[0].toUpperCase() === "HEADER" &&
354
+ typeof criterion[1] === "string" &&
355
+ criterion[1].toLowerCase() === "message-id" &&
356
+ typeof criterion[2] === "string";
357
+
358
+ const buildDestination = (
359
+ messages: Array<{ uid: number; messageIdHeader: string }>,
360
+ ): IImapConnection => {
361
+ const searchAll = () => messages.map((row) => row.uid);
362
+ return {
363
+ openBox: async () => ({}) as never,
364
+ search: async (criteria: unknown[]) => {
365
+ const criterion = criteria.find(isMessageIdCriterion);
366
+ if (criterion === undefined) return searchAll();
367
+ return messages
368
+ .filter((row) => row.messageIdHeader === criterion[2])
369
+ .map((row) => row.uid);
370
+ },
371
+ } as unknown as IImapConnection;
372
+ };
373
+
374
+ it("asks the folder by Message-ID rather than by an interpolated string", async () => {
375
+ const sent: unknown[][] = [];
376
+ const destination = {
377
+ openBox: async () => ({}) as never,
378
+ search: async (criteria: unknown[]) => {
379
+ sent.push(criteria);
380
+ return [];
381
+ },
382
+ } as unknown as IImapConnection;
383
+
384
+ await searchMailboxByMessageId(
385
+ destination,
386
+ "Archive",
387
+ '<a"b@example.com>\r\nUID 1',
388
+ );
389
+
390
+ assert.deepStrictEqual(sent, [
391
+ [["HEADER", "Message-ID", '<a"b@example.com>\r\nUID 1']],
392
+ ]);
393
+ });
394
+
395
+ it("answers null, not the lowest UID, when the folder holds no such message", async () => {
396
+ const destination = buildDestination([
397
+ { uid: 11, messageIdHeader: "<stranger-a@example.com>" },
398
+ { uid: 12, messageIdHeader: "<stranger-b@example.com>" },
399
+ ]);
400
+
401
+ assert.strictEqual(
402
+ await searchMailboxByMessageId(
403
+ destination,
404
+ "Archive",
405
+ "<moved@example.com>",
406
+ ),
407
+ null,
408
+ );
409
+ });
410
+
411
+ it("answers the UID of the message carrying that Message-ID", async () => {
412
+ const destination = buildDestination([
413
+ { uid: 11, messageIdHeader: "<stranger-a@example.com>" },
414
+ { uid: 12, messageIdHeader: "<moved@example.com>" },
415
+ ]);
416
+
417
+ assert.strictEqual(
418
+ await searchMailboxByMessageId(
419
+ destination,
420
+ "Archive",
421
+ "<moved@example.com>",
422
+ ),
423
+ 12,
424
+ );
425
+ });
426
+ });
@@ -78,7 +78,7 @@ export const searchMailboxByMessageId = async (
78
78
  ): Promise<number | null> => {
79
79
  await connection.openBox(mailboxPath, true);
80
80
  const uids = await connection.search([
81
- `HEADER Message-ID "${messageIdHeader}"`,
81
+ ["HEADER", "Message-ID", messageIdHeader],
82
82
  ]);
83
83
  return uids[0] ?? null;
84
84
  };