@remit/imap-worker 0.0.85 → 0.0.87

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.
Files changed (73) hide show
  1. package/README.md +3 -1
  2. package/package.json +10 -2
  3. package/src/account-check.ts +1 -1
  4. package/src/body-sync-gate.test.ts +1 -1
  5. package/src/body-sync-gate.ts +1 -1
  6. package/src/e2e-processor-shim.ts +6 -21
  7. package/src/emit.test.ts +32 -4
  8. package/src/emit.ts +58 -23
  9. package/src/failure-kind.ts +1 -1
  10. package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
  11. package/src/handlers/append-sent-message.ts +3 -24
  12. package/src/handlers/delete-account-objects.test.ts +1 -1
  13. package/src/handlers/delete-account-objects.ts +1 -1
  14. package/src/handlers/empty-trash.sqlite.test.ts +209 -0
  15. package/src/handlers/empty-trash.ts +3 -24
  16. package/src/handlers/flag-push.ts +11 -18
  17. package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
  18. package/src/handlers/mailbox-management.ts +12 -48
  19. package/src/handlers/message-copy.sqlite.test.ts +298 -0
  20. package/src/handlers/message-copy.ts +31 -49
  21. package/src/handlers/message-delete-terminal.ts +12 -10
  22. package/src/handlers/message-delete.sqlite.test.ts +291 -0
  23. package/src/handlers/message-delete.ts +26 -32
  24. package/src/handlers/message-move-terminal.ts +12 -10
  25. package/src/handlers/message-move.sqlite.test.ts +271 -0
  26. package/src/handlers/message-move.test.ts +76 -863
  27. package/src/handlers/message-move.ts +49 -39
  28. package/src/handlers/placement-move-push.test.ts +96 -329
  29. package/src/handlers/placement-move-push.ts +75 -29
  30. package/src/handlers/placement-push.sqlite.test.ts +298 -0
  31. package/src/handlers/probe-message-id.sqlite.test.ts +197 -0
  32. package/src/handlers/probe-message-id.ts +15 -0
  33. package/src/handlers/sync-mailboxes.test.ts +0 -1
  34. package/src/handlers/sync-mailboxes.ts +1 -1
  35. package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
  36. package/src/handlers/sync-message-body.test.ts +3 -299
  37. package/src/handlers/sync-message-body.ts +21 -11
  38. package/src/handlers/sync-messages.sqlite.test.ts +183 -0
  39. package/src/handlers/sync-messages.ts +35 -34
  40. package/src/index.ts +5 -3
  41. package/src/mail-sync-selection.sqlite.test.ts +20 -18
  42. package/src/pending-sync.sqlite.test.ts +167 -0
  43. package/src/poller.ts +5 -4
  44. package/src/processor.test.ts +1 -1
  45. package/src/processor.ts +1 -1
  46. package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
  47. package/src/scheduler/attachment-sweep.ts +1 -1
  48. package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
  49. package/src/scheduler/calendar-subscriptions.ts +6 -6
  50. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
  51. package/src/scheduler/handler.ts +12 -1
  52. package/src/scheduler/loop.test.ts +2 -0
  53. package/src/scheduler/run-tick.sqlite.test.ts +192 -0
  54. package/src/scheduler/run-tick.ts +39 -1
  55. package/src/scheduler/runner.ts +21 -1
  56. package/src/scheduler/stranded-placement-redrive.ts +265 -0
  57. package/src/stranded-placement.sqlite.test.ts +795 -0
  58. package/src/test-helpers/test-world.ts +398 -0
  59. package/src/thread-join.sqlite.test.ts +115 -0
  60. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +138 -14
  61. package/src/with-oauth-lifecycle.ts +1 -1
  62. package/src/handlers/append-sent-message.test.ts +0 -529
  63. package/src/handlers/empty-trash.test.ts +0 -728
  64. package/src/handlers/flag-push-placement-race.test.ts +0 -385
  65. package/src/handlers/flag-push.test.ts +0 -325
  66. package/src/handlers/mailbox-management.test.ts +0 -850
  67. package/src/handlers/message-copy.test.ts +0 -654
  68. package/src/handlers/message-delete.test.ts +0 -1869
  69. package/src/handlers/message-move-terminal.test.ts +0 -412
  70. package/src/handlers/sync-messages.test.ts +0 -567
  71. package/src/scheduler/attachment-sweep.test.ts +0 -169
  72. package/src/scheduler/run-tick.test.ts +0 -478
  73. package/src/with-oauth-lifecycle.test.ts +0 -318
@@ -3,7 +3,7 @@ import { refreshFolderAppointmentLabels } from "@remit/backend/folder-role-label
3
3
  import type { IMailboxRepository } from "@remit/data-ports";
4
4
  import { isNotFoundError, NotFoundError } from "@remit/data-ports/errors";
5
5
  import { MailboxSyncStatus } from "@remit/domain-enums";
6
- import type { Logger } from "@remit/logger-lambda";
6
+ import type { Logger } from "@remit/logger";
7
7
  import {
8
8
  FolderGoneUpstreamError,
9
9
  FolderRenameSettleError,
@@ -24,20 +24,6 @@ import type {
24
24
  import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
25
25
  import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
26
26
 
27
- export interface MailboxManagementDeps {
28
- getClient: typeof getClient;
29
- buildLifecycleDeps: typeof buildLifecycleDeps;
30
- withOAuthLifecycle: typeof withOAuthLifecycle;
31
- createConnectionScope: typeof createConnectionScopeWithCredentials;
32
- }
33
-
34
- const defaultDeps: MailboxManagementDeps = {
35
- getClient,
36
- buildLifecycleDeps,
37
- withOAuthLifecycle,
38
- createConnectionScope: createConnectionScopeWithCredentials,
39
- };
40
-
41
27
  /**
42
28
  * The outcome of a create, written against the state a create in flight is:
43
29
  * `pending` with no recorded rename target (D3, D10). Without the absence
@@ -85,15 +71,8 @@ const recordCreateOutcome = async (
85
71
  const handleCreate = async (
86
72
  event: MailboxCreateEvent,
87
73
  log: Logger,
88
- deps: MailboxManagementDeps,
74
+ createConnectionScope: typeof createConnectionScopeWithCredentials,
89
75
  ): Promise<void> => {
90
- const {
91
- getClient,
92
- buildLifecycleDeps,
93
- withOAuthLifecycle,
94
- createConnectionScope: createConnectionScopeWithCredentials,
95
- } = deps;
96
-
97
76
  const {
98
77
  account: accountService,
99
78
  mailbox: mailboxService,
@@ -118,7 +97,7 @@ const handleCreate = async (
118
97
  account,
119
98
  log,
120
99
  async (credentials) => {
121
- const scope = createConnectionScopeWithCredentials(account, credentials);
100
+ const scope = createConnectionScope(account, credentials);
122
101
  const managementService = new MailboxManagementService(
123
102
  mailboxService,
124
103
  log,
@@ -209,15 +188,8 @@ const handleCreate = async (
209
188
  const handleRename = async (
210
189
  event: MailboxRenameEvent,
211
190
  log: Logger,
212
- deps: MailboxManagementDeps,
191
+ createConnectionScope: typeof createConnectionScopeWithCredentials,
213
192
  ): Promise<void> => {
214
- const {
215
- getClient,
216
- buildLifecycleDeps,
217
- withOAuthLifecycle,
218
- createConnectionScope: createConnectionScopeWithCredentials,
219
- } = deps;
220
-
221
193
  const {
222
194
  account: accountService,
223
195
  accountSetting: accountSettingService,
@@ -246,7 +218,7 @@ const handleRename = async (
246
218
  account,
247
219
  log,
248
220
  async (credentials) => {
249
- const scope = createConnectionScopeWithCredentials(account, credentials);
221
+ const scope = createConnectionScope(account, credentials);
250
222
  const managementService = new MailboxManagementService(
251
223
  mailboxService,
252
224
  log,
@@ -321,8 +293,7 @@ const handleRename = async (
321
293
  // folder's mail. Removing the root row alone is the orphaning
322
294
  // bug: every descendant recorded the same intent (D6) and would
323
295
  // be left `pending` at a path that does not exist, with its mail
324
- // stranded out of every reader and still in the search index
325
- // (D8).
296
+ // stranded out of every reader (D8).
326
297
  if (error instanceof FolderGoneUpstreamError) {
327
298
  log.info(
328
299
  { accountId, mailboxId, oldPath, intent: "rename" },
@@ -387,15 +358,8 @@ const handleRename = async (
387
358
  const handleDelete = async (
388
359
  event: MailboxDeleteEvent,
389
360
  log: Logger,
390
- deps: MailboxManagementDeps,
361
+ createConnectionScope: typeof createConnectionScopeWithCredentials,
391
362
  ): Promise<void> => {
392
- const {
393
- getClient,
394
- buildLifecycleDeps,
395
- withOAuthLifecycle,
396
- createConnectionScope: createConnectionScopeWithCredentials,
397
- } = deps;
398
-
399
363
  const {
400
364
  account: accountService,
401
365
  mailbox: mailboxService,
@@ -420,7 +384,7 @@ const handleDelete = async (
420
384
  account,
421
385
  log,
422
386
  async (credentials) => {
423
- const scope = createConnectionScopeWithCredentials(account, credentials);
387
+ const scope = createConnectionScope(account, credentials);
424
388
  const managementService = new MailboxManagementService(
425
389
  mailboxService,
426
390
  log,
@@ -509,14 +473,14 @@ const handleDelete = async (
509
473
  export const processMailboxManagement = async (
510
474
  event: MailboxManagementEvent,
511
475
  log: Logger,
512
- deps: MailboxManagementDeps = defaultDeps,
476
+ createConnectionScope = createConnectionScopeWithCredentials,
513
477
  ): Promise<void> => {
514
478
  switch (event.type) {
515
479
  case "MAILBOX_CREATE":
516
- return handleCreate(event, log, deps);
480
+ return handleCreate(event, log, createConnectionScope);
517
481
  case "MAILBOX_RENAME":
518
- return handleRename(event, log, deps);
482
+ return handleRename(event, log, createConnectionScope);
519
483
  case "MAILBOX_DELETE":
520
- return handleDelete(event, log, deps);
484
+ return handleDelete(event, log, createConnectionScope);
521
485
  }
522
486
  };
@@ -0,0 +1,298 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, it } from "node:test";
3
+ import {
4
+ MailboxCursorState,
5
+ MessageMutation,
6
+ MessageStatus,
7
+ MessageSyncStatus,
8
+ } from "@remit/domain-enums";
9
+ import type {
10
+ MessageCopyEvent,
11
+ MessageDeleteEvent,
12
+ MessageMoveEvent,
13
+ } from "../events.js";
14
+ import { mail, openStore, type World } from "../test-helpers/test-world.js";
15
+ import { handleMessageCopy } from "./message-copy.js";
16
+ import { handleMessageDelete } from "./message-delete.js";
17
+ import { handleMessageMove } from "./message-move.js";
18
+
19
+ let store: Awaited<ReturnType<typeof openStore>>;
20
+
21
+ before(async () => {
22
+ store = await openStore();
23
+ });
24
+
25
+ after(() => store.close());
26
+
27
+ interface Arranged {
28
+ w: World;
29
+ copyId: string;
30
+ sourceMessageId: string;
31
+ uid: number;
32
+ messageIdHeader: string;
33
+ inboxId: string;
34
+ projectsId: string;
35
+ event: MessageCopyEvent;
36
+ }
37
+
38
+ /** A message in INBOX, copied to Projects through the service the API calls. */
39
+ const arrange = async (
40
+ options: { olderCopyInProjects?: boolean } = {},
41
+ ): Promise<Arranged> => {
42
+ const w = await store.world();
43
+ const inbox = await w.folder("INBOX");
44
+ const projects = await w.folder("Projects");
45
+ const seeded = await w.message(inbox);
46
+ if (options.olderCopyInProjects) {
47
+ w.imap.deliver("Projects", mail(seeded.messageIdHeader));
48
+ }
49
+
50
+ const copyId = await w.client.messageMove.copyMessage(
51
+ w.account.accountConfigId,
52
+ seeded.messageId,
53
+ projects.mailboxId,
54
+ w.account.accountId,
55
+ );
56
+ const event = w
57
+ .queued()
58
+ .find(
59
+ (body) => body.type === "MESSAGE_COPY",
60
+ ) as unknown as MessageCopyEvent;
61
+ assert.ok(event, "the copy enqueued a MESSAGE_COPY");
62
+
63
+ return {
64
+ w,
65
+ copyId,
66
+ sourceMessageId: seeded.messageId,
67
+ uid: seeded.uid,
68
+ messageIdHeader: seeded.messageIdHeader,
69
+ inboxId: inbox.mailboxId,
70
+ projectsId: projects.mailboxId,
71
+ event,
72
+ };
73
+ };
74
+
75
+ const run = (a: Arranged, receiveCount = 1) =>
76
+ handleMessageCopy(a.event, a.w.log, receiveCount, a.w.scope);
77
+
78
+ describe("a message copy", () => {
79
+ it("lands at the destination, leaves the source alone and settles the copy row", async () => {
80
+ const a = await arrange();
81
+
82
+ await run(a);
83
+
84
+ assert.deepEqual(a.w.imap.uids("INBOX"), [a.uid]);
85
+ const [copyUid] = a.w.imap.uids("Projects", a.messageIdHeader);
86
+ const row = await a.w.row(a.copyId);
87
+ assert.equal(row?.mailboxId, a.projectsId);
88
+ assert.equal(row?.uid, copyUid);
89
+ assert.equal(row?.status, MessageStatus.active);
90
+ assert.equal(row?.syncStatus, MessageSyncStatus.synced);
91
+ assert.equal((await a.w.row(a.sourceMessageId))?.mailboxId, a.inboxId);
92
+ });
93
+
94
+ it("settles on the copy this event made when the server sends no COPYUID", async () => {
95
+ const a = await arrange({ olderCopyInProjects: true });
96
+ a.w.imap.uidplus = false;
97
+
98
+ await run(a);
99
+
100
+ const copies = a.w.imap.uids("Projects", a.messageIdHeader);
101
+ assert.equal(copies.length, 2);
102
+ assert.equal((await a.w.row(a.copyId))?.uid, Math.max(...copies));
103
+ });
104
+
105
+ it("reconciles the copy row away when the destination does not hold the copy", async () => {
106
+ const a = await arrange();
107
+ a.w.imap.uidplus = false;
108
+ a.w.imap.ignoreNext.add("COPY");
109
+
110
+ await run(a);
111
+
112
+ assert.equal(await a.w.row(a.copyId), undefined);
113
+ assert.equal(
114
+ a.w.logs.filter(
115
+ (line) => line.fields.metric === "message_copy_not_landed",
116
+ ).length,
117
+ 1,
118
+ );
119
+ });
120
+
121
+ // COPY has no source-side effect, so a second one is a duplicate the user
122
+ // then has to find and delete.
123
+ it("never copies twice when a redelivery finds the copy already landed", async () => {
124
+ const a = await arrange();
125
+ a.w.imap.deliver("Projects", mail(a.messageIdHeader));
126
+
127
+ await run(a, 2);
128
+
129
+ assert.deepEqual(a.w.imap.commands("COPY"), []);
130
+ assert.equal(a.w.imap.uids("Projects", a.messageIdHeader).length, 1);
131
+ assert.equal((await a.w.row(a.copyId))?.status, MessageStatus.active);
132
+ });
133
+
134
+ it("issues nothing when a redelivery finds the copy already settled", async () => {
135
+ const a = await arrange();
136
+ await run(a);
137
+ const commands = a.w.imap.log.length;
138
+
139
+ await run(a);
140
+
141
+ assert.equal(a.w.imap.log.length, commands);
142
+ });
143
+
144
+ it("creates a destination the server does not have and redelivers", async () => {
145
+ const a = await arrange();
146
+ a.w.imap.removeFolder("Projects");
147
+
148
+ await assert.rejects(run(a), /TRYCREATE/);
149
+
150
+ assert.deepEqual(a.w.imap.commands("CREATE"), ["CREATE Projects"]);
151
+ });
152
+
153
+ it("gives the copy up when the source message is gone on the server", async () => {
154
+ const a = await arrange();
155
+ a.w.imap.failNext.COPY = new Error("Message not found (NONEXISTENT)");
156
+
157
+ await run(a);
158
+
159
+ const row = await a.w.row(a.copyId);
160
+ assert.equal(row?.status, MessageStatus.deleted);
161
+ assert.equal(row?.syncStatus, MessageSyncStatus.abandoned);
162
+ assert.equal(row?.abandonedMutation, MessageMutation.copy);
163
+ });
164
+
165
+ it("marks the copy failed and redelivers when the connection drops", async () => {
166
+ const a = await arrange();
167
+ a.w.imap.failNext.COPY = new Error("Connection reset");
168
+
169
+ await assert.rejects(run(a), /Connection reset/);
170
+
171
+ assert.equal(
172
+ (await a.w.row(a.copyId))?.syncStatus,
173
+ MessageSyncStatus.failed,
174
+ );
175
+ });
176
+
177
+ it("settles on the destination's copy at the ceiling instead of dead-lettering", async () => {
178
+ const a = await arrange();
179
+ a.w.imap.deliver("Projects", mail(a.messageIdHeader));
180
+ a.w.imap.failNext.COPY = new Error("Connection reset");
181
+
182
+ await run(a, 3);
183
+
184
+ const [copyUid] = a.w.imap.uids("Projects", a.messageIdHeader);
185
+ const row = await a.w.row(a.copyId);
186
+ assert.equal(row?.uid, copyUid);
187
+ assert.equal(row?.status, MessageStatus.active);
188
+ });
189
+
190
+ it("reconciles the copy row away at the ceiling when nothing reached the destination", async () => {
191
+ const a = await arrange();
192
+ a.w.imap.failNext.COPY = new Error("Connection reset");
193
+
194
+ await run(a, 3);
195
+
196
+ assert.equal(await a.w.row(a.copyId), undefined);
197
+ });
198
+
199
+ it("reconciles the copy row away without connecting when the destination mailbox was deleted", async () => {
200
+ const a = await arrange();
201
+ await a.w.client.mailbox.delete(a.w.account.accountId, a.projectsId);
202
+
203
+ await run(a);
204
+
205
+ assert.equal(a.w.imap.connections, 0);
206
+ assert.equal(await a.w.row(a.copyId), undefined);
207
+ });
208
+
209
+ it("reconciles the copy row away without connecting while the source cursor is rebuilding", async () => {
210
+ const a = await arrange();
211
+ await a.w.client.mailbox.update(a.w.account.accountId, a.inboxId, {
212
+ cursorState: MailboxCursorState.rebuilding,
213
+ });
214
+
215
+ await run(a);
216
+
217
+ assert.equal(a.w.imap.connections, 0);
218
+ assert.equal(await a.w.row(a.copyId), undefined);
219
+ });
220
+
221
+ it("copies nothing when the source's UIDVALIDITY changed under it", async () => {
222
+ const a = await arrange();
223
+ a.w.imap.setUidValidity("INBOX", 2);
224
+
225
+ await run(a);
226
+
227
+ assert.deepEqual(a.w.imap.commands("COPY"), []);
228
+ assert.equal(await a.w.row(a.copyId), undefined);
229
+ });
230
+
231
+ it("acks without connecting when the source mailbox was deleted", async () => {
232
+ const a = await arrange();
233
+ await a.w.client.mailbox.delete(a.w.account.accountId, a.inboxId);
234
+
235
+ await run(a);
236
+
237
+ assert.equal(a.w.imap.connections, 0);
238
+ });
239
+ });
240
+
241
+ describe("a fresh copy acted on before the server confirms it", () => {
242
+ const isMove = (
243
+ body: Record<string, unknown>,
244
+ ): body is Record<string, unknown> & MessageMoveEvent =>
245
+ body.type === "MESSAGE_MOVE";
246
+ const isDelete = (
247
+ body: Record<string, unknown>,
248
+ ): body is Record<string, unknown> & MessageDeleteEvent =>
249
+ body.type === "MESSAGE_DELETE";
250
+
251
+ it("waits for the copy to settle, then moves the copy and leaves the original", async () => {
252
+ const a = await arrange();
253
+
254
+ const moving = a.w.client.messageMove.moveMessage(
255
+ a.w.account.accountConfigId,
256
+ a.copyId,
257
+ a.inboxId,
258
+ a.w.account.accountId,
259
+ );
260
+ await run(a);
261
+ await moving;
262
+ const [copyUid] = a.w.imap.uids("Projects", a.messageIdHeader);
263
+ const event = a.w.queued().find(isMove);
264
+ assert.ok(event, "the move enqueued a MESSAGE_MOVE");
265
+ assert.equal(event.uid, copyUid);
266
+
267
+ await handleMessageMove(event, a.w.log, 1, a.w.scope);
268
+
269
+ assert.deepEqual(a.w.imap.uids("Projects", a.messageIdHeader), []);
270
+ assert.equal(a.w.imap.uids("INBOX", a.messageIdHeader).length, 2);
271
+ const row = await a.w.row(a.copyId);
272
+ assert.equal(row?.mailboxId, a.inboxId);
273
+ assert.equal(row?.status, MessageStatus.active);
274
+ assert.equal((await a.w.row(a.sourceMessageId))?.uid, a.uid);
275
+ });
276
+
277
+ it("waits for the copy to settle, then expunges the copy and leaves the original", async () => {
278
+ const a = await arrange();
279
+
280
+ const deleting = a.w.client.messageMove.deleteMessages(
281
+ a.w.account.accountConfigId,
282
+ [a.copyId],
283
+ a.w.account.accountId,
284
+ { permanent: true },
285
+ );
286
+ await run(a);
287
+ assert.deepEqual((await deleting).refusedMessageIds, []);
288
+ const event = a.w.queued().find(isDelete);
289
+ assert.ok(event, "the delete enqueued a MESSAGE_DELETE");
290
+
291
+ await handleMessageDelete(event, a.w.log, 1, a.w.scope);
292
+
293
+ assert.deepEqual(a.w.imap.uids("Projects", a.messageIdHeader), []);
294
+ assert.deepEqual(a.w.imap.uids("INBOX"), [a.uid]);
295
+ assert.equal(await a.w.row(a.copyId), undefined);
296
+ assert.equal((await a.w.row(a.sourceMessageId))?.mailboxId, a.inboxId);
297
+ });
298
+ });
@@ -4,13 +4,12 @@ import {
4
4
  MessageStatus,
5
5
  MessageSyncStatus,
6
6
  } from "@remit/domain-enums";
7
- import type { Logger } from "@remit/logger-lambda";
7
+ import type { Logger } from "@remit/logger";
8
8
  import {
9
9
  guardConnectionCursor,
10
10
  type IImapConnection,
11
11
  isCursorRebuildNeeded,
12
12
  MailboxCursorPausedError,
13
- reconcileStaleMessage,
14
13
  } from "@remit/mailbox-service";
15
14
  import { attemptBudget } from "@remit/sqs-client/attempt-budget";
16
15
  import { isAccountDeleted } from "../account-check.js";
@@ -24,22 +23,7 @@ import {
24
23
  emitMoveResync,
25
24
  searchMailboxForHighestMessageIdUid,
26
25
  } from "./message-move.js";
27
-
28
- export interface MessageCopyDeps {
29
- getClient: typeof getClient;
30
- buildLifecycleDeps: typeof buildLifecycleDeps;
31
- withOAuthLifecycle: typeof withOAuthLifecycle;
32
- createConnectionScope: typeof createConnectionScopeWithCredentials;
33
- emitEvent: typeof emitEvent;
34
- }
35
-
36
- const defaultDeps: MessageCopyDeps = {
37
- getClient,
38
- buildLifecycleDeps,
39
- withOAuthLifecycle,
40
- createConnectionScope: createConnectionScopeWithCredentials,
41
- emitEvent,
42
- };
26
+ import { probeableMessageIdHeader } from "./probe-message-id.js";
43
27
 
44
28
  export const getMessageCopyMaxAttempts = (
45
29
  processEnv: NodeJS.ProcessEnv = process.env,
@@ -49,8 +33,8 @@ export const MESSAGE_COPY_MAX_ATTEMPTS = getMessageCopyMaxAttempts();
49
33
 
50
34
  /**
51
35
  * Where the destination says this copy is. `unprobeable` is the row that
52
- * carries no Message-ID header (`messageIdHeader` is optional on Message, and
53
- * drafts and automated senders do arrive without one): the server was never
36
+ * carries no usable Message-ID header (drafts, automated senders and bounces
37
+ * do arrive without one): the server was never
54
38
  * asked, so its silence says nothing.
55
39
  */
56
40
  export type CopyProbe =
@@ -85,16 +69,8 @@ export const handleMessageCopy = async (
85
69
  event: MessageCopyEvent,
86
70
  log: Logger,
87
71
  receiveCount = 1,
88
- deps: MessageCopyDeps = defaultDeps,
72
+ createConnectionScope = createConnectionScopeWithCredentials,
89
73
  ): Promise<void> => {
90
- const {
91
- getClient,
92
- buildLifecycleDeps,
93
- withOAuthLifecycle,
94
- createConnectionScope: createConnectionScopeWithCredentials,
95
- emitEvent,
96
- } = deps;
97
-
98
74
  const {
99
75
  account: accountService,
100
76
  message: messageService,
@@ -153,6 +129,11 @@ export const handleMessageCopy = async (
153
129
  // whether THIS copy is still outstanding, and a `deleting` row is somebody
154
130
  // else's work — reading it as unsettled is what would let the second COPY
155
131
  // through.
132
+ const copyPlacement = {
133
+ status: copyRow.status,
134
+ mailboxId: copyRow.mailboxId,
135
+ uid: copyRow.uid,
136
+ };
156
137
  if (copyRow.status !== MessageStatus.moving) {
157
138
  log.info(
158
139
  { accountId, newMessageId, uid: copyRow.uid, status: copyRow.status },
@@ -161,6 +142,11 @@ export const handleMessageCopy = async (
161
142
  return;
162
143
  }
163
144
 
145
+ // `failed` is an earlier attempt's mark, and the re-drive's: either may
146
+ // have issued a COPY whose answer was lost.
147
+ const earlierAttempt =
148
+ receiveCount > 1 || copyRow.syncStatus === MessageSyncStatus.failed;
149
+
164
150
  await withOAuthLifecycle(
165
151
  buildLifecycleDeps(secrets, accountService),
166
152
  account,
@@ -192,10 +178,9 @@ export const handleMessageCopy = async (
192
178
  destinationMailboxId,
193
179
  );
194
180
  if (destination.kind === "gone") {
195
- const { threadMessagesDeleted } = await reconcileStaleMessage(
196
- { messageService, threadMessageService },
197
- account.accountConfigId,
181
+ const reconciled = await messageService.deleteIfPlacement(
198
182
  newMessageId,
183
+ copyPlacement,
199
184
  );
200
185
  log.warn(
201
186
  {
@@ -204,7 +189,7 @@ export const handleMessageCopy = async (
204
189
  sourceMessageId,
205
190
  newMessageId,
206
191
  mailboxId: destinationMailboxId,
207
- threadMessagesDeleted,
192
+ reconciled,
208
193
  },
209
194
  "Skipping MESSAGE_COPY: destination mailbox no longer exists (deleted); optimistic copy row reconciled away",
210
195
  );
@@ -217,21 +202,19 @@ export const handleMessageCopy = async (
217
202
  // handle: a `guardConnectionCursor` wrap is bound to the SOURCE mailbox
218
203
  // snapshot alone, and opening the destination through it trips that
219
204
  // mailbox into a rebuild.
220
- //
221
- // This is the call site with no source-side gate to fall back on
222
- // (#1122): a COPY leaves the source untouched, so nothing here can rule
223
- // an older destination copy out the way `confirmTrashMoveUid` does. The
224
- // fresh copy is the highest matching uid, and `deriveCopyMessageId`
225
- // makes a repeated copy of the same message a second server COPY, so
226
- // this probe must never bind to the earlier one.
227
205
  const probeDestination = async (
228
206
  connection: IImapConnection,
229
207
  ): Promise<CopyProbe> => {
230
- if (!copyRow.messageIdHeader) return { kind: "unprobeable" };
208
+ const messageIdHeader = await probeableMessageIdHeader(
209
+ threadMessageService,
210
+ account.accountConfigId,
211
+ newMessageId,
212
+ );
213
+ if (!messageIdHeader) return { kind: "unprobeable" };
231
214
  const probedUid = await searchMailboxForHighestMessageIdUid(
232
215
  connection,
233
216
  destinationMailboxPath,
234
- copyRow.messageIdHeader,
217
+ messageIdHeader,
235
218
  );
236
219
  return probedUid === null
237
220
  ? { kind: "absent" }
@@ -288,10 +271,9 @@ export const handleMessageCopy = async (
288
271
  // reality, the same terminal outcome `resolveExhaustedMessageMoveFailure`
289
272
  // calls `reconciled`. Routine: metric, no alarm.
290
273
  const settleNeverLanded = async (reason: string): Promise<void> => {
291
- const { threadMessagesDeleted } = await reconcileStaleMessage(
292
- { messageService, threadMessageService },
293
- account.accountConfigId,
274
+ const reconciled = await messageService.deleteIfPlacement(
294
275
  newMessageId,
276
+ copyPlacement,
295
277
  );
296
278
  log.info(
297
279
  {
@@ -300,7 +282,7 @@ export const handleMessageCopy = async (
300
282
  sourceMessageId,
301
283
  newMessageId,
302
284
  destination: destinationMailboxPath,
303
- threadMessagesDeleted,
285
+ reconciled,
304
286
  reason,
305
287
  },
306
288
  "Copy absent from its destination; optimistic copy row reconciled away",
@@ -352,7 +334,7 @@ export const handleMessageCopy = async (
352
334
  );
353
335
  };
354
336
 
355
- const scope = createConnectionScopeWithCredentials(account, credentials);
337
+ const scope = createConnectionScope(account, credentials);
356
338
 
357
339
  // A paused cursor settles rather than acks: acking leaves the optimistic
358
340
  // row at `uid: 0`/`moving` with nothing to re-enqueue it and no folder
@@ -380,7 +362,7 @@ export const handleMessageCopy = async (
380
362
  const settlePausedCopy = async (
381
363
  commandIssued: boolean,
382
364
  ): Promise<void> => {
383
- const mayHaveIssued = commandIssued || receiveCount > 1;
365
+ const mayHaveIssued = commandIssued || earlierAttempt;
384
366
 
385
367
  if (!mayHaveIssued) {
386
368
  await settleNeverLanded(
@@ -445,7 +427,7 @@ export const handleMessageCopy = async (
445
427
  // duplicates the mail. `absent` is an answer the server gave, so
446
428
  // copying again is safe; `unprobeable` is silence, and copying on
447
429
  // silence is the duplicate this guard exists to prevent.
448
- if (receiveCount > 1) {
430
+ if (earlierAttempt) {
449
431
  const earlier = await probeDestination(rawConnection);
450
432
  if (earlier.kind === "confirmed") {
451
433
  await settleCopied(earlier.uid);
@@ -1,14 +1,13 @@
1
1
  import type {
2
2
  IMessageRepository,
3
3
  IThreadMessageRepository,
4
+ MessageItem,
4
5
  } from "@remit/data-ports";
5
6
  import { MessageMutation, MessageSyncStatus } from "@remit/domain-enums";
6
7
  import {
7
8
  type IImapConnection,
8
9
  isMessageGoneFromOpenMailbox,
9
- reconcileStaleMessage,
10
10
  restoreSourcePlacement,
11
- type StaleMessageReconcileDeps,
12
11
  } from "@remit/mailbox-service";
13
12
 
14
13
  export interface MessageDeleteTerminalLogger {
@@ -16,9 +15,11 @@ export interface MessageDeleteTerminalLogger {
16
15
  error(obj: Record<string, unknown>, msg: string): void;
17
16
  }
18
17
 
19
- export interface ResolveExhaustedMessageDeleteDeps
20
- extends StaleMessageReconcileDeps {
21
- messageService: Pick<IMessageRepository, "delete" | "transitionPlacement">;
18
+ export interface ResolveExhaustedMessageDeleteDeps {
19
+ messageService: Pick<
20
+ IMessageRepository,
21
+ "deleteIfPlacement" | "transitionPlacement"
22
+ >;
22
23
  threadMessageService: Pick<
23
24
  IThreadMessageRepository,
24
25
  "findAllByMessageId" | "deleteMany" | "update"
@@ -27,6 +28,8 @@ export interface ResolveExhaustedMessageDeleteDeps
27
28
  }
28
29
 
29
30
  export interface ResolveExhaustedMessageDeleteInput {
31
+ /** Where the row stood when the caller read it; the delete holds only while it still does. */
32
+ placement: Pick<MessageItem, "status" | "mailboxId" | "uid">;
30
33
  accountId: string;
31
34
  accountConfigId: string;
32
35
  messageId: string;
@@ -53,7 +56,7 @@ export interface ResolveExhaustedMessageDeleteResult {
53
56
  * than by a FETCH coming back empty. The move to Trash landed server-side,
54
57
  * or a foreign client moved or expunged the message; from here those are
55
58
  * indistinguishable and have the same answer. The stale rows are deleted via
56
- * {@link reconcileStaleMessage} and the caller resyncs the affected folders,
59
+ * `deleteIfPlacement` and the caller resyncs the affected folders,
57
60
  * so whichever folder actually holds the message re-projects it with the
58
61
  * server's own UID. Metric only, no alarm — routine.
59
62
  * 2. BROKEN — the message is still at the source, so the delete never took
@@ -93,10 +96,9 @@ export const resolveExhaustedMessageDeleteFailure = async (
93
96
  await connection.openBox(sourceMailboxPath, true);
94
97
 
95
98
  if (await isMessageGoneFromOpenMailbox(connection, uid)) {
96
- const { threadMessagesDeleted } = await reconcileStaleMessage(
97
- deps,
98
- accountConfigId,
99
+ const reconciled = await deps.messageService.deleteIfPlacement(
99
100
  messageId,
101
+ input.placement,
100
102
  );
101
103
  deps.log.info(
102
104
  {
@@ -106,7 +108,7 @@ export const resolveExhaustedMessageDeleteFailure = async (
106
108
  messageId,
107
109
  uid,
108
110
  sourceMailboxPath,
109
- threadMessagesDeleted,
111
+ reconciled,
110
112
  },
111
113
  "Message no longer at its delete source after retry exhaustion (the delete landed server-side, or an external delete or move); stale row reconciled",
112
114
  );