@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
@@ -4,7 +4,7 @@ import {
4
4
  triggerAccountSync,
5
5
  } from "@remit/backend/trigger-sync";
6
6
  import type { AccountItem, IAccountRepository } from "@remit/data-ports";
7
- import type { Logger } from "@remit/logger-lambda";
7
+ import type { Logger } from "@remit/logger";
8
8
  import { hasStoredCredential } from "@remit/mailbox-service/account-credentials";
9
9
  import pMap from "p-map";
10
10
  import {
@@ -53,6 +53,10 @@ export interface RunSchedulerTickDeps {
53
53
  now: number,
54
54
  intervalMs: number,
55
55
  ) => Promise<CalendarSubscriptionTally>;
56
+ redriveStrandedPlacements: (
57
+ account: AccountItem,
58
+ now: number,
59
+ ) => Promise<number>;
56
60
  }
57
61
 
58
62
  export interface SchedulerTickResult {
@@ -63,6 +67,8 @@ export interface SchedulerTickResult {
63
67
  sweepFailed: number;
64
68
  subscriptionsRefreshed: number;
65
69
  subscriptionsFailed: number;
70
+ redriven: number;
71
+ redriveFailed: number;
66
72
  }
67
73
 
68
74
  /**
@@ -135,6 +141,8 @@ export const runSchedulerTick = async (
135
141
  let skipped = 0;
136
142
  let swept = 0;
137
143
  let sweepFailed = 0;
144
+ let redriven = 0;
145
+ let redriveFailed = 0;
138
146
 
139
147
  do {
140
148
  const page = await accountService.listAllAccountsPage({
@@ -202,6 +210,32 @@ export const runSchedulerTick = async (
202
210
  sweepFailed += outcomes.filter((ok) => !ok).length;
203
211
  }
204
212
 
213
+ // Contained like the sweep, and for the same reason: a failed re-drive
214
+ // costs one account this tick, never the scheduler. Eligible accounts
215
+ // only, since a re-driven event needs a connection to settle anything.
216
+ const redrives = await pMap(
217
+ page.items.filter(isEligible),
218
+ (account) =>
219
+ deps.redriveStrandedPlacements(account, now).then(
220
+ (count) => count,
221
+ (error: unknown) => {
222
+ log.error(
223
+ { accountId: account.accountId, error },
224
+ "Stranded placement re-drive failed for account",
225
+ );
226
+ return null;
227
+ },
228
+ ),
229
+ { concurrency: SCHEDULER_ENQUEUE_CONCURRENCY },
230
+ );
231
+ for (const count of redrives) {
232
+ if (count === null) {
233
+ redriveFailed += 1;
234
+ continue;
235
+ }
236
+ redriven += count;
237
+ }
238
+
205
239
  cursor = page.cursor ?? undefined;
206
240
  } while (cursor);
207
241
 
@@ -231,6 +265,8 @@ export const runSchedulerTick = async (
231
265
  sweepFailed,
232
266
  subscriptionsRefreshed,
233
267
  subscriptionsFailed,
268
+ redriven,
269
+ redriveFailed,
234
270
  },
235
271
  "Scheduled-sync tick complete",
236
272
  );
@@ -243,5 +279,7 @@ export const runSchedulerTick = async (
243
279
  sweepFailed,
244
280
  subscriptionsRefreshed,
245
281
  subscriptionsFailed,
282
+ redriven,
283
+ redriveFailed,
246
284
  };
247
285
  };
@@ -1,16 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  import { setTimeout as delay } from "node:timers/promises";
3
+ import { finishHalfDeletedAccounts } from "@remit/backend/account-deletion";
3
4
  import { getClient } from "@remit/backend/client";
4
- import { createLogger } from "@remit/logger-lambda";
5
+ import { createLogger } from "@remit/logger";
6
+ import { installNodeLogger } from "@remit/logger-lambda/node";
5
7
  import { clearHeartbeats, createHeartbeat } from "@remit/sqs-client/heartbeat";
6
8
  import { createQueueProducer } from "@remit/sqs-client/producer";
7
9
 
8
10
  import { env } from "expect-env";
11
+ import { emitEvent } from "../emit.js";
9
12
  import { buildAttachmentSweep } from "./attachment-sweep.js";
10
13
  import { buildCalendarSubscriptionRefresh } from "./calendar-subscriptions.js";
11
14
  import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
12
15
  import { runSchedulerLoop } from "./loop.js";
13
16
  import { runSchedulerTick } from "./run-tick.js";
17
+ import { redriveStrandedPlacements } from "./stranded-placement-redrive.js";
18
+
19
+ installNodeLogger();
14
20
 
15
21
  /**
16
22
  * The scheduled-sync runner for every deployment that has no EventBridge: the
@@ -36,6 +42,7 @@ const log = createLogger();
36
42
 
37
43
  const mailboxesQueueUrl = env.SQS_QUEUE_URL_MAILBOXES;
38
44
  const sqsClient = createQueueProducer({ queueUrl: mailboxesQueueUrl });
45
+ const accountFanoutQueueUrl = env.SQS_QUEUE_URL_ACCOUNT_FANOUT;
39
46
 
40
47
  const tickIntervalMs = getTickIntervalMs();
41
48
  const offlineIntervalMs = getOfflineIntervalMs();
@@ -71,6 +78,13 @@ const runLoop = async (): Promise<void> => {
71
78
  await clearHeartbeats().catch(onClearError);
72
79
  const client = await getClient();
73
80
  const { account } = client;
81
+ await finishHalfDeletedAccounts({
82
+ account,
83
+ configImport: client.configImport,
84
+ sqsClient: createQueueProducer({ queueUrl: accountFanoutQueueUrl }),
85
+ accountFanoutQueueUrl,
86
+ log,
87
+ });
74
88
  await runSchedulerLoop({
75
89
  tick: runSchedulerTick,
76
90
  tickDeps: {
@@ -85,6 +99,12 @@ const runLoop = async (): Promise<void> => {
85
99
  client,
86
100
  log,
87
101
  ),
102
+ redriveStrandedPlacements: (stranded, now) =>
103
+ redriveStrandedPlacements(
104
+ { client, emit: emitEvent, log },
105
+ stranded,
106
+ now,
107
+ ),
88
108
  },
89
109
  heartbeat: createHeartbeat("tick"),
90
110
  onHeartbeatError: onBeatError,
@@ -0,0 +1,265 @@
1
+ import type { RemitClient } from "@remit/backend/client";
2
+ import type { AccountItem, MessageItem } from "@remit/data-ports";
3
+ import { trashMailboxAt } from "@remit/data-ports/folder-role";
4
+ import {
5
+ deriveCopyMessageId,
6
+ deriveMessageId,
7
+ isValidMessageId,
8
+ } from "@remit/data-ports/id";
9
+ import { PATHLESS_MUTATION_EVENT_SCHEMA_VERSION } from "@remit/data-ports/mutation-events";
10
+ import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
11
+ import type { Logger } from "@remit/logger";
12
+ import { isCursorRebuildNeeded } from "@remit/mailbox-service";
13
+ import type {
14
+ MessageCopyEventV3,
15
+ MessageDeleteEventV3,
16
+ MessageMoveEventV3,
17
+ PlacementMovePushEvent,
18
+ } from "../events.js";
19
+
20
+ /** Well past three attempts on a 60 s visibility timeout. */
21
+ export const STRANDED_PLACEMENT_AFTER_MILLIS = 60 * 60 * 1000;
22
+
23
+ type Unstamped<T> = Omit<T, "eventId" | "timestamp">;
24
+
25
+ export type StrandedPlacementEvent =
26
+ | Unstamped<PlacementMovePushEvent>
27
+ | Unstamped<MessageDeleteEventV3>
28
+ | Unstamped<MessageMoveEventV3>
29
+ | Unstamped<MessageCopyEventV3>;
30
+
31
+ export interface StrandedPlacementRedriveDeps {
32
+ client: Pick<
33
+ RemitClient,
34
+ | "mailbox"
35
+ | "mailboxSpecialUse"
36
+ | "message"
37
+ | "placementMove"
38
+ | "threadMessage"
39
+ >;
40
+ emit: (event: StrandedPlacementEvent) => Promise<void>;
41
+ log: Logger;
42
+ }
43
+
44
+ const isStrandedCopy = (row: MessageItem): boolean =>
45
+ row.status === MessageStatus.moving &&
46
+ row.uid === 0 &&
47
+ typeof row.originalUid !== "number";
48
+
49
+ type CopyRedrive =
50
+ | { kind: "redrive"; event: Unstamped<MessageCopyEventV3> }
51
+ | { kind: "unresolvable"; reason: string }
52
+ | { kind: "wait" };
53
+
54
+ /**
55
+ * The message a uid-0 copy was made from: its id derives from the Message-ID
56
+ * the copy's listing row carries, and the copy's own id derives from it.
57
+ */
58
+ const copyRedriveFor = async (
59
+ client: StrandedPlacementRedriveDeps["client"],
60
+ account: AccountItem,
61
+ copy: MessageItem,
62
+ ): Promise<CopyRedrive> => {
63
+ const listing = await client.threadMessage.findByMessageId(
64
+ account.accountConfigId,
65
+ copy.messageId,
66
+ );
67
+ if (!listing) return { kind: "unresolvable", reason: "listing row missing" };
68
+ const header = listing.messageIdHeader;
69
+ if (!header || !isValidMessageId(header)) {
70
+ return { kind: "unresolvable", reason: "listing row has no Message-ID" };
71
+ }
72
+ const sourceId = deriveMessageId(account.accountId, header);
73
+ if (deriveCopyMessageId(sourceId, copy.mailboxId) !== copy.messageId) {
74
+ return {
75
+ kind: "unresolvable",
76
+ reason: "copy was not made from the message its Message-ID names",
77
+ };
78
+ }
79
+ const [source] = await client.message.get([sourceId]);
80
+ if (!source) return { kind: "unresolvable", reason: "source row missing" };
81
+ if (source.status === MessageStatus.deleted) {
82
+ return { kind: "unresolvable", reason: "source row deleted" };
83
+ }
84
+ if (source.status !== MessageStatus.active) return { kind: "wait" };
85
+ return {
86
+ kind: "redrive",
87
+ event: {
88
+ type: "MESSAGE_COPY",
89
+ schemaVersion: PATHLESS_MUTATION_EVENT_SCHEMA_VERSION,
90
+ accountId: account.accountId,
91
+ sourceMessageId: source.messageId,
92
+ newMessageId: copy.messageId,
93
+ sourceMailboxId: source.mailboxId,
94
+ destinationMailboxId: copy.mailboxId,
95
+ uid: source.uid,
96
+ },
97
+ };
98
+ };
99
+
100
+ /** A copy with no source to copy from has nothing to bind and nothing to hide. */
101
+ const removeUnresolvableCopy = async (
102
+ deps: StrandedPlacementRedriveDeps,
103
+ account: AccountItem,
104
+ row: MessageItem,
105
+ reason: string,
106
+ now: number,
107
+ ): Promise<boolean> => {
108
+ const removed = await deps.client.message.deleteIfPlacement(row.messageId, {
109
+ status: MessageStatus.moving,
110
+ mailboxId: row.mailboxId,
111
+ uid: 0,
112
+ });
113
+ if (!removed) return false;
114
+ deps.log.error(
115
+ {
116
+ alert: "stranded_copy_unresolvable",
117
+ accountId: account.accountId,
118
+ messageId: row.messageId,
119
+ mailboxId: row.mailboxId,
120
+ reason,
121
+ untouchedForMs: now - row.updatedAt,
122
+ },
123
+ "A copy stranded at uid 0 has no source to copy from; the copy row was removed",
124
+ );
125
+ return true;
126
+ };
127
+
128
+ const eventFor = async (
129
+ client: StrandedPlacementRedriveDeps["client"],
130
+ account: AccountItem,
131
+ row: MessageItem,
132
+ trashMailboxId: string | undefined,
133
+ ): Promise<StrandedPlacementEvent | undefined> => {
134
+ const { accountId, accountConfigId } = account;
135
+ const { messageId } = row;
136
+
137
+ if (row.status === MessageStatus.deleting) {
138
+ return {
139
+ type: "MESSAGE_DELETE",
140
+ schemaVersion: PATHLESS_MUTATION_EVENT_SCHEMA_VERSION,
141
+ accountId,
142
+ messageId,
143
+ mailboxId: row.mailboxId,
144
+ uid: row.uid,
145
+ operation: "permanent_delete",
146
+ };
147
+ }
148
+ if (
149
+ typeof row.originalMailboxId !== "string" ||
150
+ typeof row.originalUid !== "number"
151
+ ) {
152
+ return undefined;
153
+ }
154
+
155
+ const marker = await client.placementMove.find(messageId);
156
+ if (marker && marker.destinationMailboxId === row.mailboxId) {
157
+ return {
158
+ type: "PLACEMENT_MOVE_PUSH",
159
+ accountId,
160
+ accountConfigId,
161
+ messageId,
162
+ };
163
+ }
164
+ if (row.mailboxId === trashMailboxId) {
165
+ return {
166
+ type: "MESSAGE_DELETE",
167
+ schemaVersion: PATHLESS_MUTATION_EVENT_SCHEMA_VERSION,
168
+ accountId,
169
+ messageId,
170
+ mailboxId: row.originalMailboxId,
171
+ uid: row.originalUid,
172
+ operation: "move_to_trash",
173
+ destinationMailboxId: row.mailboxId,
174
+ };
175
+ }
176
+ return {
177
+ type: "MESSAGE_MOVE",
178
+ schemaVersion: PATHLESS_MUTATION_EVENT_SCHEMA_VERSION,
179
+ accountId,
180
+ messageId,
181
+ sourceMailboxId: row.originalMailboxId,
182
+ destinationMailboxId: row.mailboxId,
183
+ uid: row.originalUid,
184
+ };
185
+ };
186
+
187
+ /** The claim restarts the row's clock; a folder awaiting a cursor rebuild waits until it is normal. */
188
+ export const redriveStrandedPlacements = async (
189
+ deps: StrandedPlacementRedriveDeps,
190
+ account: AccountItem,
191
+ now: number,
192
+ ): Promise<number> => {
193
+ const { client, emit, log } = deps;
194
+ const { accountId } = account;
195
+
196
+ const mailboxes = await client.mailbox.listAllByAccount(accountId);
197
+ const paused = new Set(
198
+ mailboxes
199
+ .filter((mailbox) => isCursorRebuildNeeded(mailbox.cursorState))
200
+ .map((mailbox) => mailbox.mailboxId),
201
+ );
202
+ const stranded = (
203
+ await client.message.listStrandedPlacements(
204
+ mailboxes.map((mailbox) => mailbox.mailboxId),
205
+ now - STRANDED_PLACEMENT_AFTER_MILLIS,
206
+ )
207
+ ).filter(
208
+ (row) =>
209
+ !paused.has(row.mailboxId) &&
210
+ !(row.originalMailboxId && paused.has(row.originalMailboxId)),
211
+ );
212
+ if (stranded.length === 0) return 0;
213
+
214
+ const trash = trashMailboxAt(
215
+ await client.mailboxSpecialUse.resolveTrashRole(accountId),
216
+ "resolved",
217
+ );
218
+ const trashMailboxId = trash.allowed ? trash.mailbox.mailboxId : undefined;
219
+
220
+ let redriven = 0;
221
+ for (const row of stranded) {
222
+ const copy = isStrandedCopy(row)
223
+ ? await copyRedriveFor(client, account, row)
224
+ : undefined;
225
+ if (copy?.kind === "wait") continue;
226
+ if (copy?.kind === "unresolvable") {
227
+ if (await removeUnresolvableCopy(deps, account, row, copy.reason, now)) {
228
+ redriven += 1;
229
+ }
230
+ continue;
231
+ }
232
+ const event =
233
+ copy?.kind === "redrive"
234
+ ? copy.event
235
+ : await eventFor(client, account, row, trashMailboxId);
236
+ if (!event) continue;
237
+
238
+ // A copy's earlier COPY may have landed with its answer lost, so the
239
+ // re-drive marks it `failed`: the copy handler then asks the destination
240
+ // before it copies again.
241
+ const claimed = await client.message.transitionPlacement(
242
+ row.messageId,
243
+ { status: row.status, mailboxId: row.mailboxId, uid: row.uid },
244
+ {
245
+ syncStatus: copy ? MessageSyncStatus.failed : MessageSyncStatus.pending,
246
+ },
247
+ );
248
+ if (!claimed) continue;
249
+
250
+ await emit(event);
251
+ redriven += 1;
252
+ log.warn(
253
+ {
254
+ metric: "stranded_placement_redriven",
255
+ accountId,
256
+ messageId: row.messageId,
257
+ event: event.type,
258
+ syncStatus: row.syncStatus,
259
+ untouchedForMs: now - row.updatedAt,
260
+ },
261
+ "A move or delete had no event left to settle it; re-driven",
262
+ );
263
+ }
264
+ return redriven;
265
+ };