@remit/imap-worker 0.0.86 → 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 (71) 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.ts +1 -1
  8. package/src/failure-kind.ts +1 -1
  9. package/src/handlers/append-sent-message.sqlite.test.ts +176 -0
  10. package/src/handlers/append-sent-message.ts +3 -24
  11. package/src/handlers/delete-account-objects.test.ts +1 -1
  12. package/src/handlers/delete-account-objects.ts +1 -1
  13. package/src/handlers/empty-trash.sqlite.test.ts +209 -0
  14. package/src/handlers/empty-trash.ts +3 -24
  15. package/src/handlers/flag-push.ts +11 -18
  16. package/src/handlers/mailbox-management.sqlite.test.ts +265 -0
  17. package/src/handlers/mailbox-management.ts +12 -48
  18. package/src/handlers/message-copy.sqlite.test.ts +298 -0
  19. package/src/handlers/message-copy.ts +31 -49
  20. package/src/handlers/message-delete-terminal.ts +12 -10
  21. package/src/handlers/message-delete.sqlite.test.ts +291 -0
  22. package/src/handlers/message-delete.ts +26 -32
  23. package/src/handlers/message-move-terminal.ts +12 -10
  24. package/src/handlers/message-move.sqlite.test.ts +271 -0
  25. package/src/handlers/message-move.test.ts +76 -863
  26. package/src/handlers/message-move.ts +49 -39
  27. package/src/handlers/placement-move-push.test.ts +96 -329
  28. package/src/handlers/placement-move-push.ts +75 -29
  29. package/src/handlers/placement-push.sqlite.test.ts +298 -0
  30. package/src/handlers/probe-message-id.sqlite.test.ts +197 -0
  31. package/src/handlers/probe-message-id.ts +15 -0
  32. package/src/handlers/sync-mailboxes.test.ts +0 -1
  33. package/src/handlers/sync-mailboxes.ts +1 -1
  34. package/src/handlers/sync-message-body.sqlite.test.ts +314 -0
  35. package/src/handlers/sync-message-body.test.ts +3 -299
  36. package/src/handlers/sync-message-body.ts +21 -11
  37. package/src/handlers/sync-messages.sqlite.test.ts +183 -0
  38. package/src/handlers/sync-messages.ts +27 -33
  39. package/src/index.ts +5 -3
  40. package/src/mail-sync-selection.sqlite.test.ts +16 -18
  41. package/src/poller.ts +5 -4
  42. package/src/processor.test.ts +1 -1
  43. package/src/processor.ts +1 -1
  44. package/src/scheduler/attachment-sweep.sqlite.test.ts +74 -0
  45. package/src/scheduler/attachment-sweep.ts +1 -1
  46. package/src/scheduler/{calendar-subscriptions.test.ts → calendar-subscriptions.sqlite.test.ts} +43 -55
  47. package/src/scheduler/calendar-subscriptions.ts +6 -6
  48. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +203 -0
  49. package/src/scheduler/handler.ts +12 -1
  50. package/src/scheduler/loop.test.ts +2 -0
  51. package/src/scheduler/run-tick.sqlite.test.ts +192 -0
  52. package/src/scheduler/run-tick.ts +39 -1
  53. package/src/scheduler/runner.ts +21 -1
  54. package/src/scheduler/stranded-placement-redrive.ts +265 -0
  55. package/src/stranded-placement.sqlite.test.ts +795 -0
  56. package/src/test-helpers/test-world.ts +398 -0
  57. package/src/thread-join.sqlite.test.ts +115 -0
  58. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +138 -14
  59. package/src/with-oauth-lifecycle.ts +1 -1
  60. package/src/handlers/append-sent-message.test.ts +0 -529
  61. package/src/handlers/empty-trash.test.ts +0 -728
  62. package/src/handlers/flag-push-placement-race.test.ts +0 -385
  63. package/src/handlers/flag-push.test.ts +0 -325
  64. package/src/handlers/mailbox-management.test.ts +0 -850
  65. package/src/handlers/message-copy.test.ts +0 -654
  66. package/src/handlers/message-delete.test.ts +0 -1869
  67. package/src/handlers/message-move-terminal.test.ts +0 -412
  68. package/src/handlers/sync-messages.test.ts +0 -569
  69. package/src/scheduler/attachment-sweep.test.ts +0 -169
  70. package/src/scheduler/run-tick.test.ts +0 -478
  71. package/src/with-oauth-lifecycle.test.ts +0 -318
@@ -1,7 +1,7 @@
1
1
  import { getClient } from "@remit/backend/client";
2
2
  import { isNotFoundError } from "@remit/data-ports/errors";
3
- import type { Logger } from "@remit/logger-lambda";
4
- import { recordImapFailure } from "@remit/logger-lambda";
3
+ import type { Logger } from "@remit/logger";
4
+ import { recordImapFailure } from "@remit/logger-lambda/metrics";
5
5
  import {
6
6
  guardConnectionCursor,
7
7
  isCursorRebuildNeeded,
@@ -30,20 +30,7 @@ export const FLAG_PUSH_MAX_ATTEMPTS = getFlagPushMaxAttempts();
30
30
  * cycle one SQS round trip per sync tick forever instead of surfacing the
31
31
  * stall.
32
32
  */
33
- const DEFAULT_FLAG_PUSH_DEFER_MAX_MS = 10 * 60 * 1000;
34
-
35
- export const getFlagPushDeferMaxMs = (
36
- processEnv: NodeJS.ProcessEnv = process.env,
37
- ): number => {
38
- const raw = processEnv.FLAG_PUSH_DEFER_MAX_MS;
39
- if (!raw) return DEFAULT_FLAG_PUSH_DEFER_MAX_MS;
40
- const parsed = Number.parseInt(raw, 10);
41
- return Number.isFinite(parsed) && parsed > 0
42
- ? parsed
43
- : DEFAULT_FLAG_PUSH_DEFER_MAX_MS;
44
- };
45
-
46
- export const FLAG_PUSH_DEFER_MAX_MS = getFlagPushDeferMaxMs();
33
+ export const FLAG_PUSH_DEFER_MAX_MS = 10 * 60 * 1000;
47
34
 
48
35
  /**
49
36
  * Handle FLAG_PUSH events (issue #1273, epic #1281). Drains ONE pending
@@ -75,6 +62,7 @@ export const handleFlagPush = async (
75
62
  event: FlagPushEvent,
76
63
  log: Logger,
77
64
  receiveCount = 1,
65
+ createConnectionScope = createConnectionScopeWithCredentials,
78
66
  ): Promise<void> => {
79
67
  const {
80
68
  account: accountService,
@@ -202,7 +190,7 @@ export const handleFlagPush = async (
202
190
  account,
203
191
  log,
204
192
  async (credentials) => {
205
- const scope = createConnectionScopeWithCredentials(account, credentials);
193
+ const scope = createConnectionScope(account, credentials);
206
194
 
207
195
  await scope
208
196
  .getConnection()
@@ -261,8 +249,13 @@ export const handleFlagPush = async (
261
249
  // two terminal outcomes (epic invariant 3) instead of
262
250
  // dead-lettering with no diagnosis.
263
251
  const { outcome } = await resolveExhaustedFlagPushFailure(
264
- { markerService, messageService, threadMessageService, log },
252
+ { markerService, messageService, log },
265
253
  {
254
+ placement: {
255
+ status: message.status,
256
+ mailboxId: message.mailboxId,
257
+ uid: message.uid,
258
+ },
266
259
  accountId,
267
260
  accountConfigId,
268
261
  messageId,
@@ -0,0 +1,265 @@
1
+ import assert from "node:assert/strict";
2
+ import { after, before, describe, it } from "node:test";
3
+ import type { MailboxItem } from "@remit/data-ports";
4
+ import { MailboxSyncStatus } from "@remit/domain-enums";
5
+ import { imapError } from "@remit/mailbox-service/test-helpers/fake-imap";
6
+ import type { MailboxManagementEvent } from "../events.js";
7
+ import { openStore, type World } from "../test-helpers/test-world.js";
8
+ import { processMailboxManagement } from "./mailbox-management.js";
9
+
10
+ let store: Awaited<ReturnType<typeof openStore>>;
11
+
12
+ before(async () => {
13
+ store = await openStore();
14
+ });
15
+
16
+ after(() => store.close());
17
+
18
+ const lastEvent = (w: World, type: string): MailboxManagementEvent => {
19
+ const event = w
20
+ .queued()
21
+ .filter((body) => body.type === type)
22
+ .at(-1) as unknown as MailboxManagementEvent | undefined;
23
+ assert.ok(event, `a ${type} was enqueued`);
24
+ return event;
25
+ };
26
+
27
+ const run = (w: World, event: MailboxManagementEvent) =>
28
+ processMailboxManagement(event, w.log, w.scope);
29
+
30
+ const mailbox = (
31
+ w: World,
32
+ mailboxId: string,
33
+ ): Promise<MailboxItem | undefined> =>
34
+ w.client.mailbox
35
+ .listAllByAccount(w.account.accountId)
36
+ .then((rows) => rows.find((row) => row.mailboxId === mailboxId));
37
+
38
+ /** A folder created through the service the API calls, not yet on the server. */
39
+ const requestCreate = async (
40
+ w: World,
41
+ path: string,
42
+ subscribe?: boolean,
43
+ ): Promise<{ mailboxId: string; event: MailboxManagementEvent }> => {
44
+ const created = await w.client.mailboxQueue.createMailbox(
45
+ {
46
+ accountId: w.account.accountId,
47
+ namespacePrefix: "",
48
+ hierarchyDelimiter: "/",
49
+ fullPath: path,
50
+ uidValidity: 0,
51
+ uidNext: 0,
52
+ highestModseq: "0",
53
+ messageCount: 0,
54
+ unseenCount: 0,
55
+ deletedCount: 0,
56
+ totalSize: 0,
57
+ lastSyncUid: 0,
58
+ highWaterMarkUid: 0,
59
+ lastMessageSyncAt: 0,
60
+ },
61
+ w.account.accountId,
62
+ subscribe,
63
+ );
64
+ assert.equal(created.outcome, "Created");
65
+ if (created.outcome !== "Created") throw new Error("path taken");
66
+ return {
67
+ mailboxId: created.mailbox.mailboxId,
68
+ event: lastEvent(w, "MAILBOX_CREATE"),
69
+ };
70
+ };
71
+
72
+ describe("creating a folder", () => {
73
+ it("creates it on the server and settles the row on the server's UIDVALIDITY", async () => {
74
+ const w = await store.world();
75
+ const { mailboxId, event } = await requestCreate(w, "Receipts");
76
+
77
+ await run(w, event);
78
+
79
+ assert.ok(w.imap.hasFolder("Receipts"));
80
+ const row = await mailbox(w, mailboxId);
81
+ assert.equal(row?.syncStatus, MailboxSyncStatus.synced);
82
+ assert.equal(row?.uidValidity, w.imap.uidValidityOf("Receipts"));
83
+ });
84
+
85
+ it("subscribes only when the request asks for it", async () => {
86
+ const w = await store.world();
87
+ const quiet = await requestCreate(w, "Quiet");
88
+ const loud = await requestCreate(w, "Loud", true);
89
+
90
+ await run(w, quiet.event);
91
+ await run(w, loud.event);
92
+
93
+ assert.deepEqual(w.imap.commands("SUBSCRIBE"), ["SUBSCRIBE Loud"]);
94
+ });
95
+
96
+ it("settles a folder the server already has as created", async () => {
97
+ const w = await store.world();
98
+ const { mailboxId, event } = await requestCreate(w, "Receipts");
99
+ w.imap.addFolder("Receipts", { uidValidity: 7 });
100
+
101
+ await run(w, event);
102
+
103
+ assert.equal(
104
+ (await mailbox(w, mailboxId))?.syncStatus,
105
+ MailboxSyncStatus.synced,
106
+ );
107
+ });
108
+
109
+ it("marks the folder failed with the server's reason and acks a refused create", async () => {
110
+ const w = await store.world();
111
+ const { mailboxId, event } = await requestCreate(w, "Receipts");
112
+ w.imap.failNext.CREATE = imapError("NOPERM", "Permission denied");
113
+
114
+ await run(w, event);
115
+
116
+ const row = await mailbox(w, mailboxId);
117
+ assert.equal(row?.syncStatus, MailboxSyncStatus.failed);
118
+ assert.equal(row?.syncFailureReason, "Permission denied");
119
+ });
120
+
121
+ it("marks the folder failed and redelivers when the create fails for another reason", async () => {
122
+ const w = await store.world();
123
+ const { mailboxId, event } = await requestCreate(w, "Receipts");
124
+ w.imap.failNext.CREATE = new Error("Connection reset");
125
+
126
+ await assert.rejects(run(w, event), /Connection reset/);
127
+
128
+ assert.equal(
129
+ (await mailbox(w, mailboxId))?.syncStatus,
130
+ MailboxSyncStatus.failed,
131
+ );
132
+ });
133
+
134
+ it("acks when the folder row was deleted before the create ran", async () => {
135
+ const w = await store.world();
136
+ const { mailboxId, event } = await requestCreate(w, "Receipts");
137
+ await w.client.mailbox.delete(w.account.accountId, mailboxId);
138
+
139
+ await run(w, event);
140
+ });
141
+ });
142
+
143
+ describe("renaming a folder", () => {
144
+ const arrange = async () => {
145
+ const w = await store.world();
146
+ const work = await w.folder("Work");
147
+ const child = await w.folder("Work/Clients");
148
+ const message = await w.message(child);
149
+ await w.client.mailboxQueue.renameMailbox(
150
+ work.mailboxId,
151
+ "Projects",
152
+ w.account.accountId,
153
+ );
154
+ return { w, work, child, message, event: lastEvent(w, "MAILBOX_RENAME") };
155
+ };
156
+
157
+ it("renames on the server and settles the folder and its children onto the new path", async () => {
158
+ const { w, work, child, event } = await arrange();
159
+
160
+ await run(w, event);
161
+
162
+ assert.ok(w.imap.hasFolder("Projects/Clients"));
163
+ assert.equal((await mailbox(w, work.mailboxId))?.fullPath, "Projects");
164
+ const settledChild = await mailbox(w, child.mailboxId);
165
+ assert.equal(settledChild?.fullPath, "Projects/Clients");
166
+ assert.equal(settledChild?.syncStatus, MailboxSyncStatus.synced);
167
+ });
168
+
169
+ it("removes the folder, its children and their mail when the source is gone on the server", async () => {
170
+ const { w, work, child, message, event } = await arrange();
171
+ w.imap.removeFolder("Work");
172
+ w.imap.removeFolder("Work/Clients");
173
+
174
+ await run(w, event);
175
+
176
+ assert.equal(await mailbox(w, work.mailboxId), undefined);
177
+ assert.equal(await mailbox(w, child.mailboxId), undefined);
178
+ assert.equal(await w.row(message.messageId), undefined);
179
+ });
180
+
181
+ it("keeps the confirmed path and acks a refused rename", async () => {
182
+ const { w, work, event } = await arrange();
183
+ w.imap.failNext.RENAME = imapError("NOPERM", "Permission denied");
184
+
185
+ await run(w, event);
186
+
187
+ const row = await mailbox(w, work.mailboxId);
188
+ assert.equal(row?.fullPath, "Work");
189
+ assert.equal(row?.syncStatus, MailboxSyncStatus.failed);
190
+ assert.ok(w.imap.hasFolder("Work"));
191
+ });
192
+
193
+ it("issues no RENAME when a redelivery finds the rename already settled", async () => {
194
+ const { w, event } = await arrange();
195
+ await run(w, event);
196
+
197
+ await run(w, event);
198
+
199
+ assert.equal(w.imap.commands("RENAME").length, 1);
200
+ });
201
+ });
202
+
203
+ describe("deleting a folder", () => {
204
+ const arrange = async () => {
205
+ const w = await store.world();
206
+ const old = await w.folder("Old");
207
+ const message = await w.message(old);
208
+ await w.client.mailboxQueue.deleteMailbox(
209
+ old.mailboxId,
210
+ w.account.accountId,
211
+ );
212
+ return { w, old, message, event: lastEvent(w, "MAILBOX_DELETE") };
213
+ };
214
+
215
+ it("deletes it on the server and removes the folder and its mail", async () => {
216
+ const { w, old, message, event } = await arrange();
217
+
218
+ await run(w, event);
219
+
220
+ assert.equal(w.imap.hasFolder("Old"), false);
221
+ assert.equal(await mailbox(w, old.mailboxId), undefined);
222
+ assert.equal(await w.row(message.messageId), undefined);
223
+ });
224
+
225
+ it("removes the folder and its mail when the server no longer has it", async () => {
226
+ const { w, old, message, event } = await arrange();
227
+ w.imap.removeFolder("Old");
228
+
229
+ await run(w, event);
230
+
231
+ assert.equal(await mailbox(w, old.mailboxId), undefined);
232
+ assert.equal(await w.row(message.messageId), undefined);
233
+ });
234
+
235
+ it("marks the folder failed, keeps its mail and acks a refused delete", async () => {
236
+ const { w, old, message, event } = await arrange();
237
+ w.imap.failNext.DELETE = imapError("NOPERM", "Permission denied");
238
+
239
+ await run(w, event);
240
+
241
+ assert.equal(
242
+ (await mailbox(w, old.mailboxId))?.syncStatus,
243
+ MailboxSyncStatus.failed,
244
+ );
245
+ assert.ok(await w.row(message.messageId));
246
+ assert.ok(w.imap.hasFolder("Old"));
247
+ });
248
+
249
+ it("issues no DELETE for a folder that is not being deleted", async () => {
250
+ const w = await store.world();
251
+ const kept = await w.folder("Kept");
252
+
253
+ await run(w, {
254
+ type: "MAILBOX_DELETE",
255
+ eventId: "evt-stale",
256
+ timestamp: Date.now(),
257
+ accountId: w.account.accountId,
258
+ mailboxId: kept.mailboxId,
259
+ path: "Kept",
260
+ } as MailboxManagementEvent);
261
+
262
+ assert.deepEqual(w.imap.commands("DELETE"), []);
263
+ assert.ok(await mailbox(w, kept.mailboxId));
264
+ });
265
+ });
@@ -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
  };