@remit/imap-worker 0.0.61 → 0.0.63

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.61",
3
+ "version": "0.0.63",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -15,7 +15,7 @@ import type { EmptyTrashEvent } from "../events.js";
15
15
  import { isNotFoundError } from "../is-not-found.js";
16
16
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
17
17
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
18
- import { buildThreadMessageUndelete } from "./message-delete.js";
18
+ import { buildThreadMessageUndelete } from "./thread-message-rows.js";
19
19
 
20
20
  export interface EmptyTrashDeps {
21
21
  getClient: typeof getClient;
@@ -28,9 +28,19 @@ interface Connection {
28
28
  uids: number[],
29
29
  dest: string,
30
30
  ) => Promise<{ uidMap: Map<number, number> }>;
31
+ search: (criteria: unknown[]) => Promise<number[]>;
31
32
  createMailbox: (path: string) => Promise<void>;
32
33
  }
33
34
 
35
+ interface CopyRow {
36
+ messageId: string;
37
+ mailboxId: string;
38
+ uid: number;
39
+ status: string;
40
+ syncStatus: string;
41
+ messageIdHeader?: string;
42
+ }
43
+
34
44
  interface Harness {
35
45
  calls: Call[];
36
46
  account: {
@@ -40,6 +50,8 @@ interface Harness {
40
50
  } | null;
41
51
  mailbox: { mailboxId: string; uidValidity: number; cursorState?: string };
42
52
  mailboxError?: Error;
53
+ copyRow: CopyRow | null;
54
+ destinationHolds: number[];
43
55
  connection: Connection;
44
56
  getConnectionCount: number;
45
57
  disconnectCount: number;
@@ -59,15 +71,36 @@ const record =
59
71
  };
60
72
 
61
73
  const buildConnection = (): Connection => ({
62
- openBox: async () => ({ uidvalidity: 1 }),
63
- copyMessages: async () => ({ uidMap: new Map([[10, 20]]) }),
74
+ openBox: async (path: string, readOnly?: boolean) => {
75
+ h.calls.push({ method: "openBox", args: [path, readOnly] });
76
+ return { uidvalidity: 1 };
77
+ },
78
+ copyMessages: async (uids: number[], dest: string) => {
79
+ h.calls.push({ method: "copyMessages", args: [uids, dest] });
80
+ return { uidMap: new Map([[10, 20]]) };
81
+ },
82
+ search: async (criteria: unknown[]) => {
83
+ h.calls.push({ method: "search", args: criteria });
84
+ return h.destinationHolds;
85
+ },
64
86
  createMailbox: record("createMailbox") as Connection["createMailbox"],
65
87
  });
66
88
 
89
+ const unsettledCopyRow = (): CopyRow => ({
90
+ messageId: "new-msg",
91
+ mailboxId: "dst-mbx",
92
+ uid: 0,
93
+ status: "moving",
94
+ syncStatus: "pending",
95
+ messageIdHeader: "<abc@example.com>",
96
+ });
97
+
67
98
  const fresh = (): Harness => ({
68
99
  calls: [],
69
100
  account: { accountId: "acc-1", accountConfigId: "cfg-1" },
70
101
  mailbox: { mailboxId: "src-mbx", uidValidity: 1, cursorState: undefined },
102
+ copyRow: unsettledCopyRow(),
103
+ destinationHolds: [],
71
104
  connection: buildConnection(),
72
105
  getConnectionCount: 0,
73
106
  disconnectCount: 0,
@@ -83,8 +116,13 @@ const deps = (): MessageCopyDeps =>
83
116
  },
84
117
  },
85
118
  message: {
119
+ get: async (messageIds: string[]) => {
120
+ h.calls.push({ method: "message.get", args: [messageIds] });
121
+ return h.copyRow ? [h.copyRow] : [];
122
+ },
86
123
  updateUid: record("message.updateUid"),
87
124
  update: record("message.update"),
125
+ delete: record("message.delete"),
88
126
  },
89
127
  threadMessage: {
90
128
  findByMessageId: async (cfg: string) => ({
@@ -97,6 +135,10 @@ const deps = (): MessageCopyDeps =>
97
135
  hasStars: false,
98
136
  hasAttachment: false,
99
137
  }),
138
+ findAllByMessageId: async (cfg: string) => [
139
+ { accountConfigId: cfg, threadMessageId: "tm-1" },
140
+ ],
141
+ deleteMany: record("threadMessage.deleteMany"),
100
142
  update: record("threadMessage.update"),
101
143
  },
102
144
  mailbox: {
@@ -141,13 +183,17 @@ const event: MessageCopyEvent = {
141
183
  const called = (method: string): Call[] =>
142
184
  h.calls.filter((c) => c.method === method);
143
185
 
186
+ const askedTheDestination = (): boolean =>
187
+ called("search").length > 0 &&
188
+ called("openBox").some((c) => c.args[0] === "Archive");
189
+
144
190
  describe("handleMessageCopy", () => {
145
191
  beforeEach(() => {
146
192
  h = fresh();
147
193
  });
148
194
 
149
195
  it("writes the new UID, marks the copy synced, and updates the thread row", async () => {
150
- await handleMessageCopy(event, noopLog, deps());
196
+ await handleMessageCopy(event, noopLog, 1, deps());
151
197
 
152
198
  assert.deepEqual(called("message.updateUid")[0]?.args, [
153
199
  "new-msg",
@@ -163,18 +209,114 @@ describe("handleMessageCopy", () => {
163
209
  assert.equal(h.disconnectCount, 1, "the scope is always disconnected");
164
210
  });
165
211
 
166
- it("marks the copy failed when the COPYUID response omits the source uid", async () => {
212
+ it("settles on the destination UID when a server without UIDPLUS omits COPYUID", async () => {
213
+ h.connection.copyMessages = async () => ({ uidMap: new Map() });
214
+ h.destinationHolds = [42];
215
+
216
+ await handleMessageCopy(event, noopLog, 1, deps());
217
+
218
+ assert.ok(askedTheDestination(), "the destination was asked for the copy");
219
+ assert.deepEqual(called("message.updateUid")[0]?.args, [
220
+ "new-msg",
221
+ 42,
222
+ "dst-mbx",
223
+ ]);
224
+ assert.equal(
225
+ (called("message.update")[0]?.args[1] as { syncStatus?: string })
226
+ ?.syncStatus,
227
+ "synced",
228
+ );
229
+ assert.equal(called("threadMessage.update").length, 1);
230
+ });
231
+
232
+ it("reconciles the optimistic row away when the destination does not hold the copy", async () => {
167
233
  h.connection.copyMessages = async () => ({ uidMap: new Map() });
234
+ h.destinationHolds = [];
168
235
 
169
- await handleMessageCopy(event, noopLog, deps());
236
+ await handleMessageCopy(event, noopLog, 1, deps());
170
237
 
238
+ assert.ok(askedTheDestination());
239
+ assert.equal(called("message.delete")[0]?.args[0], "new-msg");
240
+ assert.equal(called("threadMessage.deleteMany").length, 1);
171
241
  assert.equal(called("message.updateUid").length, 0);
242
+ });
243
+
244
+ it("settles a copy it cannot probe instead of retrying it into duplicates", async () => {
245
+ let copies = 0;
246
+ h.connection.copyMessages = async () => {
247
+ copies += 1;
248
+ return { uidMap: new Map() };
249
+ };
250
+ h.copyRow = { ...unsettledCopyRow(), messageIdHeader: undefined };
251
+
252
+ await handleMessageCopy(event, noopLog, 1, deps());
253
+
254
+ assert.equal(copies, 1, "the COPY is issued once");
255
+ assert.equal(called("search").length, 0, "there is nothing to ask with");
172
256
  const update = called("message.update")[0];
257
+ assert.equal((update?.args[1] as { status?: string })?.status, "deleted");
173
258
  assert.equal(
174
259
  (update?.args[1] as { syncStatus?: string })?.syncStatus,
175
260
  "failed",
176
261
  );
177
- assert.equal(called("threadMessage.update").length, 0);
262
+ assert.equal(called("message.delete").length, 0, "no row is thrown away");
263
+ });
264
+
265
+ it("never copies twice when a redelivered copy already landed", async () => {
266
+ h.destinationHolds = [42];
267
+
268
+ await handleMessageCopy(event, noopLog, 2, deps());
269
+
270
+ assert.equal(called("copyMessages").length, 0);
271
+ assert.deepEqual(called("message.updateUid")[0]?.args, [
272
+ "new-msg",
273
+ 42,
274
+ "dst-mbx",
275
+ ]);
276
+ });
277
+
278
+ it("never copies twice when a redelivered copy cannot be probed", async () => {
279
+ h.copyRow = { ...unsettledCopyRow(), messageIdHeader: undefined };
280
+ let copies = 0;
281
+ h.connection.copyMessages = async () => {
282
+ copies += 1;
283
+ // The COPY lands, then the tagged OK is lost with the connection.
284
+ throw new Error("connection reset by peer");
285
+ };
286
+
287
+ await assert.rejects(
288
+ handleMessageCopy(event, noopLog, 1, deps()),
289
+ /connection reset/,
290
+ );
291
+ await handleMessageCopy(event, noopLog, 2, deps());
292
+
293
+ assert.equal(copies, 1, "the redelivery issues no second COPY");
294
+ assert.equal(called("search").length, 0);
295
+ const settled = called("message.update").at(-1);
296
+ assert.equal((settled?.args[1] as { status?: string })?.status, "deleted");
297
+ });
298
+
299
+ it("acks a copy that already settled without touching IMAP", async () => {
300
+ h.copyRow = {
301
+ ...unsettledCopyRow(),
302
+ uid: 20,
303
+ status: "active",
304
+ syncStatus: "synced",
305
+ };
306
+
307
+ await handleMessageCopy(event, noopLog, 2, deps());
308
+
309
+ assert.equal(h.getConnectionCount, 0);
310
+ assert.equal(called("copyMessages").length, 0);
311
+ });
312
+
313
+ it("acks when the copy row no longer exists", async () => {
314
+ h.copyRow = null;
315
+
316
+ await handleMessageCopy(event, noopLog, 1, deps());
317
+
318
+ assert.equal(h.getConnectionCount, 0);
319
+ assert.equal(called("message.update").length, 0);
178
320
  });
179
321
 
180
322
  it("returns early without connecting when the account is soft-deleted", async () => {
@@ -184,7 +326,7 @@ describe("handleMessageCopy", () => {
184
326
  deletedAt: Date.now(),
185
327
  };
186
328
 
187
- await handleMessageCopy(event, noopLog, deps());
329
+ await handleMessageCopy(event, noopLog, 1, deps());
188
330
 
189
331
  assert.equal(h.getConnectionCount, 0);
190
332
  });
@@ -193,7 +335,7 @@ describe("handleMessageCopy", () => {
193
335
  h.account = null;
194
336
 
195
337
  await assert.rejects(
196
- handleMessageCopy(event, noopLog, deps()),
338
+ handleMessageCopy(event, noopLog, 1, deps()),
197
339
  /not found/,
198
340
  );
199
341
  });
@@ -201,7 +343,7 @@ describe("handleMessageCopy", () => {
201
343
  it("acks terminally without connecting when the source mailbox was deleted", async () => {
202
344
  h.mailboxError = notFoundError();
203
345
 
204
- await handleMessageCopy(event, noopLog, deps());
346
+ await handleMessageCopy(event, noopLog, 1, deps());
205
347
 
206
348
  assert.equal(h.getConnectionCount, 0);
207
349
  assert.equal(called("message.updateUid").length, 0);
@@ -215,7 +357,7 @@ describe("handleMessageCopy", () => {
215
357
  cursorState: "rebuilding",
216
358
  };
217
359
 
218
- await handleMessageCopy(event, noopLog, deps());
360
+ await handleMessageCopy(event, noopLog, 1, deps());
219
361
 
220
362
  assert.equal(h.getConnectionCount, 0);
221
363
  assert.equal(called("message.updateUid").length, 0);
@@ -224,7 +366,7 @@ describe("handleMessageCopy", () => {
224
366
  it("pauses quietly when openBox trips a UIDVALIDITY mismatch", async () => {
225
367
  h.connection.openBox = async () => ({ uidvalidity: 999 });
226
368
 
227
- await handleMessageCopy(event, noopLog, deps());
369
+ await handleMessageCopy(event, noopLog, 1, deps());
228
370
 
229
371
  assert.equal(
230
372
  (called("mailbox.update")[0]?.args[2] as { cursorState?: string })
@@ -242,7 +384,7 @@ describe("handleMessageCopy", () => {
242
384
  };
243
385
 
244
386
  await assert.rejects(
245
- handleMessageCopy(event, noopLog, deps()),
387
+ handleMessageCopy(event, noopLog, 1, deps()),
246
388
  /TRYCREATE/,
247
389
  );
248
390
 
@@ -255,20 +397,20 @@ describe("handleMessageCopy", () => {
255
397
  throw new Error("NONEXISTENT source message");
256
398
  };
257
399
 
258
- await handleMessageCopy(event, noopLog, deps());
400
+ await handleMessageCopy(event, noopLog, 1, deps());
259
401
 
260
402
  const update = called("message.update")[0];
261
403
  assert.equal((update?.args[1] as { status?: string })?.status, "deleted");
262
404
  assert.equal(called("createMailbox").length, 0);
263
405
  });
264
406
 
265
- it("marks failed and rethrows on an unclassified IMAP error", async () => {
407
+ it("marks failed and rethrows on an unclassified IMAP error within the budget", async () => {
266
408
  h.connection.copyMessages = async () => {
267
409
  throw new Error("server exploded");
268
410
  };
269
411
 
270
412
  await assert.rejects(
271
- handleMessageCopy(event, noopLog, deps()),
413
+ handleMessageCopy(event, noopLog, 1, deps()),
272
414
  /server exploded/,
273
415
  );
274
416
 
@@ -278,4 +420,36 @@ describe("handleMessageCopy", () => {
278
420
  "failed",
279
421
  );
280
422
  });
423
+
424
+ it("settles on the destination UID instead of dead-lettering the last attempt", async () => {
425
+ h.connection.copyMessages = async () => {
426
+ throw new Error("server exploded");
427
+ };
428
+ let asked = 0;
429
+ h.connection.search = async (criteria: unknown[]) => {
430
+ h.calls.push({ method: "search", args: criteria });
431
+ asked += 1;
432
+ return asked === 1 ? [] : [42];
433
+ };
434
+
435
+ await handleMessageCopy(event, noopLog, 3, deps());
436
+
437
+ assert.deepEqual(called("message.updateUid")[0]?.args, [
438
+ "new-msg",
439
+ 42,
440
+ "dst-mbx",
441
+ ]);
442
+ });
443
+
444
+ it("reconciles the row away when the last attempt finds nothing at the destination", async () => {
445
+ h.connection.copyMessages = async () => {
446
+ throw new Error("server exploded");
447
+ };
448
+ h.destinationHolds = [];
449
+
450
+ await handleMessageCopy(event, noopLog, 3, deps());
451
+
452
+ assert.equal(called("message.delete")[0]?.args[0], "new-msg");
453
+ assert.equal(called("threadMessage.deleteMany").length, 1);
454
+ });
281
455
  });
@@ -3,8 +3,11 @@ import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
3
3
  import type { Logger } from "@remit/logger-lambda";
4
4
  import {
5
5
  guardConnectionCursor,
6
+ type IImapConnection,
6
7
  isCursorRebuildNeeded,
8
+ isPlacementUnsettled,
7
9
  MailboxCursorPausedError,
10
+ reconcileStaleMessage,
8
11
  } from "@remit/mailbox-service";
9
12
  import { isAccountDeleted } from "../account-check.js";
10
13
  import { createConnectionScopeWithCredentials } from "../connection-scope.js";
@@ -12,6 +15,7 @@ import type { MessageCopyEvent } from "../events.js";
12
15
  import { isNotFoundError } from "../is-not-found.js";
13
16
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
14
17
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
18
+ import { searchMailboxByMessageId } from "./message-move.js";
15
19
 
16
20
  export interface MessageCopyDeps {
17
21
  getClient: typeof getClient;
@@ -27,13 +31,65 @@ const defaultDeps: MessageCopyDeps = {
27
31
  createConnectionScope: createConnectionScopeWithCredentials,
28
32
  };
29
33
 
34
+ /**
35
+ * Fallback when `MESSAGE_COPY_MAX_ATTEMPTS` is unset (local dev, unit tests).
36
+ * Matches the `maxReceiveCount` the message queue's redrive policy uses
37
+ * (`remit-messages.fifo`, `deploy/vps/queues.json`), same pattern as
38
+ * `MESSAGE_MOVE_MAX_ATTEMPTS`.
39
+ */
40
+ const DEFAULT_MESSAGE_COPY_MAX_ATTEMPTS = 3;
41
+
42
+ export const getMessageCopyMaxAttempts = (
43
+ processEnv: NodeJS.ProcessEnv = process.env,
44
+ ): number => {
45
+ const raw = processEnv.MESSAGE_COPY_MAX_ATTEMPTS;
46
+ if (!raw) return DEFAULT_MESSAGE_COPY_MAX_ATTEMPTS;
47
+ const parsed = Number.parseInt(raw, 10);
48
+ return Number.isFinite(parsed) && parsed > 0
49
+ ? parsed
50
+ : DEFAULT_MESSAGE_COPY_MAX_ATTEMPTS;
51
+ };
52
+
53
+ export const MESSAGE_COPY_MAX_ATTEMPTS = getMessageCopyMaxAttempts();
54
+
55
+ /**
56
+ * Where the destination says this copy is. `unprobeable` is the row that
57
+ * carries no Message-ID header (`messageIdHeader` is optional on Message, and
58
+ * drafts and automated senders do arrive without one): the server was never
59
+ * asked, so its silence says nothing.
60
+ */
61
+ export type CopyProbe =
62
+ | { kind: "confirmed"; uid: number }
63
+ | { kind: "absent" }
64
+ | { kind: "unprobeable" };
65
+
30
66
  /**
31
67
  * Handle MESSAGE_COPY events.
32
68
  * Executes IMAP COPY command and updates local state with new UID.
69
+ *
70
+ * IMAP COPY is not idempotent and the message queue is FIFO per account, so
71
+ * this handler never manufactures a retry to resolve an ambiguous answer: a
72
+ * second COPY duplicates the mail, and a record that keeps coming back blocks
73
+ * every later move, copy and delete for that account (issues #287, #289,
74
+ * #290). Ambiguity is resolved in place, by asking the destination.
75
+ *
76
+ * A fault the server threw is still retried up to
77
+ * {@link MESSAGE_COPY_MAX_ATTEMPTS}, because it may be a dropped connection.
78
+ * Two guards make that safe: a redelivery asks the destination BEFORE issuing
79
+ * COPY again, and the last attempt settles the row instead of dead-lettering
80
+ * it. The copy row is a reconcile-model dependent (`copySettledMessage` writes
81
+ * its ThreadMessage row at `uid: 0` before the server has confirmed anything,
82
+ * docs/architecture/imap-mutations.md R2) and these terminal settles are its
83
+ * reconciliation path: every verdict the server gives an answer for settles the
84
+ * row out of `uid: 0`/`moving` (#1097). A server that cannot be reached at all
85
+ * gives no verdict — the record dead-letters with the row untouched, the same
86
+ * caveat `resolveExhaustedMessageMoveFailure` carries, because absence is only
87
+ * ever concluded from an answer.
33
88
  */
34
89
  export const handleMessageCopy = async (
35
90
  event: MessageCopyEvent,
36
91
  log: Logger,
92
+ receiveCount = 1,
37
93
  deps: MessageCopyDeps = defaultDeps,
38
94
  ): Promise<void> => {
39
95
  const {
@@ -83,6 +139,29 @@ export const handleMessageCopy = async (
83
139
  return;
84
140
  }
85
141
 
142
+ const [copyRow] = await messageService.get([newMessageId]);
143
+
144
+ // The copy row is already gone — a reconciliation path settled it. Nothing
145
+ // left to bind a UID to; ack.
146
+ if (!copyRow) {
147
+ log.warn(
148
+ { accountId, sourceMessageId, newMessageId },
149
+ "Skipping MESSAGE_COPY: copy row no longer exists",
150
+ );
151
+ return;
152
+ }
153
+
154
+ // This copy already settled — `updateUid` cleared `status: moving` when the
155
+ // server confirmed it. A redelivery reaching here would COPY the message a
156
+ // second time, and COPY has no source-side effect to make that a no-op.
157
+ if (!isPlacementUnsettled(copyRow)) {
158
+ log.info(
159
+ { accountId, newMessageId, uid: copyRow.uid, status: copyRow.status },
160
+ "Skipping MESSAGE_COPY: the copy already settled against confirmed IMAP state",
161
+ );
162
+ return;
163
+ }
164
+
86
165
  await withOAuthLifecycle(
87
166
  buildLifecycleDeps(secrets, accountService),
88
167
  account,
@@ -118,6 +197,131 @@ export const handleMessageCopy = async (
118
197
  return;
119
198
  }
120
199
 
200
+ // The copy row carries the source's Message-ID header, so the
201
+ // destination can be asked where this copy is. Always on the UNGUARDED
202
+ // handle: a `guardConnectionCursor` wrap is bound to the SOURCE mailbox
203
+ // snapshot alone, and opening the destination through it trips that
204
+ // mailbox into a rebuild.
205
+ const probeDestination = async (
206
+ connection: IImapConnection,
207
+ ): Promise<CopyProbe> => {
208
+ if (!copyRow.messageIdHeader) return { kind: "unprobeable" };
209
+ const probedUid = await searchMailboxByMessageId(
210
+ connection,
211
+ destinationMailboxPath,
212
+ copyRow.messageIdHeader,
213
+ );
214
+ return probedUid === null
215
+ ? { kind: "absent" }
216
+ : { kind: "confirmed", uid: probedUid };
217
+ };
218
+
219
+ const settleCopied = async (newUid: number): Promise<void> => {
220
+ await messageService.updateUid(
221
+ newMessageId,
222
+ newUid,
223
+ destinationMailboxId,
224
+ );
225
+
226
+ await messageService.update(newMessageId, {
227
+ status: MessageStatus.active,
228
+ syncStatus: MessageSyncStatus.synced,
229
+ });
230
+
231
+ const threadMessage = await threadMessageService.findByMessageId(
232
+ account.accountConfigId,
233
+ newMessageId,
234
+ );
235
+ if (threadMessage) {
236
+ await threadMessageService.update(
237
+ threadMessage.accountConfigId,
238
+ threadMessage.threadMessageId,
239
+ { uid: newUid },
240
+ {
241
+ composites: {
242
+ sentDate: threadMessage.sentDate,
243
+ mailboxId: threadMessage.mailboxId,
244
+ isRead: threadMessage.isRead,
245
+ isDeleted: threadMessage.isDeleted,
246
+ hasStars: threadMessage.hasStars,
247
+ hasAttachment: threadMessage.hasAttachment,
248
+ },
249
+ },
250
+ );
251
+ }
252
+
253
+ log.info(
254
+ {
255
+ sourceMessageId,
256
+ newMessageId,
257
+ oldUid: uid,
258
+ newUid,
259
+ destination: destinationMailboxPath,
260
+ },
261
+ "Message copied successfully",
262
+ );
263
+ };
264
+
265
+ // The destination answered that it does not hold this message, so the
266
+ // copy never landed and the optimistic row records something the server
267
+ // does not have. Deleting it (with its ThreadMessage rows) is the exact
268
+ // reality, the same terminal outcome `resolveExhaustedMessageMoveFailure`
269
+ // calls `reconciled`. Routine: metric, no alarm.
270
+ const settleNeverLanded = async (reason: string): Promise<void> => {
271
+ const { threadMessagesDeleted } = await reconcileStaleMessage(
272
+ { messageService, threadMessageService },
273
+ account.accountConfigId,
274
+ newMessageId,
275
+ );
276
+ log.info(
277
+ {
278
+ metric: "message_copy_not_landed",
279
+ accountId,
280
+ sourceMessageId,
281
+ newMessageId,
282
+ destination: destinationMailboxPath,
283
+ threadMessagesDeleted,
284
+ reason,
285
+ },
286
+ "Copy absent from its destination; optimistic copy row reconciled away",
287
+ );
288
+ };
289
+
290
+ // The server never answered where the copy is, so nothing here may
291
+ // delete a row that might describe real mail. The row is settled out of
292
+ // `moving` instead — `status: deleted` is the state this handler already
293
+ // writes for a copy that will not happen, and it keeps `holdsCopyOf`
294
+ // answering true so a sighting at the destination cannot drag the SOURCE
295
+ // row out of the folder it was copied from.
296
+ //
297
+ // The cost, paid deliberately: if that COPY did land, this row is the
298
+ // only one the copy will ever have — `holdsCopyOf` tests row existence
299
+ // alone (`message-sync.ts`), so no sync creates a second one — and
300
+ // `deleted` hides it. Real mail then sits at the destination on the
301
+ // server and invisible in the product until an operator acts on the
302
+ // alert. `settleNeverLanded` has the opposite property: it deletes only
303
+ // what the server denied holding, and a later sync re-projects anything
304
+ // that turns out to be there. Silence buys the safer half of one pair
305
+ // and the worse half of the other.
306
+ const settleBroken = async (reason: string): Promise<void> => {
307
+ await messageService.update(newMessageId, {
308
+ status: MessageStatus.deleted,
309
+ syncStatus: MessageSyncStatus.failed,
310
+ });
311
+ log.error(
312
+ {
313
+ alert: "message_copy_unconfirmed",
314
+ accountId,
315
+ sourceMessageId,
316
+ newMessageId,
317
+ uid,
318
+ destination: destinationMailboxPath,
319
+ reason,
320
+ },
321
+ "Copy could not be bound to a destination UID; row settled failed for operator investigation",
322
+ );
323
+ };
324
+
121
325
  const scope = createConnectionScopeWithCredentials(account, credentials);
122
326
 
123
327
  await scope
@@ -132,6 +336,27 @@ export const handleMessageCopy = async (
132
336
  accountId,
133
337
  mailbox,
134
338
  );
339
+
340
+ // A redelivery means the previous attempt threw, which does not
341
+ // mean its COPY failed — the tagged OK can be lost with the
342
+ // connection. Ask before copying again, or the retry is what
343
+ // duplicates the mail. `absent` is an answer the server gave, so
344
+ // copying again is safe; `unprobeable` is silence, and copying on
345
+ // silence is the duplicate this guard exists to prevent.
346
+ if (receiveCount > 1) {
347
+ const earlier = await probeDestination(rawConnection);
348
+ if (earlier.kind === "confirmed") {
349
+ await settleCopied(earlier.uid);
350
+ return;
351
+ }
352
+ if (earlier.kind === "unprobeable") {
353
+ await settleBroken(
354
+ "redelivered with no Message-ID to ask the destination with",
355
+ );
356
+ return;
357
+ }
358
+ }
359
+
135
360
  // Open source mailbox (read-only is fine for COPY)
136
361
  await connection.openBox(sourceMailboxPath, true);
137
362
 
@@ -141,66 +366,30 @@ export const handleMessageCopy = async (
141
366
  destinationMailboxPath,
142
367
  );
143
368
 
144
- // Get new UID from COPYUID response
145
- const newUid = result.uidMap.get(uid);
146
-
147
- if (newUid) {
148
- // Update the new message with the actual UID
149
- await messageService.updateUid(
150
- newMessageId,
151
- newUid,
152
- destinationMailboxId,
153
- );
154
-
155
- // Update message status to active
156
- await messageService.update(newMessageId, {
157
- status: MessageStatus.active,
158
- syncStatus: MessageSyncStatus.synced,
159
- });
160
-
161
- // Update ThreadMessage UID
162
- const threadMessage = await threadMessageService.findByMessageId(
163
- account.accountConfigId,
164
- newMessageId,
165
- );
166
- if (threadMessage) {
167
- await threadMessageService.update(
168
- threadMessage.accountConfigId,
169
- threadMessage.threadMessageId,
170
- { uid: newUid },
171
- {
172
- composites: {
173
- sentDate: threadMessage.sentDate,
174
- mailboxId: threadMessage.mailboxId,
175
- isRead: threadMessage.isRead,
176
- isDeleted: threadMessage.isDeleted,
177
- hasStars: threadMessage.hasStars,
178
- hasAttachment: threadMessage.hasAttachment,
179
- },
180
- },
181
- );
182
- }
369
+ const copiedUid = result.uidMap.get(uid);
370
+ if (copiedUid) {
371
+ await settleCopied(copiedUid);
372
+ return;
373
+ }
183
374
 
184
- log.info(
185
- {
186
- sourceMessageId,
187
- newMessageId,
188
- oldUid: uid,
189
- newUid,
190
- destination: destinationMailboxPath,
191
- },
192
- "Message copied successfully",
193
- );
194
- } else {
195
- // Source message may have been deleted on server
196
- log.error(
197
- { sourceMessageId, uid },
198
- "Source message not found in COPYUID response - may have been deleted",
375
+ // A server without UIDPLUS answers a perfectly successful COPY
376
+ // with no COPYUID entry, so an empty map is UNCONFIRMED, never
377
+ // evidence the server matched nothing (#1097). The destination is
378
+ // asked before any verdict, as `message-move.ts` does (#912, #979).
379
+ const probe = await probeDestination(rawConnection);
380
+ if (probe.kind === "confirmed") {
381
+ await settleCopied(probe.uid);
382
+ return;
383
+ }
384
+ if (probe.kind === "absent") {
385
+ await settleNeverLanded(
386
+ "no COPYUID entry, absent from destination",
199
387
  );
200
- await messageService.update(newMessageId, {
201
- syncStatus: MessageSyncStatus.failed,
202
- });
388
+ return;
203
389
  }
390
+ await settleBroken(
391
+ "no COPYUID entry and no Message-ID to probe with",
392
+ );
204
393
  })
205
394
  .catch(async (error: unknown) => {
206
395
  if (error instanceof MailboxCursorPausedError) {
@@ -227,7 +416,10 @@ export const handleMessageCopy = async (
227
416
  );
228
417
  const connection = await scope.getConnection();
229
418
  await connection.createMailbox(destinationMailboxPath);
230
- // Re-throw to let the event be retried
419
+ // Re-throw to let the event be retried against the folder just
420
+ // created. Kept unconditional past the attempt budget, as the
421
+ // move path does: the destination now exists, so the record is
422
+ // worth redriving from the DLQ.
231
423
  throw error;
232
424
  }
233
425
 
@@ -247,11 +439,33 @@ export const handleMessageCopy = async (
247
439
  return;
248
440
  }
249
441
 
250
- // Mark as failed for other errors
251
- await messageService.update(newMessageId, {
252
- syncStatus: MessageSyncStatus.failed,
253
- });
254
- throw error;
442
+ if (receiveCount < MESSAGE_COPY_MAX_ATTEMPTS) {
443
+ // Transient copy failure — expected (connections drop). Queue
444
+ // redelivery retries, and the probe above keeps the retry from
445
+ // copying the message twice.
446
+ await messageService.update(newMessageId, {
447
+ syncStatus: MessageSyncStatus.failed,
448
+ });
449
+ throw error;
450
+ }
451
+
452
+ // Redelivery budget exhausted. Dead-lettering here would leave the
453
+ // row at `uid: 0`/`moving` with nothing left to settle it and the
454
+ // account's FIFO group still holding the record, so the row is
455
+ // resolved into one terminal outcome instead — never by inferring
456
+ // the server's state from our own failures. A server that cannot be
457
+ // reached reaches no verdict: the probe throws and the record
458
+ // dead-letters with the row untouched.
459
+ const probe = await probeDestination(await scope.getConnection());
460
+ if (probe.kind === "confirmed") {
461
+ await settleCopied(probe.uid);
462
+ return;
463
+ }
464
+ if (probe.kind === "absent") {
465
+ await settleNeverLanded(`retry exhausted: ${errorMessage}`);
466
+ return;
467
+ }
468
+ await settleBroken(`retry exhausted: ${errorMessage}`);
255
469
  })
256
470
  .finally(() => scope.disconnect());
257
471
  },
@@ -1,11 +1,14 @@
1
- import type { IMessageRepository } from "@remit/data-ports";
2
- import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
1
+ import type {
2
+ IMessageRepository,
3
+ IThreadMessageRepository,
4
+ } from "@remit/data-ports";
3
5
  import {
4
6
  type IImapConnection,
5
7
  isMessageGoneFromOpenMailbox,
6
8
  reconcileStaleMessage,
7
9
  type StaleMessageReconcileDeps,
8
10
  } from "@remit/mailbox-service";
11
+ import { buildThreadMessageMoveRevert } from "./thread-message-rows.js";
9
12
 
10
13
  export interface MessageDeleteTerminalLogger {
11
14
  info(obj: Record<string, unknown>, msg: string): void;
@@ -14,7 +17,11 @@ export interface MessageDeleteTerminalLogger {
14
17
 
15
18
  export interface ResolveExhaustedMessageDeleteDeps
16
19
  extends StaleMessageReconcileDeps {
17
- messageService: Pick<IMessageRepository, "delete" | "update">;
20
+ messageService: Pick<IMessageRepository, "delete" | "updateUid">;
21
+ threadMessageService: Pick<
22
+ IThreadMessageRepository,
23
+ "findAllByMessageId" | "deleteMany" | "update"
24
+ >;
18
25
  log: MessageDeleteTerminalLogger;
19
26
  }
20
27
 
@@ -22,6 +29,7 @@ export interface ResolveExhaustedMessageDeleteInput {
22
29
  accountId: string;
23
30
  accountConfigId: string;
24
31
  messageId: string;
32
+ sourceMailboxId: string;
25
33
  uid: number;
26
34
  sourceMailboxPath: string;
27
35
  getConnection: () => Promise<IImapConnection>;
@@ -49,15 +57,18 @@ export interface ResolveExhaustedMessageDeleteResult {
49
57
  * server's own UID. Metric only, no alarm — routine.
50
58
  * 2. BROKEN — the message is still at the source, so the delete never took
51
59
  * effect, but it keeps failing: broken code or a broken account, not a
52
- * transient blip. The row's mailbox and uid are left exactly as they stand,
53
- * because reverting the optimistic move on this ambiguity is what PR #652
54
- * was pulled for.
60
+ * transient blip. The row is put back where the server has just said the
61
+ * message is: `mailboxId` and `uid` return to the delete's source pair, and
62
+ * the thread rows lose the deletion mark the optimistic `updateForMove`
63
+ * wrote (issue #1098).
55
64
  *
56
- * `status` does settle, to `active`. It is not a claim about where the
57
- * message is — only that this row is no longer mid-mutation. Leaving it
58
- * `moving` makes `isPlacementUnsettled` true forever, and every later delete
59
- * of that message then waits on a mutation that has already terminated, so
60
- * the user is left with mail they cannot delete.
65
+ * Settling `status` to `active` while leaving `mailboxId` on Trash is what
66
+ * this replaces. `bindsForeignUid` refuses a mismatched pair only while
67
+ * `status` is `moving`, so clearing it read as consistent, and the UI then
68
+ * offered "Delete permanently" on a Trash mailbox with the source folder's
69
+ * uid — an expunge naming a different message. Restoring the pair is not a
70
+ * revert on ambiguity (what PR #652 was pulled for): the presence probe
71
+ * above has just answered, so this writes a placement the server confirmed.
61
72
  *
62
73
  * A server that cannot be reached at exhaustion time never reaches either
63
74
  * verdict: the probe throws and the record dead-letters with the row untouched.
@@ -71,6 +82,7 @@ export const resolveExhaustedMessageDeleteFailure = async (
71
82
  accountId,
72
83
  accountConfigId,
73
84
  messageId,
85
+ sourceMailboxId,
74
86
  uid,
75
87
  sourceMailboxPath,
76
88
  getConnection,
@@ -100,10 +112,6 @@ export const resolveExhaustedMessageDeleteFailure = async (
100
112
  return { outcome: "reconciled" };
101
113
  }
102
114
 
103
- await deps.messageService.update(messageId, {
104
- status: MessageStatus.active,
105
- syncStatus: MessageSyncStatus.failed,
106
- });
107
115
  deps.log.error(
108
116
  {
109
117
  alert: "message_delete_failed",
@@ -111,9 +119,31 @@ export const resolveExhaustedMessageDeleteFailure = async (
111
119
  accountConfigId,
112
120
  messageId,
113
121
  uid,
122
+ sourceMailboxId,
114
123
  sourceMailboxPath,
115
124
  },
116
- "Delete could not be pushed to IMAP after retry exhaustion; the message is still at its source — row settled out of `moving` and left in place for operator investigation",
125
+ "Delete could not be pushed to IMAP after retry exhaustion; the message is still at its source — row moved back to the source folder",
117
126
  );
127
+
128
+ await deps.messageService.updateUid(messageId, uid, sourceMailboxId);
129
+
130
+ const threadMessages = await deps.threadMessageService.findAllByMessageId(
131
+ accountConfigId,
132
+ messageId,
133
+ );
134
+ for (const threadMessage of threadMessages) {
135
+ const args = buildThreadMessageMoveRevert(
136
+ threadMessage,
137
+ uid,
138
+ sourceMailboxId,
139
+ );
140
+ await deps.threadMessageService.update(
141
+ threadMessage.accountConfigId,
142
+ threadMessage.threadMessageId,
143
+ args.set,
144
+ { composites: args.composites },
145
+ );
146
+ }
147
+
118
148
  return { outcome: "broken" };
119
149
  };
@@ -5,14 +5,16 @@ import type { Logger } from "@remit/logger-lambda";
5
5
  import { renderMetrics, resetMetrics } from "@remit/logger-lambda";
6
6
  import type { MessageDeleteEvent } from "../events.js";
7
7
  import {
8
- buildThreadMessageTrashUpdate,
9
- buildThreadMessageUndelete,
10
8
  deleteAllThreadMessagesForMessage,
11
9
  getMessageDeleteMaxAttempts,
12
10
  handleMessageDelete,
13
11
  MESSAGE_DELETE_MAX_ATTEMPTS,
14
12
  type MessageDeleteDeps,
15
13
  } from "./message-delete.js";
14
+ import {
15
+ buildThreadMessageTrashUpdate,
16
+ buildThreadMessageUndelete,
17
+ } from "./thread-message-rows.js";
16
18
 
17
19
  describe("getMessageDeleteMaxAttempts — env-derived threshold (#980)", () => {
18
20
  it("parses the injected env var", () => {
@@ -676,28 +678,65 @@ describe("handleMessageDelete", () => {
676
678
  );
677
679
  });
678
680
 
679
- // A source that still holds the uid at the ceiling means the MOVE never
680
- // took effect. Local state is left exactly as it stands — the row is the
681
- // only record that this delete is still owed, and a revert races a MOVE
682
- // that may yet have landed (#652, #655).
683
- it("leaves a message still at its source untouched at the ceiling and never re-throws", async () => {
681
+ // Issue #1098. A source that still holds the uid at the ceiling means the
682
+ // MOVE never took effect, so the row goes back to the source folder: the
683
+ // optimistic `updateForMove` left `mailboxId` on Trash while `uid` still
684
+ // named the source's message, and settling `status` to `active` on top of
685
+ // that pair switched off every guard that reads `status === "moving"`.
686
+ // The UI then resolved a Trash `mailboxId` to "Delete permanently", an
687
+ // expunge of Trash by a uid belonging to a different message. This is not
688
+ // a revert on ambiguity (#652, #655): the presence probe has just
689
+ // answered, so the pair written here is the placement the server holds.
690
+ it("moves a message still at its source back to the source folder at the ceiling", async () => {
684
691
  h.connection.moveMessages = async () => ({ uidMap: new Map() });
685
692
  h.destinationSearchUids = [100];
693
+ h.allThreadMessages = [
694
+ {
695
+ ...baseThreadMessage,
696
+ accountConfigId: "cfg-1",
697
+ threadMessageId: "tm-1",
698
+ mailboxId: "trash-mbx",
699
+ isDeleted: true,
700
+ },
701
+ ];
686
702
 
687
703
  await handleMessageDelete(moveEvent, noopLog, 3, deps());
688
704
 
689
705
  assert.equal(called("message.delete").length, 0);
690
706
  assert.equal(called("threadMessage.deleteMany").length, 0);
691
- assert.equal(called("threadMessage.update").length, 0);
692
707
 
693
- // The row's mailbox and uid stay put, but `status` must leave
694
- // `moving`: `isPlacementUnsettled` reads exactly that value, so a row
695
- // left mid-mutation makes every later delete of this message wait on
696
- // a mutation that has already terminated.
697
- assert.deepEqual(called("message.update").at(-1)?.args[1], {
698
- status: "active",
699
- syncStatus: "failed",
700
- });
708
+ // `mailboxId` and `uid` name the same message again, and `updateUid`
709
+ // settles `status` out of `moving` in the same write, so no later
710
+ // delete of this message waits on a mutation that has terminated.
711
+ assert.deepEqual(called("message.updateUid").at(-1)?.args, [
712
+ "msg-1",
713
+ 10,
714
+ "src-mbx",
715
+ ]);
716
+ assert.equal(
717
+ called("message.update").length,
718
+ 0,
719
+ "settling `status` without repairing the pair is what left the row addressable as a Trash expunge",
720
+ );
721
+
722
+ // The listing row follows the Message row out of Trash, which is what
723
+ // makes "Delete permanently" unreachable: the client resolves that
724
+ // outcome from a target whose `mailboxId` is the Trash mailbox.
725
+ assert.deepEqual(called("threadMessage.update").at(-1)?.args, [
726
+ "cfg-1",
727
+ "tm-1",
728
+ { uid: 10, mailboxId: "src-mbx", isDeleted: false },
729
+ {
730
+ composites: {
731
+ sentDate: baseThreadMessage.sentDate,
732
+ mailboxId: "trash-mbx",
733
+ isRead: true,
734
+ isDeleted: true,
735
+ hasStars: true,
736
+ hasAttachment: false,
737
+ },
738
+ },
739
+ ]);
701
740
  assert.equal(await imapFailures("MESSAGE_DELETE_EXHAUSTED"), 1);
702
741
  });
703
742
 
@@ -767,15 +806,16 @@ describe("handleMessageDelete", () => {
767
806
  assert.equal(called("emitEvent").length, 2);
768
807
  });
769
808
 
770
- it("settles a still-present message at the ceiling out of `moving`", async () => {
809
+ it("settles a still-present message at the ceiling back onto its source", async () => {
771
810
  moveThrows();
772
811
 
773
812
  await handleMessageDelete(moveEvent, noopLog, 3, deps());
774
813
 
775
- assert.deepEqual(called("message.update").at(-1)?.args[1], {
776
- status: "active",
777
- syncStatus: "failed",
778
- });
814
+ assert.deepEqual(called("message.updateUid").at(-1)?.args, [
815
+ "msg-1",
816
+ 10,
817
+ "src-mbx",
818
+ ]);
779
819
  assert.equal(await imapFailures("MESSAGE_DELETE_EXHAUSTED"), 1);
780
820
  });
781
821
  });
@@ -2,7 +2,6 @@ import { getClient } from "@remit/backend/client";
2
2
  import type {
3
3
  IMessageRepository,
4
4
  IThreadMessageRepository,
5
- ThreadMessageItem,
6
5
  } from "@remit/data-ports";
7
6
  import { isCurrentSchemaVersion } from "@remit/data-ports/mutation-events";
8
7
  import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
@@ -24,6 +23,10 @@ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
24
23
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
25
24
  import { resolveExhaustedMessageDeleteFailure } from "./message-delete-terminal.js";
26
25
  import { emitMoveResync, searchMailboxByMessageId } from "./message-move.js";
26
+ import {
27
+ buildThreadMessageMoveRevert,
28
+ buildThreadMessageTrashUpdate,
29
+ } from "./thread-message-rows.js";
27
30
 
28
31
  /**
29
32
  * Fallback when `MESSAGE_DELETE_MAX_ATTEMPTS` is unset (local dev, unit tests).
@@ -73,82 +76,6 @@ export const deleteAllThreadMessagesForMessage = async (
73
76
  return rows.length;
74
77
  };
75
78
 
76
- /**
77
- * Build the `set` and `composites` payload for the ThreadMessage update on a
78
- * MESSAGE_DELETE move-to-trash.
79
- *
80
- * The CURRENT row state goes in `composites`; the NEW values go in `set`.
81
- * ElectroDB uses `composites` to run the conditional check on the existing row
82
- * AND to compute the previous sort-key values needed to recompute the new ones.
83
- * Passing the NEW values in `composites` makes the conditional check fail with
84
- * ConditionalCheckFailedException, which ElectroDB wraps as NotFoundError, and
85
- * the caller silently drops the update. Same root cause as PR #186 fixed for
86
- * `flag-queue.ts`.
87
- */
88
- type ThreadMessageRowState = Pick<
89
- ThreadMessageItem,
90
- | "sentDate"
91
- | "mailboxId"
92
- | "isRead"
93
- | "isDeleted"
94
- | "hasStars"
95
- | "hasAttachment"
96
- >;
97
-
98
- const currentComposites = (threadMessage: ThreadMessageRowState) => ({
99
- sentDate: threadMessage.sentDate,
100
- mailboxId: threadMessage.mailboxId,
101
- isRead: threadMessage.isRead,
102
- isDeleted: threadMessage.isDeleted,
103
- hasStars: threadMessage.hasStars,
104
- hasAttachment: threadMessage.hasAttachment,
105
- });
106
-
107
- export const buildThreadMessageTrashUpdate = (
108
- threadMessage: ThreadMessageRowState,
109
- newUid: number,
110
- destinationMailboxId: string,
111
- ) => ({
112
- set: {
113
- uid: newUid,
114
- mailboxId: destinationMailboxId,
115
- isDeleted: true,
116
- },
117
- composites: currentComposites(threadMessage),
118
- });
119
-
120
- /**
121
- * The inverse payload: put the thread row back where the message still is on
122
- * the server. The delete was recorded optimistically, so a delete abandoned
123
- * before any IMAP write has to hand the row back rather than leave it claiming
124
- * Trash — an invisible `failed` on a row the user cannot see is the shape of
125
- * the incident this whole change is about.
126
- */
127
- export const buildThreadMessageMoveRevert = (
128
- threadMessage: ThreadMessageRowState,
129
- sourceUid: number,
130
- sourceMailboxId: string,
131
- ) => ({
132
- set: {
133
- uid: sourceUid,
134
- mailboxId: sourceMailboxId,
135
- isDeleted: false,
136
- },
137
- composites: currentComposites(threadMessage),
138
- });
139
-
140
- /**
141
- * Hand a row back after an abandoned expunge. The mail never left Trash, so
142
- * only the deletion mark reverts — the uid and mailbox on the row are still
143
- * where the server has it.
144
- */
145
- export const buildThreadMessageUndelete = (
146
- threadMessage: ThreadMessageRowState,
147
- ) => ({
148
- set: { isDeleted: false },
149
- composites: currentComposites(threadMessage),
150
- });
151
-
152
79
  /**
153
80
  * Settle a move to Trash the server left unconfirmed, by asking it two
154
81
  * read-only questions instead of one. Both handles wrap the SAME connection but
@@ -349,6 +276,7 @@ export const handleMessageDelete = async (
349
276
  accountId,
350
277
  accountConfigId,
351
278
  messageId,
279
+ sourceMailboxId: mailboxId,
352
280
  uid,
353
281
  sourceMailboxPath: mailboxPath,
354
282
  getConnection,
@@ -362,11 +290,9 @@ export const handleMessageDelete = async (
362
290
  // Both verdicts end in a row the server has contradicted, so this delete
363
291
  // reconciles rather than waits (R2): whichever folder actually holds the
364
292
  // message re-projects it with the server's own uid. RECONCILED, the local
365
- // rows are gone and the resync rebuilds them. BROKEN, the server has just
366
- // confirmed the message is still at the source while the optimistic
367
- // `updateForMove` left the row pointing at Trash — without the resync the
368
- // user sees it in Trash for as long as that row stands, which is the
369
- // silent misdirection the settle exists to end.
293
+ // rows are gone and the resync rebuilds them. BROKEN, the row has just
294
+ // been put back at the source the server confirmed, and the resync is
295
+ // what carries any drift either folder has picked up since.
370
296
  if (destinationMailboxId) {
371
297
  await emitMoveResync(emitEvent, {
372
298
  accountId,
@@ -0,0 +1,77 @@
1
+ import type { ThreadMessageItem } from "@remit/data-ports";
2
+
3
+ /**
4
+ * Build the `set` and `composites` payload for the ThreadMessage update on a
5
+ * MESSAGE_DELETE move-to-trash.
6
+ *
7
+ * The CURRENT row state goes in `composites`; the NEW values go in `set`.
8
+ * ElectroDB uses `composites` to run the conditional check on the existing row
9
+ * AND to compute the previous sort-key values needed to recompute the new ones.
10
+ * Passing the NEW values in `composites` makes the conditional check fail with
11
+ * ConditionalCheckFailedException, which ElectroDB wraps as NotFoundError, and
12
+ * the caller silently drops the update. Same root cause as PR #186 fixed for
13
+ * `flag-queue.ts`.
14
+ */
15
+ export type ThreadMessageRowState = Pick<
16
+ ThreadMessageItem,
17
+ | "sentDate"
18
+ | "mailboxId"
19
+ | "isRead"
20
+ | "isDeleted"
21
+ | "hasStars"
22
+ | "hasAttachment"
23
+ >;
24
+
25
+ const currentComposites = (threadMessage: ThreadMessageRowState) => ({
26
+ sentDate: threadMessage.sentDate,
27
+ mailboxId: threadMessage.mailboxId,
28
+ isRead: threadMessage.isRead,
29
+ isDeleted: threadMessage.isDeleted,
30
+ hasStars: threadMessage.hasStars,
31
+ hasAttachment: threadMessage.hasAttachment,
32
+ });
33
+
34
+ export const buildThreadMessageTrashUpdate = (
35
+ threadMessage: ThreadMessageRowState,
36
+ newUid: number,
37
+ destinationMailboxId: string,
38
+ ) => ({
39
+ set: {
40
+ uid: newUid,
41
+ mailboxId: destinationMailboxId,
42
+ isDeleted: true,
43
+ },
44
+ composites: currentComposites(threadMessage),
45
+ });
46
+
47
+ /**
48
+ * The inverse payload: put the thread row back where the message still is on
49
+ * the server. The delete was recorded optimistically, so a delete that gives up
50
+ * with the message still at its source has to hand the row back rather than
51
+ * leave it claiming Trash — an invisible `failed` on a row the user cannot see
52
+ * is the shape of the incident this whole change is about.
53
+ */
54
+ export const buildThreadMessageMoveRevert = (
55
+ threadMessage: ThreadMessageRowState,
56
+ sourceUid: number,
57
+ sourceMailboxId: string,
58
+ ) => ({
59
+ set: {
60
+ uid: sourceUid,
61
+ mailboxId: sourceMailboxId,
62
+ isDeleted: false,
63
+ },
64
+ composites: currentComposites(threadMessage),
65
+ });
66
+
67
+ /**
68
+ * Hand a row back after an abandoned expunge. The mail never left Trash, so
69
+ * only the deletion mark reverts — the uid and mailbox on the row are still
70
+ * where the server has it.
71
+ */
72
+ export const buildThreadMessageUndelete = (
73
+ threadMessage: ThreadMessageRowState,
74
+ ) => ({
75
+ set: { isDeleted: false },
76
+ composites: currentComposites(threadMessage),
77
+ });
package/src/processor.ts CHANGED
@@ -19,7 +19,8 @@ export const processEvent = async (
19
19
  /**
20
20
  * SQS's own delivery count for the record carrying this event (1 on first
21
21
  * delivery). Read by SYNC_MESSAGE_BODY, PLACEMENT_MOVE_PUSH, FLAG_PUSH,
22
- * APPEND_SENT_MESSAGE and MESSAGE_MOVE — each knows from it when this is
22
+ * APPEND_SENT_MESSAGE, MESSAGE_MOVE, MESSAGE_DELETE and MESSAGE_COPY — each
23
+ * knows from it when this is
23
24
  * the last attempt before the queue's own redrive policy would DLQ the
24
25
  * record, so it can resolve retry exhaustion into a terminal outcome
25
26
  * (issue #1270) instead of dead-lettering blindly.
@@ -46,7 +47,7 @@ export const processEvent = async (
46
47
  case "FLAG_PUSH":
47
48
  return handleFlagPush(event, log, receiveCount);
48
49
  case "MESSAGE_COPY":
49
- return handleMessageCopy(event, log);
50
+ return handleMessageCopy(event, log, receiveCount);
50
51
  case "EMPTY_TRASH":
51
52
  return handleEmptyTrash(event, log);
52
53
  case "APPEND_SENT_MESSAGE":