@remit/imap-worker 0.0.69 → 0.0.71

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.69",
3
+ "version": "0.0.71",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { beforeEach, describe, it } from "node:test";
3
- import type { Logger } from "@remit/logger-lambda";
3
+ import { noopLogger } from "@remit/logger-lambda/noop-logger";
4
4
  import type { AppendSentMessageEvent } from "../events.js";
5
5
  import {
6
6
  APPEND_SENT_MAX_ATTEMPTS,
@@ -8,16 +8,6 @@ import {
8
8
  handleAppendSentMessage,
9
9
  } from "./append-sent-message.js";
10
10
 
11
- const noopLog = {
12
- info: () => {},
13
- warn: () => {},
14
- error: () => {},
15
- debug: () => {},
16
- fatal: () => {},
17
- trace: () => {},
18
- child: () => noopLog,
19
- } as unknown as Logger;
20
-
21
11
  interface Call {
22
12
  method: string;
23
13
  args: unknown[];
@@ -175,7 +165,7 @@ describe("handleAppendSentMessage", () => {
175
165
  });
176
166
 
177
167
  it("appends a seen RFC822 copy to the Sent folder and drops the outbox row", async () => {
178
- await handleAppendSentMessage(event, noopLog, 1, deps());
168
+ await handleAppendSentMessage(event, noopLogger, 1, deps());
179
169
 
180
170
  const append = called("connection.append")[0];
181
171
  assert.equal(append?.args[0], "INBOX/Sent");
@@ -188,7 +178,7 @@ describe("handleAppendSentMessage", () => {
188
178
  });
189
179
 
190
180
  it("takes the draft's stored attachments with the row (#679)", async () => {
191
- await handleAppendSentMessage(event, noopLog, 1, deps());
181
+ await handleAppendSentMessage(event, noopLogger, 1, deps());
192
182
 
193
183
  // The row is the only reference to those objects. Sweeping has to happen
194
184
  // here too, not only on a discard, or a sent message leaves its files
@@ -201,7 +191,7 @@ describe("handleAppendSentMessage", () => {
201
191
  });
202
192
 
203
193
  it("builds the message from the outbox row's own headers", async () => {
204
- await handleAppendSentMessage(event, noopLog, 1, deps());
194
+ await handleAppendSentMessage(event, noopLogger, 1, deps());
205
195
 
206
196
  const raw = String(called("connection.append")[0]?.args[1] as Buffer);
207
197
  assert.match(raw, /^From: Alice <alice@example\.com>$/m);
@@ -216,7 +206,7 @@ describe("handleAppendSentMessage", () => {
216
206
  it("uses a bare address when the outbox row carries no display name", async () => {
217
207
  h.outbox = { ...h.outbox, fromName: undefined };
218
208
 
219
- await handleAppendSentMessage(event, noopLog, 1, deps());
209
+ await handleAppendSentMessage(event, noopLogger, 1, deps());
220
210
 
221
211
  const raw = String(called("connection.append")[0]?.args[1] as Buffer);
222
212
  assert.match(raw, /^From: alice@example\.com$/m);
@@ -225,7 +215,7 @@ describe("handleAppendSentMessage", () => {
225
215
  it("settles the row as unfiled when the account has no Sent folder at all", async () => {
226
216
  h.sentMailbox = null;
227
217
 
228
- await handleAppendSentMessage(event, noopLog, 1, deps());
218
+ await handleAppendSentMessage(event, noopLogger, 1, deps());
229
219
 
230
220
  assert.equal(called("connection.append").length, 0);
231
221
  assert.equal(called("outboxMessage.delete").length, 0);
@@ -243,7 +233,7 @@ describe("handleAppendSentMessage", () => {
243
233
  it("skips the append while the outbox row is not yet sent", async () => {
244
234
  h.outbox = { ...h.outbox, status: "pending" };
245
235
 
246
- await handleAppendSentMessage(event, noopLog, 1, deps());
236
+ await handleAppendSentMessage(event, noopLogger, 1, deps());
247
237
 
248
238
  assert.equal(called("connection.append").length, 0);
249
239
  assert.equal(called("outboxMessage.delete").length, 0);
@@ -252,7 +242,7 @@ describe("handleAppendSentMessage", () => {
252
242
  it("returns early without touching the outbox when the account is soft-deleted", async () => {
253
243
  h.account = { ...h.account, deletedAt: Date.now() };
254
244
 
255
- await handleAppendSentMessage(event, noopLog, 1, deps());
245
+ await handleAppendSentMessage(event, noopLogger, 1, deps());
256
246
 
257
247
  assert.equal(called("connection.append").length, 0);
258
248
  assert.equal(called("outboxMessage.delete").length, 0);
@@ -264,7 +254,7 @@ describe("handleAppendSentMessage", () => {
264
254
  };
265
255
 
266
256
  await assert.rejects(
267
- handleAppendSentMessage(event, noopLog, 1, deps()),
257
+ handleAppendSentMessage(event, noopLogger, 1, deps()),
268
258
  /server exploded/,
269
259
  );
270
260
 
@@ -282,7 +272,7 @@ describe("handleAppendSentMessage", () => {
282
272
 
283
273
  await handleAppendSentMessage(
284
274
  event,
285
- noopLog,
275
+ noopLogger,
286
276
  APPEND_SENT_MAX_ATTEMPTS,
287
277
  deps(),
288
278
  );
@@ -301,7 +291,7 @@ describe("handleAppendSentMessage", () => {
301
291
  it("settles the row as unfiled when a terminal auth failure acks the record", async () => {
302
292
  h.ackWithoutWork = true;
303
293
 
304
- await handleAppendSentMessage(event, noopLog, 1, deps());
294
+ await handleAppendSentMessage(event, noopLogger, 1, deps());
305
295
 
306
296
  const patch = called("outboxMessage.update")[0]?.args[2] as {
307
297
  status: string;
@@ -313,7 +303,7 @@ describe("handleAppendSentMessage", () => {
313
303
 
314
304
  it("leaves the row alone when the APPEND landed but the delete did not", async () => {
315
305
  await assert.rejects(
316
- handleAppendSentMessage(event, noopLog, 1, depsWithFailingDelete()),
306
+ handleAppendSentMessage(event, noopLogger, 1, depsWithFailingDelete()),
317
307
  /storage down/,
318
308
  );
319
309
 
@@ -325,7 +315,7 @@ describe("handleAppendSentMessage", () => {
325
315
  it("stops redelivering a landed APPEND at the budget instead of filing another copy", async () => {
326
316
  await handleAppendSentMessage(
327
317
  event,
328
- noopLog,
318
+ noopLogger,
329
319
  APPEND_SENT_MAX_ATTEMPTS,
330
320
  depsWithFailingDelete(),
331
321
  );
@@ -335,7 +325,7 @@ describe("handleAppendSentMessage", () => {
335
325
  });
336
326
 
337
327
  it("records the uid the APPEND produced before it deletes the row", async () => {
338
- await handleAppendSentMessage(event, noopLog, 1, deps());
328
+ await handleAppendSentMessage(event, noopLogger, 1, deps());
339
329
 
340
330
  // Order is the whole of it: a uid written after the delete is a uid a
341
331
  // redelivery never sees, and the redelivery is what files the second copy.
@@ -352,7 +342,7 @@ describe("handleAppendSentMessage", () => {
352
342
  // nothing, and "filed" still has to be told apart from "not filed".
353
343
  h.append = async () => ({ uid: 0, uidValidity: 0 });
354
344
 
355
- await handleAppendSentMessage(event, noopLog, 1, deps());
345
+ await handleAppendSentMessage(event, noopLogger, 1, deps());
356
346
 
357
347
  assert.deepEqual(patches(), [{ appendedUid: -1 }]);
358
348
  assert.equal(called("outboxMessage.delete").length, 1);
@@ -361,7 +351,7 @@ describe("handleAppendSentMessage", () => {
361
351
  it("files nothing a second time when a redelivery finds a recorded uid (#858)", async () => {
362
352
  h.outbox = { ...h.outbox, appendedUid: 55 };
363
353
 
364
- await handleAppendSentMessage(event, noopLog, 1, deps());
354
+ await handleAppendSentMessage(event, noopLogger, 1, deps());
365
355
 
366
356
  // The copy is already in the user's Sent folder. All this redelivery owes
367
357
  // is the delete the last attempt could not make.
@@ -382,7 +372,7 @@ describe("handleAppendSentMessage", () => {
382
372
  h.outbox = { ...h.outbox, appendedUid: 55 };
383
373
 
384
374
  await assert.rejects(
385
- handleAppendSentMessage(event, noopLog, 1, depsWithFailingDelete()),
375
+ handleAppendSentMessage(event, noopLogger, 1, depsWithFailingDelete()),
386
376
  /storage down/,
387
377
  );
388
378
 
@@ -392,7 +382,7 @@ describe("handleAppendSentMessage", () => {
392
382
 
393
383
  it("keeps the row when the uid cannot be recorded", async () => {
394
384
  await assert.rejects(
395
- handleAppendSentMessage(event, noopLog, 1, depsWithFailingUpdate()),
385
+ handleAppendSentMessage(event, noopLogger, 1, depsWithFailingUpdate()),
396
386
  /storage down/,
397
387
  );
398
388
 
@@ -425,7 +415,7 @@ describe("handleAppendSentMessage", () => {
425
415
  },
426
416
  };
427
417
 
428
- await handleAppendSentMessage(event, noopLog, 1, failing);
418
+ await handleAppendSentMessage(event, noopLogger, 1, failing);
429
419
 
430
420
  assert.equal(called("connection.append").length, 0);
431
421
  assert.deepEqual(patches(), []);
@@ -436,7 +426,7 @@ describe("handleAppendSentMessage", () => {
436
426
 
437
427
  await handleAppendSentMessage(
438
428
  event,
439
- noopLog,
429
+ noopLogger,
440
430
  APPEND_SENT_MAX_ATTEMPTS,
441
431
  depsWithFailingDelete(),
442
432
  );
@@ -6,21 +6,11 @@ import {
6
6
  S3Client,
7
7
  } from "@aws-sdk/client-s3";
8
8
  import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
9
- import type { Logger } from "@remit/logger-lambda";
9
+ import { noopLogger } from "@remit/logger-lambda/noop-logger";
10
10
  import { mockClient } from "aws-sdk-client-mock";
11
11
  import type { DeleteAccountObjectsEvent } from "../events.js";
12
12
  import { handleDeleteAccountObjects } from "./delete-account-objects.js";
13
13
 
14
- const noopLog = {
15
- info: () => {},
16
- warn: () => {},
17
- error: () => {},
18
- debug: () => {},
19
- fatal: () => {},
20
- trace: () => {},
21
- child: () => noopLog,
22
- } as unknown as Logger;
23
-
24
14
  const s3Mock = mockClient(S3Client);
25
15
  const sqsMock = mockClient(SQSClient);
26
16
 
@@ -45,7 +35,7 @@ describe("handleDeleteAccountObjects", () => {
45
35
  });
46
36
  s3Mock.on(DeleteObjectsCommand).resolves({});
47
37
 
48
- await handleDeleteAccountObjects(event, noopLog);
38
+ await handleDeleteAccountObjects(event, noopLogger);
49
39
 
50
40
  const listCall = s3Mock.commandCalls(ListObjectsV2Command)[0];
51
41
  assert.equal(listCall.args[0].input.Prefix, "accounts/cfg-1/");
@@ -72,7 +62,7 @@ describe("handleDeleteAccountObjects", () => {
72
62
  });
73
63
  s3Mock.on(DeleteObjectsCommand).resolves({});
74
64
 
75
- await handleDeleteAccountObjects(event, noopLog);
65
+ await handleDeleteAccountObjects(event, noopLogger);
76
66
 
77
67
  const listCalls = s3Mock.commandCalls(ListObjectsV2Command);
78
68
  assert.equal(listCalls.length, 2);
@@ -83,7 +73,7 @@ describe("handleDeleteAccountObjects", () => {
83
73
  it("skips the delete call when a page holds no keys", async () => {
84
74
  s3Mock.on(ListObjectsV2Command).resolves({ IsTruncated: false });
85
75
 
86
- await handleDeleteAccountObjects(event, noopLog);
76
+ await handleDeleteAccountObjects(event, noopLogger);
87
77
 
88
78
  assert.equal(s3Mock.commandCalls(DeleteObjectsCommand).length, 0);
89
79
  });
@@ -94,7 +84,7 @@ describe("handleDeleteAccountObjects", () => {
94
84
 
95
85
  await handleDeleteAccountObjects(
96
86
  { ...event, continuationToken: "tok-mid" },
97
- noopLog,
87
+ noopLogger,
98
88
  () => 5_000,
99
89
  );
100
90
 
@@ -119,7 +109,7 @@ describe("handleDeleteAccountObjects", () => {
119
109
  });
120
110
  s3Mock.on(DeleteObjectsCommand).resolves({});
121
111
 
122
- await handleDeleteAccountObjects(event, noopLog, () => 120_000);
112
+ await handleDeleteAccountObjects(event, noopLogger, () => 120_000);
123
113
 
124
114
  assert.equal(s3Mock.commandCalls(ListObjectsV2Command).length, 1);
125
115
  assert.equal(sqsMock.commandCalls(SendMessageCommand).length, 0);
@@ -1,20 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { beforeEach, describe, it } from "node:test";
3
3
  import type { RoleResolution } from "@remit/data-ports/folder-role";
4
- import type { Logger } from "@remit/logger-lambda";
4
+ import { noopLogger } from "@remit/logger-lambda/noop-logger";
5
5
  import type { EmptyTrashEvent } from "../events.js";
6
6
  import { type EmptyTrashDeps, handleEmptyTrash } from "./empty-trash.js";
7
7
 
8
- const noopLog = {
9
- info: () => {},
10
- warn: () => {},
11
- error: () => {},
12
- debug: () => {},
13
- fatal: () => {},
14
- trace: () => {},
15
- child: () => noopLog,
16
- } as unknown as Logger;
17
-
18
8
  interface Call {
19
9
  method: string;
20
10
  args: unknown[];
@@ -33,6 +23,10 @@ interface LocalMessage {
33
23
  messageId: string;
34
24
  uid: number;
35
25
  status: string;
26
+ syncStatus?: string;
27
+ mailboxId?: string;
28
+ originalMailboxId?: string;
29
+ originalUid?: number;
36
30
  }
37
31
 
38
32
  type TrashMailbox = { mailboxId: string; fullPath: string };
@@ -77,6 +71,29 @@ const deleting = (messageId: string, uid: number): LocalMessage => ({
77
71
  status: "deleting",
78
72
  });
79
73
 
74
+ /** Mid-move into Trash: the folder is written, the uid is still the source's. */
75
+ const movingIntoTrash = (messageId: string, uid: number): LocalMessage => ({
76
+ messageId,
77
+ uid,
78
+ status: "moving",
79
+ syncStatus: "pending",
80
+ mailboxId: "trash-mbx",
81
+ originalMailboxId: "inbox-mbx",
82
+ originalUid: uid,
83
+ });
84
+
85
+ /** The same row between a failed attempt and its redelivery. */
86
+ const retryingIntoTrash = (messageId: string, uid: number): LocalMessage => ({
87
+ ...movingIntoTrash(messageId, uid),
88
+ syncStatus: "failed",
89
+ });
90
+
91
+ /** The same row after an empty marked the folder, `status` overwritten. */
92
+ const markedMidMove = (messageId: string, uid: number): LocalMessage => ({
93
+ ...movingIntoTrash(messageId, uid),
94
+ status: "deleting",
95
+ });
96
+
80
97
  const fresh = (): Harness => ({
81
98
  calls: [],
82
99
  account: { accountId: "acc-1", accountConfigId: "cfg-1" },
@@ -197,7 +214,7 @@ describe("handleEmptyTrash", () => {
197
214
  });
198
215
 
199
216
  it("expunges every server uid and both local rows for each trashed message", async () => {
200
- await handleEmptyTrash(event, noopLog, deps());
217
+ await handleEmptyTrash(event, noopLogger, deps());
201
218
 
202
219
  assert.deepEqual(called("connection.deleteMessages")[0]?.args, [[10, 11]]);
203
220
  assert.deepEqual(
@@ -218,7 +235,7 @@ describe("handleEmptyTrash", () => {
218
235
  { messageId: "msg-late", uid: 12, status: "active" },
219
236
  ];
220
237
 
221
- await handleEmptyTrash(event, noopLog, deps());
238
+ await handleEmptyTrash(event, noopLogger, deps());
222
239
 
223
240
  assert.deepEqual(
224
241
  called("message.delete").map((c) => c.args[0]),
@@ -232,7 +249,7 @@ describe("handleEmptyTrash", () => {
232
249
  // exists anywhere, with nothing left to clear the mark.
233
250
  h.connection.search = async () => [];
234
251
 
235
- await handleEmptyTrash(event, noopLog, deps());
252
+ await handleEmptyTrash(event, noopLogger, deps());
236
253
 
237
254
  assert.equal(called("connection.deleteMessages").length, 0);
238
255
  assert.equal(called("message.delete").length, 0);
@@ -247,7 +264,7 @@ describe("handleEmptyTrash", () => {
247
264
  h.localMessages = [deleting("msg-2", 11)];
248
265
  h.connection.search = async () => [];
249
266
 
250
- await handleEmptyTrash(event, noopLog, deps());
267
+ await handleEmptyTrash(event, noopLogger, deps());
251
268
 
252
269
  assert.equal(called("message.delete").length, 0);
253
270
  assert.deepEqual(revertedMessageIds(), ["msg-2"]);
@@ -261,7 +278,7 @@ describe("handleEmptyTrash", () => {
261
278
  h.messagesWithoutListingRow = ["msg-expunging"];
262
279
  h.connection.search = async () => [];
263
280
 
264
- await handleEmptyTrash(event, noopLog, deps());
281
+ await handleEmptyTrash(event, noopLogger, deps());
265
282
 
266
283
  assert.deepEqual(revertedMessageIds(), ["msg-1"]);
267
284
  });
@@ -269,7 +286,7 @@ describe("handleEmptyTrash", () => {
269
286
  it("deletes the message even when it has no thread row", async () => {
270
287
  h.threadMessage = false;
271
288
 
272
- await handleEmptyTrash(event, noopLog, deps());
289
+ await handleEmptyTrash(event, noopLogger, deps());
273
290
 
274
291
  assert.equal(called("message.delete").length, 2);
275
292
  assert.equal(called("threadMessage.delete").length, 0);
@@ -282,7 +299,7 @@ describe("handleEmptyTrash", () => {
282
299
  deletedAt: Date.now(),
283
300
  };
284
301
 
285
- await handleEmptyTrash(event, noopLog, deps());
302
+ await handleEmptyTrash(event, noopLogger, deps());
286
303
 
287
304
  assert.equal(h.getConnectionCount, 0);
288
305
  });
@@ -290,7 +307,10 @@ describe("handleEmptyTrash", () => {
290
307
  it("throws when the account no longer exists", async () => {
291
308
  h.account = null;
292
309
 
293
- await assert.rejects(handleEmptyTrash(event, noopLog, deps()), /not found/);
310
+ await assert.rejects(
311
+ handleEmptyTrash(event, noopLogger, deps()),
312
+ /not found/,
313
+ );
294
314
  });
295
315
 
296
316
  it("acks terminally without connecting when the Trash mailbox was deleted", async () => {
@@ -298,7 +318,7 @@ describe("handleEmptyTrash", () => {
298
318
  name: "NotFoundError",
299
319
  });
300
320
 
301
- await handleEmptyTrash(event, noopLog, deps());
321
+ await handleEmptyTrash(event, noopLogger, deps());
302
322
 
303
323
  assert.equal(h.getConnectionCount, 0);
304
324
  assert.equal(called("message.delete").length, 0);
@@ -312,7 +332,7 @@ describe("handleEmptyTrash", () => {
312
332
  trashMailboxPath: "Trash",
313
333
  } as unknown as EmptyTrashEvent;
314
334
 
315
- await handleEmptyTrash(unversioned, noopLog, deps());
335
+ await handleEmptyTrash(unversioned, noopLogger, deps());
316
336
 
317
337
  assert.equal(h.getConnectionCount, 0, "no connection is ever opened");
318
338
  assert.equal(called("connection.deleteMessages").length, 0);
@@ -326,7 +346,7 @@ describe("handleEmptyTrash", () => {
326
346
  mailbox: { mailboxId: "other-mbx", fullPath: "INBOX/Bak" },
327
347
  };
328
348
 
329
- await handleEmptyTrash(event, noopLog, deps());
349
+ await handleEmptyTrash(event, noopLogger, deps());
330
350
 
331
351
  assert.equal(h.getConnectionCount, 0);
332
352
  assert.equal(called("connection.deleteMessages").length, 0);
@@ -339,7 +359,7 @@ describe("handleEmptyTrash", () => {
339
359
  mailbox: { mailboxId: "trash-mbx", fullPath: "Trash" },
340
360
  };
341
361
 
342
- await handleEmptyTrash(event, noopLog, deps());
362
+ await handleEmptyTrash(event, noopLogger, deps());
343
363
 
344
364
  assert.equal(called("connection.deleteMessages").length, 0);
345
365
  assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
@@ -351,7 +371,7 @@ describe("handleEmptyTrash", () => {
351
371
  h.connection.openBox = async () => ({ uidvalidity: 77 });
352
372
  h.mailbox = { mailboxId: "trash-mbx", uidValidity: 77 };
353
373
 
354
- await handleEmptyTrash(event, noopLog, deps());
374
+ await handleEmptyTrash(event, noopLogger, deps());
355
375
 
356
376
  assert.equal(called("connection.deleteMessages").length, 0);
357
377
  assert.deepEqual(revertedMessageIds(), ["msg-1", "msg-2"]);
@@ -367,7 +387,7 @@ describe("handleEmptyTrash", () => {
367
387
  h.connection.openBox = async () => ({ uidvalidity: 77 });
368
388
  h.mailbox = { mailboxId: "trash-mbx", uidValidity: 77 };
369
389
 
370
- await handleEmptyTrash(event, noopLog, deps());
390
+ await handleEmptyTrash(event, noopLogger, deps());
371
391
 
372
392
  assert.deepEqual(revertedMessageIds(), ["msg-1"]);
373
393
  assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-1"]);
@@ -378,7 +398,7 @@ describe("handleEmptyTrash", () => {
378
398
  // marked `deleting` hides healthy mail until the user notices.
379
399
  h.connection.openBox = async () => ({ uidvalidity: 999 });
380
400
 
381
- await handleEmptyTrash(event, noopLog, deps());
401
+ await handleEmptyTrash(event, noopLogger, deps());
382
402
 
383
403
  assert.equal(
384
404
  (called("mailbox.update")[0]?.args[2] as { cursorState?: string })
@@ -397,7 +417,7 @@ describe("handleEmptyTrash", () => {
397
417
  cursorState: "rebuilding",
398
418
  };
399
419
 
400
- await handleEmptyTrash(event, noopLog, deps());
420
+ await handleEmptyTrash(event, noopLogger, deps());
401
421
 
402
422
  assert.equal(h.getConnectionCount, 0);
403
423
  assert.equal(called("message.delete").length, 0);
@@ -410,10 +430,121 @@ describe("handleEmptyTrash", () => {
410
430
  };
411
431
 
412
432
  await assert.rejects(
413
- handleEmptyTrash(event, noopLog, deps()),
433
+ handleEmptyTrash(event, noopLogger, deps()),
414
434
  /server exploded/,
415
435
  );
416
436
 
417
437
  assert.equal(h.disconnectCount, 1);
418
438
  });
419
439
  });
440
+
441
+ /**
442
+ * Issue #1217. The sweep matched every row the Trash listing returned against
443
+ * the expunged uids. A row whose move into Trash has not settled carries the
444
+ * SOURCE folder's uid, so that match answers for whatever Trash holds at that
445
+ * uid — a different message, whose Message and thread rows the sweep then
446
+ * deletes while the mail it named is still sitting in the folder it never left.
447
+ */
448
+ describe("handleEmptyTrash and an unsettled placement", () => {
449
+ beforeEach(() => {
450
+ h = fresh();
451
+ });
452
+
453
+ it("deletes no row of a message whose move into Trash has not settled", async () => {
454
+ h.localMessages = [
455
+ deleting("msg-1", 10),
456
+ movingIntoTrash("msg-moving", 10),
457
+ ];
458
+
459
+ await handleEmptyTrash(event, noopLogger, deps());
460
+
461
+ assert.deepEqual(
462
+ called("message.delete").map((c) => c.args[0]),
463
+ ["msg-1"],
464
+ "uid 10 is msg-1's; the in-flight row only borrows it from the inbox",
465
+ );
466
+ assert.equal(called("threadMessage.delete").length, 1);
467
+ });
468
+
469
+ it("leaves a row whose move is mid-retry rather than sweeping it", async () => {
470
+ // `syncStatus: failed` under `status: moving` is an ordinary transient
471
+ // attempt about to be redelivered (`data-ports/message-settlement.ts`),
472
+ // not a give-up. Either way the pair is a lie while it stands, and the
473
+ // uid is somebody else's.
474
+ h.localMessages = [retryingIntoTrash("msg-retrying", 11)];
475
+
476
+ await handleEmptyTrash(event, noopLogger, deps());
477
+
478
+ assert.equal(called("message.delete").length, 0);
479
+ assert.equal(called("threadMessage.delete").length, 0);
480
+ assert.deepEqual(revertedMessageIds(), []);
481
+ });
482
+
483
+ it("hands back, rather than binds, a mid-move row an earlier mark rewrote", async () => {
484
+ // Defence in depth for the shape `emptyTrash` used to write: the mark
485
+ // overwrote `status` and the row reached here reading settled while its
486
+ // uid still belonged to the inbox. The uid is not bound — but the mark is
487
+ // cleared, because nothing else is coming to clear it and a row left
488
+ // `deleting` is hidden from every listing for good.
489
+ h.localMessages = [deleting("msg-1", 10), markedMidMove("msg-marked", 11)];
490
+
491
+ await handleEmptyTrash(event, noopLogger, deps());
492
+
493
+ assert.deepEqual(
494
+ called("message.delete").map((c) => c.args[0]),
495
+ ["msg-1"],
496
+ );
497
+ assert.deepEqual(revertedMessageIds(), ["msg-marked"]);
498
+ assert.deepEqual(undeletedThreadMessageIds(), ["tm-msg-marked"]);
499
+ });
500
+
501
+ it("sweeps a settled row whose Trash uid matches the one it left behind", async () => {
502
+ // Two folders count uids independently, so a move can land on the same
503
+ // number it started from. `updateUid` drops `originalUid` when it settles,
504
+ // which is what keeps this row apart from one still mid-move — without it
505
+ // the sweep would expunge the message on the server and then refuse its
506
+ // rows, leaving mail that no longer exists hidden in Trash for good.
507
+ h.localMessages = [
508
+ {
509
+ messageId: "msg-collided",
510
+ uid: 10,
511
+ status: "deleting",
512
+ syncStatus: "pending",
513
+ mailboxId: "trash-mbx",
514
+ originalMailboxId: "inbox-mbx",
515
+ },
516
+ ];
517
+
518
+ await handleEmptyTrash(event, noopLogger, deps());
519
+
520
+ assert.deepEqual(
521
+ called("message.delete").map((c) => c.args[0]),
522
+ ["msg-collided"],
523
+ );
524
+ assert.deepEqual(revertedMessageIds(), []);
525
+ });
526
+
527
+ it("sweeps a settled row that reached Trash by a move", async () => {
528
+ // The confirmed pair: `updateUid` wrote Trash's own uid over the
529
+ // source's, leaving `originalUid` behind as history. Refusing this would
530
+ // leave the folder marked for a deletion that already happened.
531
+ h.localMessages = [
532
+ {
533
+ messageId: "msg-settled",
534
+ uid: 11,
535
+ status: "deleting",
536
+ syncStatus: "pending",
537
+ mailboxId: "trash-mbx",
538
+ originalMailboxId: "inbox-mbx",
539
+ originalUid: 42,
540
+ },
541
+ ];
542
+
543
+ await handleEmptyTrash(event, noopLogger, deps());
544
+
545
+ assert.deepEqual(
546
+ called("message.delete").map((c) => c.args[0]),
547
+ ["msg-settled"],
548
+ );
549
+ });
550
+ });
@@ -5,6 +5,7 @@ import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
5
5
  import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
6
6
  import type { Logger } from "@remit/logger-lambda";
7
7
  import {
8
+ carriesForeignUid,
8
9
  guardConnectionCursor,
9
10
  isCursorRebuildNeeded,
10
11
  MailboxCursorPausedError,
@@ -248,12 +249,29 @@ export const handleEmptyTrash = async (
248
249
  const expunged = new Set(uids);
249
250
  const localMessages =
250
251
  await messageService.listAllByMailbox(trashMailboxId);
251
- let deletedCount = 0;
252
252
 
253
- for (const message of localMessages) {
254
- if (!expunged.has(message.uid)) continue;
255
- deletedCount += 1;
253
+ // Reconciles, never waits (imap-mutations R2). A row whose move
254
+ // into Trash has not settled names this folder while still
255
+ // carrying the SOURCE folder's uid, so matching it against the
256
+ // expunge answers for whatever Trash held at that uid — another
257
+ // message, deleted here in both its rows. Waiting is not open to
258
+ // this handler the way it is to the API-side mutators: every event
259
+ // of an account shares one FIFO group, so the move that would
260
+ // settle the row cannot run until this returns, and the ceiling
261
+ // would be spent to reach the same answer.
262
+ //
263
+ // `carriesForeignUid`, not the placement binding: the binding reads
264
+ // `status`, and `status` is exactly what an operation marking this
265
+ // folder overwrites. This is the second half of the gate
266
+ // `emptyTrash` applies before it marks anything.
267
+ const swept = localMessages.filter(
268
+ (message) =>
269
+ !carriesForeignUid(message) && expunged.has(message.uid),
270
+ );
271
+ const sweptIds = new Set(swept.map((message) => message.messageId));
272
+ const deletedCount = swept.length;
256
273
 
274
+ for (const message of swept) {
257
275
  await messageService.delete(message.messageId);
258
276
 
259
277
  const threadMessage = await threadMessageService.findByMessageId(
@@ -268,12 +286,16 @@ export const handleEmptyTrash = async (
268
286
  }
269
287
  }
270
288
 
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.
289
+ // Everything this sweep did not carry through is still marked for a
290
+ // deletion that will never come: mail another client already
291
+ // emptied, mail that reached Trash after the SEARCH, a redelivery
292
+ // finishing a partial sweep, and a row whose uid the sweep would
293
+ // not bind. All of them must come back rather than sit invisible —
294
+ // the last one especially, since nothing else is coming to clear
295
+ // its mark, and a row hidden in a folder it never left is worse
296
+ // than one the next sync re-projects.
275
297
  const revertedCount = await handBackMarkedRows(
276
- localMessages.filter((message) => !expunged.has(message.uid)),
298
+ localMessages.filter((message) => !sweptIds.has(message.messageId)),
277
299
  );
278
300
 
279
301
  log.info(