@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
@@ -1,12 +1,17 @@
1
1
  import { getClient } from "@remit/backend/client";
2
- import { createLogger, withTelemetry } from "@remit/logger-lambda";
2
+ import { createLogger } from "@remit/logger";
3
+ import { installNodeLogger, withTelemetry } from "@remit/logger-lambda/node";
3
4
  import { createQueueProducer } from "@remit/sqs-client/producer";
4
5
  import type { ScheduledHandler } from "aws-lambda";
5
6
  import { env } from "expect-env";
7
+ import { emitEvent } from "../emit.js";
6
8
  import { buildAttachmentSweep } from "./attachment-sweep.js";
7
9
  import { buildCalendarSubscriptionRefresh } from "./calendar-subscriptions.js";
8
10
  import { getOfflineIntervalMs, getTickIntervalMs } from "./config.js";
9
11
  import { runSchedulerTick } from "./run-tick.js";
12
+ import { redriveStrandedPlacements } from "./stranded-placement-redrive.js";
13
+
14
+ installNodeLogger();
10
15
 
11
16
  const log = createLogger();
12
17
 
@@ -47,5 +52,11 @@ export const handler: ScheduledHandler = withTelemetry(async (event) => {
47
52
  // already gone and this sweep is the only thing that collects it.
48
53
  sweepAttachments: buildAttachmentSweep(client, log),
49
54
  refreshCalendarSubscriptions: buildCalendarSubscriptionRefresh(client, log),
55
+ redriveStrandedPlacements: (account, tickNow) =>
56
+ redriveStrandedPlacements(
57
+ { client, emit: emitEvent, log },
58
+ account,
59
+ tickNow,
60
+ ),
50
61
  });
51
62
  });
@@ -11,6 +11,8 @@ const RESULT: SchedulerTickResult = {
11
11
  sweepFailed: 0,
12
12
  subscriptionsRefreshed: 0,
13
13
  subscriptionsFailed: 0,
14
+ redriven: 0,
15
+ redriveFailed: 0,
14
16
  };
15
17
 
16
18
  // Only the loop's own two calls matter here; the tick is a stub, so what it is
@@ -0,0 +1,192 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, beforeEach, describe, it } from "node:test";
3
+ import type { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
4
+ import type { AccountItem } from "@remit/data-ports";
5
+ import {
6
+ AccountAuthType,
7
+ AccountService,
8
+ ConnectionState,
9
+ } from "@remit/domain-enums";
10
+ import { AccountRepo } from "@remit/drizzle-service";
11
+ import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
12
+ import { capturingLogger, type LogLine } from "../test-helpers/test-world.js";
13
+ import { runSchedulerTick } from "./run-tick.js";
14
+
15
+ const TICK_INTERVAL_MS = 60 * 60 * 1000;
16
+ const OFFLINE_INTERVAL_MS = 12 * 60 * 60 * 1000;
17
+ const NOW = 1_700_000_000_000;
18
+
19
+ let accounts: AccountRepo;
20
+ let close: () => void;
21
+
22
+ beforeEach(() => {
23
+ const store = createShippedSqliteDb();
24
+ close = store.close;
25
+ accounts = new AccountRepo(store.db, store.db);
26
+ });
27
+
28
+ afterEach(() => close());
29
+
30
+ const account = async (
31
+ name: string,
32
+ overrides: Partial<AccountItem> = {},
33
+ stored: { credential: boolean } = { credential: true },
34
+ ): Promise<AccountItem> => {
35
+ const created = await accounts.create({
36
+ accountConfigId: `cfg-${name}`,
37
+ username: `${name}@example.com`,
38
+ email: `${name}@example.com`,
39
+ authType: AccountAuthType.Password,
40
+ passwordHash: stored.credential ? '{"ciphertext":"x"}' : undefined,
41
+ imapHost: "imap.example.com",
42
+ imapPort: 993,
43
+ imapTls: true,
44
+ imapStartTls: false,
45
+ isActive: true,
46
+ connectionState: ConnectionState.Authenticated,
47
+ } as Parameters<AccountRepo["create"]>[0]);
48
+ if (Object.keys(overrides).length === 0) return created;
49
+ await accounts.update(created.accountId, overrides);
50
+ return accounts.get(created.accountId);
51
+ };
52
+
53
+ const tick = async (
54
+ options: Partial<Parameters<typeof runSchedulerTick>[0]> = {},
55
+ ) => {
56
+ const sent: SendMessageCommand[] = [];
57
+ const logs: LogLine[] = [];
58
+ const result = await runSchedulerTick({
59
+ accountService: accounts,
60
+ sqsClient: {
61
+ send: async (command: SendMessageCommand) => {
62
+ sent.push(command);
63
+ return {};
64
+ },
65
+ } as unknown as SQSClient,
66
+ queueUrl:
67
+ "https://sqs.eu-west-1.amazonaws.com/123/remit-dev-mailboxes.fifo",
68
+ log: capturingLogger(logs),
69
+ tickIntervalMs: TICK_INTERVAL_MS,
70
+ offlineIntervalMs: OFFLINE_INTERVAL_MS,
71
+ now: NOW,
72
+ redriveStrandedPlacements: async () => 0,
73
+ ...options,
74
+ });
75
+ const enqueued = sent.map(
76
+ (command) => JSON.parse(command.input.MessageBody ?? "{}").accountId,
77
+ );
78
+ return { result, sent, enqueued, logs };
79
+ };
80
+
81
+ describe("a scheduler tick", () => {
82
+ it("enqueues the accounts that are due and only those", async () => {
83
+ const due = await account("due", {
84
+ lastSyncAt: NOW - OFFLINE_INTERVAL_MS - 1,
85
+ });
86
+ const never = await account("never");
87
+ await account("fresh", { lastSyncAt: NOW - 60_000 });
88
+
89
+ const { enqueued } = await tick();
90
+
91
+ assert.deepEqual(enqueued.sort(), [due.accountId, never.accountId].sort());
92
+ });
93
+
94
+ it("skips an account it cannot or should not sync, and says so once in aggregate", async () => {
95
+ const live = await account("live");
96
+ await account("unsyncable", { imapHost: "mail.invalid" });
97
+ await account("reauth", {
98
+ connectionState: ConnectionState.ReauthRequired,
99
+ });
100
+ await account("calendar", { syncedServices: [AccountService.Calendar] });
101
+
102
+ const { enqueued, result, logs } = await tick();
103
+
104
+ assert.deepEqual(enqueued, [live.accountId]);
105
+ assert.equal(result.skipped, 3);
106
+ assert.equal(logs.length, 1, "one aggregate line, no per-account noise");
107
+ });
108
+
109
+ // Issue #1120: a sync for an account with no stored credential fenced the
110
+ // account's FIFO group for its whole visibility timeout.
111
+ it("skips an account that stores no credential", async () => {
112
+ const live = await account("live");
113
+ await account("missing", {}, { credential: false });
114
+
115
+ const { enqueued } = await tick();
116
+
117
+ assert.deepEqual(enqueued, [live.accountId]);
118
+ });
119
+
120
+ it("gives each enqueued account a scheduler-namespaced, time-bucketed dedup id", async () => {
121
+ const live = await account("live");
122
+
123
+ const { sent } = await tick();
124
+
125
+ const dedupId = sent[0]?.input.MessageDeduplicationId;
126
+ assert.ok(
127
+ dedupId?.startsWith(`SYNC_MAILBOXES:scheduled:${live.accountId}:`),
128
+ );
129
+ });
130
+
131
+ it("sweeps every account's attachments, due a sync or not", async () => {
132
+ const due = await account("due");
133
+ const fresh = await account("fresh", { lastSyncAt: NOW - 60_000 });
134
+ const swept: string[] = [];
135
+
136
+ const { result } = await tick({
137
+ sweepAttachments: async (item) => {
138
+ swept.push(item.accountId);
139
+ },
140
+ });
141
+
142
+ assert.deepEqual(swept.sort(), [due.accountId, fresh.accountId].sort());
143
+ assert.equal(result.swept, 2);
144
+ });
145
+
146
+ // Uncontained, one storage error reaches the loop, which exits, and the
147
+ // container restarts without ever enqueuing an account again.
148
+ it("keeps enqueuing mail when the attachment sweep throws", async () => {
149
+ const live = await account("live");
150
+
151
+ const { enqueued, result } = await tick({
152
+ sweepAttachments: async () => {
153
+ throw new Error("AccessDenied on ListObjectsV2");
154
+ },
155
+ });
156
+
157
+ assert.deepEqual(enqueued, [live.accountId]);
158
+ assert.equal(result.sweepFailed, 1);
159
+ });
160
+
161
+ it("keeps the tick alive when the calendar subscription refresh throws", async () => {
162
+ const live = await account("live");
163
+
164
+ const { enqueued, logs } = await tick({
165
+ refreshCalendarSubscriptions: async () => {
166
+ throw new Error("database is locked");
167
+ },
168
+ });
169
+
170
+ assert.deepEqual(enqueued, [live.accountId]);
171
+ assert.ok(logs.some((line) => line.level === "error"));
172
+ });
173
+
174
+ it("counts what each account re-drove and keeps enqueuing past one that fails", async () => {
175
+ const live = await account("live");
176
+ const busy = await account("busy");
177
+
178
+ const { enqueued, result, logs } = await tick({
179
+ redriveStrandedPlacements: async (redriven) => {
180
+ if (redriven.accountId === busy.accountId) {
181
+ throw new Error("SQLITE_BUSY");
182
+ }
183
+ return 2;
184
+ },
185
+ });
186
+
187
+ assert.deepEqual(enqueued.sort(), [live.accountId, busy.accountId].sort());
188
+ assert.equal(result.redriven, 2);
189
+ assert.equal(result.redriveFailed, 1);
190
+ assert.ok(logs.some((line) => line.level === "error"));
191
+ });
192
+ });
@@ -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
+ };