@remit/imap-worker 0.0.1

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 (54) hide show
  1. package/README.md +100 -0
  2. package/build.mjs +17 -0
  3. package/package.json +50 -0
  4. package/src/account-check.test.ts +122 -0
  5. package/src/account-check.ts +88 -0
  6. package/src/body-sync-gate.test.ts +185 -0
  7. package/src/body-sync-gate.ts +86 -0
  8. package/src/cli.ts +211 -0
  9. package/src/connection-scope.test.ts +266 -0
  10. package/src/connection-scope.ts +335 -0
  11. package/src/e2e-processor-shim.ts +248 -0
  12. package/src/emit.test.ts +44 -0
  13. package/src/emit.ts +142 -0
  14. package/src/events.ts +221 -0
  15. package/src/handlers/append-sent-message.ts +163 -0
  16. package/src/handlers/delete-account-objects.test.ts +81 -0
  17. package/src/handlers/delete-account-objects.ts +116 -0
  18. package/src/handlers/empty-trash.ts +136 -0
  19. package/src/handlers/flag-push.test.ts +25 -0
  20. package/src/handlers/flag-push.ts +224 -0
  21. package/src/handlers/mailbox-management.ts +266 -0
  22. package/src/handlers/mailbox-sync-order.test.ts +93 -0
  23. package/src/handlers/mailbox-sync-order.ts +65 -0
  24. package/src/handlers/message-copy.ts +219 -0
  25. package/src/handlers/message-delete.test.ts +176 -0
  26. package/src/handlers/message-delete.ts +283 -0
  27. package/src/handlers/message-move.test.ts +168 -0
  28. package/src/handlers/message-move.ts +298 -0
  29. package/src/handlers/placement-move-push.test.ts +234 -0
  30. package/src/handlers/placement-move-push.ts +434 -0
  31. package/src/handlers/sync-mailboxes.ts +241 -0
  32. package/src/handlers/sync-message-body.test.ts +375 -0
  33. package/src/handlers/sync-message-body.ts +337 -0
  34. package/src/handlers/sync-messages-deleted-account.test.ts +141 -0
  35. package/src/handlers/sync-messages.test.ts +204 -0
  36. package/src/handlers/sync-messages.ts +412 -0
  37. package/src/handlers/sync-reserved-host.test.ts +97 -0
  38. package/src/index.test.ts +22 -0
  39. package/src/index.ts +70 -0
  40. package/src/poller.ts +49 -0
  41. package/src/processor.test.ts +58 -0
  42. package/src/processor.ts +66 -0
  43. package/src/scheduler/config.test.ts +40 -0
  44. package/src/scheduler/config.ts +52 -0
  45. package/src/scheduler/decide-due.test.ts +44 -0
  46. package/src/scheduler/decide-due.ts +26 -0
  47. package/src/scheduler/handler.ts +52 -0
  48. package/src/scheduler/local-runner.ts +76 -0
  49. package/src/scheduler/run-tick.test.ts +248 -0
  50. package/src/scheduler/run-tick.ts +141 -0
  51. package/src/with-oauth-lifecycle-deps.ts +62 -0
  52. package/src/with-oauth-lifecycle.test.ts +227 -0
  53. package/src/with-oauth-lifecycle.ts +125 -0
  54. package/tsconfig.json +8 -0
@@ -0,0 +1,298 @@
1
+ import { getClient } from "@remit/backend/client";
2
+ import type { ThreadMessageItem } from "@remit/data-ports";
3
+ import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
4
+ import type { Logger } from "@remit/logger-lambda";
5
+ import {
6
+ guardConnectionCursor,
7
+ isCursorRebuildNeeded,
8
+ MailboxCursorPausedError,
9
+ } from "@remit/mailbox-service";
10
+ import { isAccountDeleted } from "../account-check.js";
11
+ import { createConnectionScopeWithCredentials } from "../connection-scope.js";
12
+ import { emitEvent } from "../emit.js";
13
+ import type { MessageMoveEvent, SyncMessagesEvent } from "../events.js";
14
+ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
15
+ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
16
+
17
+ type EmitSyncMessages = (
18
+ event: Omit<SyncMessagesEvent, "eventId" | "timestamp">,
19
+ ) => Promise<unknown>;
20
+
21
+ /**
22
+ * Re-read both folders' counts from IMAP after a move by enqueuing the existing
23
+ * per-folder SYNC_MESSAGES sync. Counts are a projection of IMAP, never mutated
24
+ * locally — the move shifted a message between source and destination, so both
25
+ * folders must refresh through the one-way pipeline.
26
+ */
27
+ export const emitMoveResync = async (
28
+ emit: EmitSyncMessages,
29
+ params: {
30
+ accountId: string;
31
+ sourceMailboxId: string;
32
+ destinationMailboxId: string;
33
+ },
34
+ ): Promise<void> => {
35
+ const { accountId, sourceMailboxId, destinationMailboxId } = params;
36
+ await Promise.all(
37
+ [sourceMailboxId, destinationMailboxId].map((mailboxId) =>
38
+ emit({ type: "SYNC_MESSAGES", accountId, mailboxId }),
39
+ ),
40
+ );
41
+ };
42
+
43
+ /**
44
+ * Resync the affected folders only once the IMAP move has resolved. A move that
45
+ * fails (or is retried) must not refresh counts off a move that didn't happen,
46
+ * so the resync is sequenced strictly after `performMove`.
47
+ */
48
+ export const moveThenResync = async (
49
+ performMove: () => Promise<void>,
50
+ resync: () => Promise<void>,
51
+ ): Promise<void> => {
52
+ await performMove();
53
+ await resync();
54
+ };
55
+
56
+ /**
57
+ * Build the `set` and `composites` payload for the ThreadMessage update on a
58
+ * MESSAGE_MOVE.
59
+ *
60
+ * The CURRENT row state goes in `composites`; the NEW values go in `set`.
61
+ * ElectroDB uses `composites` to run the conditional check on the existing row
62
+ * AND to compute the previous sort-key values needed to recompute the new ones.
63
+ * Passing the NEW values in `composites` makes the conditional check fail with
64
+ * ConditionalCheckFailedException, which ElectroDB wraps as NotFoundError, and
65
+ * the caller silently drops the update. Same root cause as PR #186 fixed for
66
+ * `flag-queue.ts`.
67
+ */
68
+ export const buildThreadMessageMoveUpdate = (
69
+ threadMessage: Pick<
70
+ ThreadMessageItem,
71
+ | "sentDate"
72
+ | "mailboxId"
73
+ | "isRead"
74
+ | "isDeleted"
75
+ | "hasStars"
76
+ | "hasAttachment"
77
+ >,
78
+ newUid: number,
79
+ destinationMailboxId: string,
80
+ ) => ({
81
+ set: {
82
+ uid: newUid,
83
+ mailboxId: destinationMailboxId,
84
+ isDeleted: false,
85
+ },
86
+ composites: {
87
+ sentDate: threadMessage.sentDate,
88
+ mailboxId: threadMessage.mailboxId,
89
+ isRead: threadMessage.isRead,
90
+ isDeleted: threadMessage.isDeleted,
91
+ hasStars: threadMessage.hasStars,
92
+ hasAttachment: threadMessage.hasAttachment,
93
+ },
94
+ });
95
+
96
+ /**
97
+ * Handle MESSAGE_MOVE events.
98
+ * Executes IMAP MOVE command and updates local state with new UID.
99
+ */
100
+ export const handleMessageMove = async (
101
+ event: MessageMoveEvent,
102
+ log: Logger,
103
+ ): Promise<void> => {
104
+ const {
105
+ account: accountService,
106
+ message: messageService,
107
+ threadMessage: threadMessageService,
108
+ mailbox: mailboxService,
109
+ secrets,
110
+ } = await getClient();
111
+
112
+ const {
113
+ accountId,
114
+ messageId,
115
+ sourceMailboxId,
116
+ sourceMailboxPath,
117
+ destinationMailboxPath,
118
+ destinationMailboxId,
119
+ uid,
120
+ } = event;
121
+
122
+ log.info(
123
+ {
124
+ event: event.type,
125
+ accountId,
126
+ messageId,
127
+ from: sourceMailboxPath,
128
+ to: destinationMailboxPath,
129
+ },
130
+ "Handling event",
131
+ );
132
+
133
+ const account = await accountService.get(accountId);
134
+ if (!account) {
135
+ throw new Error(`Account ${accountId} not found`);
136
+ }
137
+
138
+ if (isAccountDeleted(account, log)) {
139
+ return;
140
+ }
141
+
142
+ await withOAuthLifecycle(
143
+ buildLifecycleDeps(secrets, accountService),
144
+ account,
145
+ log,
146
+ async (credentials) => {
147
+ const mailbox = await mailboxService.get(accountId, sourceMailboxId);
148
+
149
+ // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
150
+ // paused never even opens a connection. Optimization only — the
151
+ // guardConnectionCursor openBox wrap below is the structural guarantee.
152
+ if (isCursorRebuildNeeded(mailbox.cursorState)) {
153
+ log.info(
154
+ { accountId, messageId, mailboxId: sourceMailboxId },
155
+ "Mailbox cursor not normal; pausing outbound move this round",
156
+ );
157
+ return;
158
+ }
159
+
160
+ const scope = createConnectionScopeWithCredentials(account, credentials);
161
+
162
+ await scope
163
+ .getConnection()
164
+ .then((rawConnection) => {
165
+ // Guard at the openBox choke point (epic #1281 invariants 3 & 5):
166
+ // a fresh mismatch trips the mailbox and throws once the SELECT
167
+ // reveals it. The move stays applied locally either way.
168
+ const connection = guardConnectionCursor(
169
+ rawConnection,
170
+ { mailboxService },
171
+ accountId,
172
+ mailbox,
173
+ );
174
+ return moveThenResync(
175
+ async () => {
176
+ // Open source mailbox (not read-only)
177
+ await connection.openBox(sourceMailboxPath, false);
178
+
179
+ // Execute IMAP MOVE
180
+ const result = await connection.moveMessages(
181
+ [uid],
182
+ destinationMailboxPath,
183
+ );
184
+
185
+ // Get new UID from COPYUID response
186
+ const newUid = result.uidMap.get(uid);
187
+
188
+ if (newUid) {
189
+ // Update message with new UID
190
+ await messageService.updateUid(
191
+ messageId,
192
+ newUid,
193
+ destinationMailboxId,
194
+ );
195
+
196
+ // Update ThreadMessage UID and mailboxId
197
+ const threadMessage =
198
+ await threadMessageService.findByMessageId(
199
+ account.accountConfigId,
200
+ messageId,
201
+ );
202
+ if (threadMessage) {
203
+ const args = buildThreadMessageMoveUpdate(
204
+ threadMessage,
205
+ newUid,
206
+ destinationMailboxId,
207
+ );
208
+ await threadMessageService.update(
209
+ threadMessage.accountConfigId,
210
+ threadMessage.threadMessageId,
211
+ args.set,
212
+ { composites: args.composites },
213
+ );
214
+ }
215
+
216
+ log.info(
217
+ {
218
+ messageId,
219
+ oldUid: uid,
220
+ newUid,
221
+ destination: destinationMailboxPath,
222
+ },
223
+ "Message moved successfully",
224
+ );
225
+ } else {
226
+ // Message may have been deleted on server
227
+ log.error(
228
+ { messageId, uid },
229
+ "Message not found in COPYUID response - may have been deleted",
230
+ );
231
+ await messageService.update(messageId, {
232
+ syncStatus: MessageSyncStatus.failed,
233
+ });
234
+ }
235
+ },
236
+ () =>
237
+ emitMoveResync(emitEvent, {
238
+ accountId,
239
+ sourceMailboxId,
240
+ destinationMailboxId,
241
+ }),
242
+ );
243
+ })
244
+ .catch(async (error: unknown) => {
245
+ if (error instanceof MailboxCursorPausedError) {
246
+ log.info(
247
+ {
248
+ accountId,
249
+ messageId,
250
+ mailboxId: sourceMailboxId,
251
+ cursorState: error.state,
252
+ },
253
+ "Mailbox cursor not normal; pausing outbound move this round",
254
+ );
255
+ return;
256
+ }
257
+
258
+ const errorMessage =
259
+ error instanceof Error ? error.message : String(error);
260
+
261
+ // Handle TRYCREATE - destination doesn't exist
262
+ if (errorMessage.includes("TRYCREATE")) {
263
+ log.info(
264
+ { destinationMailboxPath },
265
+ "Destination mailbox doesn't exist, creating",
266
+ );
267
+ const connection = await scope.getConnection();
268
+ await connection.createMailbox(destinationMailboxPath);
269
+ // Re-throw to let the event be retried
270
+ throw error;
271
+ }
272
+
273
+ // Handle message not found on IMAP - already moved/deleted (idempotent)
274
+ if (
275
+ errorMessage.includes("not found") ||
276
+ errorMessage.includes("NONEXISTENT")
277
+ ) {
278
+ log.info(
279
+ { messageId, uid },
280
+ "Message not found on IMAP, updating local state as synced",
281
+ );
282
+ await messageService.update(messageId, {
283
+ status: MessageStatus.active,
284
+ syncStatus: MessageSyncStatus.synced,
285
+ });
286
+ return;
287
+ }
288
+
289
+ // Mark as failed for other errors
290
+ await messageService.update(messageId, {
291
+ syncStatus: MessageSyncStatus.failed,
292
+ });
293
+ throw error;
294
+ })
295
+ .finally(() => scope.disconnect());
296
+ },
297
+ );
298
+ };
@@ -0,0 +1,234 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { IImapConnection } from "@remit/mailbox-service";
4
+ import {
5
+ attemptMove,
6
+ getPlacementMoveMaxAttempts,
7
+ PLACEMENT_MOVE_MAX_ATTEMPTS,
8
+ } from "./placement-move-push.js";
9
+
10
+ const buildConnection = (opts: {
11
+ uidMap?: Map<number, number>;
12
+ moveError?: Error;
13
+ /** Message-ID search hits in the destination mailbox — the verification probe. */
14
+ destinationSearchUids?: number[];
15
+ /** Whether the uid is still found via fetchMessages on the source mailbox. */
16
+ stillAtSource?: boolean;
17
+ }): IImapConnection =>
18
+ ({
19
+ openBox: async () => ({}) as never,
20
+ moveMessages: async (_uids: number[], destination: string) => {
21
+ if (opts.moveError) throw opts.moveError;
22
+ return {
23
+ destination,
24
+ uidValidity: 1,
25
+ uidMap: opts.uidMap ?? new Map(),
26
+ };
27
+ },
28
+ search: async (_criteria: unknown[]) => opts.destinationSearchUids ?? [],
29
+ fetchMessages: async (uids: number[]) =>
30
+ opts.stillAtSource
31
+ ? uids.map((uid) => ({ uid }) as unknown as never)
32
+ : [],
33
+ }) as unknown as IImapConnection;
34
+
35
+ const MESSAGE_ID_HEADER = "<abc@example.com>";
36
+
37
+ describe("attemptMove — the IMAP push (issue #1271)", () => {
38
+ // Tests use the SAME fake connection for both the source and destination
39
+ // params — attemptMove treats them as independent handles (real cursor
40
+ // guarding per mailbox is #1272's `guardConnectionCursor`, tested in
41
+ // remit-mailbox-service; not re-tested here).
42
+ it("moved: returns the new uid from the COPYUID map", async () => {
43
+ const connection = buildConnection({ uidMap: new Map([[42, 99]]) });
44
+
45
+ const outcome = await attemptMove(
46
+ connection,
47
+ connection,
48
+ "Junk",
49
+ "INBOX",
50
+ 42,
51
+ MESSAGE_ID_HEADER,
52
+ );
53
+
54
+ assert.equal(outcome.kind, "moved");
55
+ assert.equal(outcome.newUid, 99);
56
+ });
57
+
58
+ it("trycreate: the destination mailbox does not exist yet", async () => {
59
+ const connection = buildConnection({
60
+ moveError: new Error("Command failed: TRYCREATE"),
61
+ });
62
+
63
+ const outcome = await attemptMove(
64
+ connection,
65
+ connection,
66
+ "Junk",
67
+ "NewLabel",
68
+ 42,
69
+ MESSAGE_ID_HEADER,
70
+ );
71
+
72
+ assert.equal(outcome.kind, "trycreate");
73
+ });
74
+
75
+ it("propagates any other error untouched (transient/infra failure, retried by the caller)", async () => {
76
+ const connection = buildConnection({
77
+ moveError: new Error("ECONNRESET"),
78
+ });
79
+
80
+ await assert.rejects(
81
+ () =>
82
+ attemptMove(
83
+ connection,
84
+ connection,
85
+ "Junk",
86
+ "INBOX",
87
+ 42,
88
+ MESSAGE_ID_HEADER,
89
+ ),
90
+ /ECONNRESET/,
91
+ );
92
+ });
93
+
94
+ describe("no COPYUID entry / explicit not-found — never trust either without verification (PR #1289 review finding 2)", () => {
95
+ it("moved: no COPYUID entry, but a Message-ID search finds it at the destination (non-UIDPLUS server, genuine success)", async () => {
96
+ const connection = buildConnection({
97
+ uidMap: new Map(),
98
+ destinationSearchUids: [77],
99
+ });
100
+
101
+ const outcome = await attemptMove(
102
+ connection,
103
+ connection,
104
+ "Junk",
105
+ "INBOX",
106
+ 42,
107
+ MESSAGE_ID_HEADER,
108
+ );
109
+
110
+ assert.equal(outcome.kind, "moved");
111
+ assert.equal(outcome.newUid, 77);
112
+ });
113
+
114
+ it("moved: explicit NONEXISTENT error, but a Message-ID search finds it at the destination", async () => {
115
+ const connection = buildConnection({
116
+ moveError: new Error("Command failed: NONEXISTENT no such message"),
117
+ destinationSearchUids: [77],
118
+ });
119
+
120
+ const outcome = await attemptMove(
121
+ connection,
122
+ connection,
123
+ "Junk",
124
+ "INBOX",
125
+ 42,
126
+ MESSAGE_ID_HEADER,
127
+ );
128
+
129
+ assert.equal(outcome.kind, "moved");
130
+ assert.equal(outcome.newUid, 77);
131
+ });
132
+
133
+ it("throws (never deletes) when unconfirmed at the destination but STILL present at the source", async () => {
134
+ const connection = buildConnection({
135
+ uidMap: new Map(),
136
+ destinationSearchUids: [],
137
+ stillAtSource: true,
138
+ });
139
+
140
+ await assert.rejects(
141
+ () =>
142
+ attemptMove(
143
+ connection,
144
+ connection,
145
+ "Junk",
146
+ "INBOX",
147
+ 42,
148
+ MESSAGE_ID_HEADER,
149
+ ),
150
+ /unresolved/,
151
+ );
152
+ });
153
+
154
+ it("not-found: confirmed absent from BOTH destination (search miss) AND source (fetch miss)", async () => {
155
+ const connection = buildConnection({
156
+ uidMap: new Map(),
157
+ destinationSearchUids: [],
158
+ stillAtSource: false,
159
+ });
160
+
161
+ const outcome = await attemptMove(
162
+ connection,
163
+ connection,
164
+ "Junk",
165
+ "INBOX",
166
+ 42,
167
+ MESSAGE_ID_HEADER,
168
+ );
169
+
170
+ assert.equal(outcome.kind, "not-found");
171
+ });
172
+
173
+ it("no messageIdHeader to verify with: falls back to the source-presence check alone — still never deletes while present at source", async () => {
174
+ const connection = buildConnection({
175
+ uidMap: new Map(),
176
+ stillAtSource: true,
177
+ });
178
+
179
+ await assert.rejects(() =>
180
+ attemptMove(connection, connection, "Junk", "INBOX", 42, undefined),
181
+ );
182
+ });
183
+
184
+ it("no messageIdHeader to verify with: resolves not-found once confirmed absent from source", async () => {
185
+ const connection = buildConnection({
186
+ uidMap: new Map(),
187
+ stillAtSource: false,
188
+ });
189
+
190
+ const outcome = await attemptMove(
191
+ connection,
192
+ connection,
193
+ "Junk",
194
+ "INBOX",
195
+ 42,
196
+ undefined,
197
+ );
198
+
199
+ assert.equal(outcome.kind, "not-found");
200
+ });
201
+ });
202
+ });
203
+
204
+ describe("getPlacementMoveMaxAttempts — env-derived threshold (mirrors #1270's getBodySyncMaxAttempts)", () => {
205
+ it("parses the CDK-injected env var", () => {
206
+ assert.equal(
207
+ getPlacementMoveMaxAttempts({ PLACEMENT_MOVE_MAX_ATTEMPTS: "3" }),
208
+ 3,
209
+ );
210
+ assert.equal(
211
+ getPlacementMoveMaxAttempts({ PLACEMENT_MOVE_MAX_ATTEMPTS: "5" }),
212
+ 5,
213
+ );
214
+ });
215
+
216
+ it("defaults to 3 when unset", () => {
217
+ assert.equal(getPlacementMoveMaxAttempts({}), 3);
218
+ });
219
+
220
+ it("defaults to 3 on a non-numeric or non-positive value", () => {
221
+ assert.equal(
222
+ getPlacementMoveMaxAttempts({ PLACEMENT_MOVE_MAX_ATTEMPTS: "nope" }),
223
+ 3,
224
+ );
225
+ assert.equal(
226
+ getPlacementMoveMaxAttempts({ PLACEMENT_MOVE_MAX_ATTEMPTS: "0" }),
227
+ 3,
228
+ );
229
+ });
230
+
231
+ it("PLACEMENT_MOVE_MAX_ATTEMPTS is a concrete, positive number at module load", () => {
232
+ assert.ok(PLACEMENT_MOVE_MAX_ATTEMPTS > 0);
233
+ });
234
+ });