@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,116 @@
1
+ import {
2
+ DeleteObjectsCommand,
3
+ ListObjectsV2Command,
4
+ S3Client,
5
+ } from "@aws-sdk/client-s3";
6
+ import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
7
+ import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
8
+ import type { Logger } from "@remit/logger-lambda";
9
+ import { resolveSqsCredentials } from "@remit/sqs-client";
10
+ import { env } from "expect-env";
11
+
12
+ export interface DeleteAccountObjectsEvent {
13
+ type: "DELETE_ACCOUNT_OBJECTS";
14
+ accountConfigId: string;
15
+ continuationToken?: string;
16
+ }
17
+
18
+ const s3 = new S3Client({});
19
+
20
+ const sqsQueueUrl = env.SQS_QUEUE_URL_MESSAGE_MGMT;
21
+ const isLocal = sqsQueueUrl.startsWith("http://localhost");
22
+
23
+ const sqs = new SQSClient({
24
+ endpoint: isLocal ? new URL(sqsQueueUrl).origin : undefined,
25
+ ...(isLocal && { protocol: AwsQueryProtocol }),
26
+ credentials: resolveSqsCredentials(),
27
+ });
28
+
29
+ const BATCH_SIZE = 1_000;
30
+ const MIN_REMAINING_MS = 30_000;
31
+
32
+ export const handleDeleteAccountObjects = async (
33
+ event: DeleteAccountObjectsEvent,
34
+ log: Logger,
35
+ getRemainingTimeMs?: () => number,
36
+ ): Promise<void> => {
37
+ const BUCKET_NAME = env.S3_BUCKET_NAME;
38
+ const { accountConfigId, continuationToken } = event;
39
+ const prefix = `accounts/${accountConfigId}/`;
40
+
41
+ log.info(
42
+ { accountConfigId, prefix, hasContinuation: !!continuationToken },
43
+ "Deleting account objects from S3",
44
+ );
45
+
46
+ let currentToken = continuationToken;
47
+ let totalDeleted = 0;
48
+
49
+ // eslint-disable-next-line no-constant-condition
50
+ while (true) {
51
+ // Check remaining time before starting a new page
52
+ if (getRemainingTimeMs && getRemainingTimeMs() < MIN_REMAINING_MS) {
53
+ log.info(
54
+ { accountConfigId, totalDeleted, continuationToken: currentToken },
55
+ "Near timeout, re-enqueuing with continuation token",
56
+ );
57
+ await reenqueue(accountConfigId, currentToken);
58
+ return;
59
+ }
60
+
61
+ const listResult = await s3.send(
62
+ new ListObjectsV2Command({
63
+ Bucket: BUCKET_NAME,
64
+ Prefix: prefix,
65
+ MaxKeys: BATCH_SIZE,
66
+ ContinuationToken: currentToken,
67
+ }),
68
+ );
69
+
70
+ const keys = (listResult.Contents ?? [])
71
+ .map((obj) => obj.Key)
72
+ .filter((k): k is string => k !== undefined);
73
+
74
+ if (keys.length > 0) {
75
+ await s3.send(
76
+ new DeleteObjectsCommand({
77
+ Bucket: BUCKET_NAME,
78
+ Delete: {
79
+ Objects: keys.map((Key) => ({ Key })),
80
+ Quiet: true,
81
+ },
82
+ }),
83
+ );
84
+ totalDeleted += keys.length;
85
+ }
86
+
87
+ if (!listResult.IsTruncated) {
88
+ break;
89
+ }
90
+
91
+ currentToken = listResult.NextContinuationToken;
92
+ }
93
+
94
+ log.info(
95
+ { accountConfigId, totalDeleted },
96
+ "Finished deleting account objects",
97
+ );
98
+ };
99
+
100
+ const reenqueue = async (
101
+ accountConfigId: string,
102
+ continuationToken: string | undefined,
103
+ ): Promise<void> => {
104
+ const event: DeleteAccountObjectsEvent = {
105
+ type: "DELETE_ACCOUNT_OBJECTS",
106
+ accountConfigId,
107
+ continuationToken,
108
+ };
109
+
110
+ await sqs.send(
111
+ new SendMessageCommand({
112
+ QueueUrl: sqsQueueUrl,
113
+ MessageBody: JSON.stringify(event),
114
+ }),
115
+ );
116
+ };
@@ -0,0 +1,136 @@
1
+ import { getClient } from "@remit/backend/client";
2
+ import type { Logger } from "@remit/logger-lambda";
3
+ import {
4
+ guardConnectionCursor,
5
+ isCursorRebuildNeeded,
6
+ MailboxCursorPausedError,
7
+ } from "@remit/mailbox-service";
8
+ import { isAccountDeleted } from "../account-check.js";
9
+ import { createConnectionScopeWithCredentials } from "../connection-scope.js";
10
+ import type { EmptyTrashEvent } from "../events.js";
11
+ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
12
+ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
13
+
14
+ /**
15
+ * Handle EMPTY_TRASH events.
16
+ * Permanently deletes all messages in the Trash mailbox.
17
+ */
18
+ export const handleEmptyTrash = async (
19
+ event: EmptyTrashEvent,
20
+ log: Logger,
21
+ ): Promise<void> => {
22
+ const {
23
+ account: accountService,
24
+ message: messageService,
25
+ threadMessage: threadMessageService,
26
+ mailbox: mailboxService,
27
+ secrets,
28
+ } = await getClient();
29
+
30
+ const { accountId, trashMailboxId, trashMailboxPath } = event;
31
+
32
+ log.info(
33
+ { event: event.type, accountId, trashMailboxPath },
34
+ "Handling event",
35
+ );
36
+
37
+ const account = await accountService.get(accountId);
38
+ if (!account) {
39
+ throw new Error(`Account ${accountId} not found`);
40
+ }
41
+
42
+ if (isAccountDeleted(account, log)) {
43
+ return;
44
+ }
45
+
46
+ await withOAuthLifecycle(
47
+ buildLifecycleDeps(secrets, accountService),
48
+ account,
49
+ log,
50
+ async (credentials) => {
51
+ const mailbox = await mailboxService.get(accountId, trashMailboxId);
52
+
53
+ // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
54
+ // paused never even opens a connection. Optimization only — the
55
+ // guardConnectionCursor openBox wrap below is the structural guarantee.
56
+ if (isCursorRebuildNeeded(mailbox.cursorState)) {
57
+ log.info(
58
+ { accountId, mailboxId: trashMailboxId },
59
+ "Mailbox cursor not normal; pausing empty-trash this round",
60
+ );
61
+ return;
62
+ }
63
+
64
+ const scope = createConnectionScopeWithCredentials(account, credentials);
65
+
66
+ await scope
67
+ .getConnection()
68
+ .then(async (rawConnection) => {
69
+ // Guard at the openBox choke point (epic #1281 invariants 3 & 5):
70
+ // a fresh mismatch trips the mailbox and throws once the SELECT
71
+ // reveals it. Local rows stay marked for deletion and are picked
72
+ // up once the mailbox returns to normal.
73
+ const connection = guardConnectionCursor(
74
+ rawConnection,
75
+ { mailboxService },
76
+ accountId,
77
+ mailbox,
78
+ );
79
+ await connection.openBox(trashMailboxPath, false);
80
+
81
+ // Search for all messages in Trash
82
+ const uids = await connection.search(["ALL"]);
83
+
84
+ if (uids.length > 0) {
85
+ // Delete all messages on IMAP
86
+ await connection.deleteMessages(uids);
87
+ log.info(
88
+ { count: uids.length },
89
+ "Deleted messages from IMAP trash",
90
+ );
91
+ }
92
+
93
+ // Delete all local messages in trash
94
+ const localMessages =
95
+ await messageService.listAllByMailbox(trashMailboxId);
96
+
97
+ for (const message of localMessages) {
98
+ // Delete the Message entity
99
+ await messageService.delete(message.messageId);
100
+
101
+ // Delete the ThreadMessage entity
102
+ const threadMessage = await threadMessageService.findByMessageId(
103
+ account.accountConfigId,
104
+ message.messageId,
105
+ );
106
+ if (threadMessage) {
107
+ await threadMessageService.delete(
108
+ threadMessage.accountConfigId,
109
+ threadMessage.threadMessageId,
110
+ );
111
+ }
112
+ }
113
+
114
+ log.info(
115
+ { accountId, deletedCount: localMessages.length },
116
+ "Trash emptied successfully",
117
+ );
118
+ })
119
+ .catch((error: unknown) => {
120
+ if (error instanceof MailboxCursorPausedError) {
121
+ log.info(
122
+ {
123
+ accountId,
124
+ mailboxId: trashMailboxId,
125
+ cursorState: error.state,
126
+ },
127
+ "Mailbox cursor not normal; pausing empty-trash this round",
128
+ );
129
+ return;
130
+ }
131
+ throw error;
132
+ })
133
+ .finally(() => scope.disconnect());
134
+ },
135
+ );
136
+ };
@@ -0,0 +1,25 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { FLAG_PUSH_MAX_ATTEMPTS, getFlagPushMaxAttempts } from "./flag-push.js";
4
+
5
+ describe("getFlagPushMaxAttempts — env-derived threshold (mirrors #1270's getBodySyncMaxAttempts / #1289's getPlacementMoveMaxAttempts)", () => {
6
+ it("parses the CDK-injected env var", () => {
7
+ assert.equal(getFlagPushMaxAttempts({ FLAG_PUSH_MAX_ATTEMPTS: "3" }), 3);
8
+ assert.equal(getFlagPushMaxAttempts({ FLAG_PUSH_MAX_ATTEMPTS: "5" }), 5);
9
+ });
10
+
11
+ it("defaults to 3 when unset", () => {
12
+ assert.equal(getFlagPushMaxAttempts({}), 3);
13
+ });
14
+
15
+ it("defaults to 3 on a non-numeric or non-positive value", () => {
16
+ assert.equal(getFlagPushMaxAttempts({ FLAG_PUSH_MAX_ATTEMPTS: "nope" }), 3);
17
+ assert.equal(getFlagPushMaxAttempts({ FLAG_PUSH_MAX_ATTEMPTS: "0" }), 3);
18
+ assert.equal(getFlagPushMaxAttempts({ FLAG_PUSH_MAX_ATTEMPTS: "-1" }), 3);
19
+ });
20
+
21
+ it("the module-level constant reflects the actual process env at load time", () => {
22
+ assert.equal(typeof FLAG_PUSH_MAX_ATTEMPTS, "number");
23
+ assert.ok(FLAG_PUSH_MAX_ATTEMPTS > 0);
24
+ });
25
+ });
@@ -0,0 +1,224 @@
1
+ import { getClient } from "@remit/backend/client";
2
+ import type { Logger } from "@remit/logger-lambda";
3
+ import { MetricUnit, metrics } from "@remit/logger-lambda";
4
+ import {
5
+ guardConnectionCursor,
6
+ isCursorRebuildNeeded,
7
+ MailboxCursorPausedError,
8
+ resolveExhaustedFlagPushFailure,
9
+ } from "@remit/mailbox-service";
10
+ import { isAccountDeleted } from "../account-check.js";
11
+ import { createConnectionScopeWithCredentials } from "../connection-scope.js";
12
+ import type { FlagPushEvent } from "../events.js";
13
+ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
14
+ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
15
+
16
+ /**
17
+ * Fallback when `FLAG_PUSH_MAX_ATTEMPTS` is unset (local dev, unit tests).
18
+ * Matches the shared `MAX_RECEIVE_COUNT` every queue's redrive policy uses
19
+ * (`infra/stacks/dev/stacks/remit-queue-stack.ts`), same pattern as
20
+ * `BODY_SYNC_MAX_ATTEMPTS` (#1270) / `PLACEMENT_MOVE_MAX_ATTEMPTS` (#1289).
21
+ */
22
+ const DEFAULT_FLAG_PUSH_MAX_ATTEMPTS = 3;
23
+
24
+ export const getFlagPushMaxAttempts = (
25
+ processEnv: NodeJS.ProcessEnv = process.env,
26
+ ): number => {
27
+ const raw = processEnv.FLAG_PUSH_MAX_ATTEMPTS;
28
+ if (!raw) return DEFAULT_FLAG_PUSH_MAX_ATTEMPTS;
29
+ const parsed = Number.parseInt(raw, 10);
30
+ return Number.isFinite(parsed) && parsed > 0
31
+ ? parsed
32
+ : DEFAULT_FLAG_PUSH_MAX_ATTEMPTS;
33
+ };
34
+
35
+ export const FLAG_PUSH_MAX_ATTEMPTS = getFlagPushMaxAttempts();
36
+
37
+ /**
38
+ * Handle FLAG_PUSH events (issue #1273, epic #1281). Drains ONE pending
39
+ * flag-push marker: resolves the message's UID and CURRENT mailbox fresh
40
+ * from the Message row (never a value captured at enqueue — invariant 1),
41
+ * pushes the IMAP STORE (add or remove, per the marker's `operation`), and
42
+ * clears the marker ONLY on confirmed success.
43
+ *
44
+ * Precedence (epic invariant 2):
45
+ * - While pending, resync never reverts the flag — nothing in this handler
46
+ * (or anywhere else) reads flags FROM IMAP back into `MessageFlag`/
47
+ * `ThreadMessage` for an existing row; the only flag-state writes happen
48
+ * here (confirmed push) or in `FlagQueueService` (the user's own local
49
+ * flip, which already applied before this marker existed).
50
+ * - A later flip of the SAME field already replaced this marker (`put`) by
51
+ * the time this event is processed, OR advanced it past `pending` — either
52
+ * way `markerService.find` returns the CURRENT marker, so this handler
53
+ * always drives the freshest intent, never a stale one.
54
+ * - An external delete supersedes the marker entirely — handled by
55
+ * `resolveExhaustedFlagPushFailure`'s `reconciled` outcome.
56
+ *
57
+ * Cursor-guarded (#1272, epic #1281 invariant 5): the connection is wrapped
58
+ * via `guardConnectionCursor` around the mailbox's current `openBox` choke
59
+ * point, so no stored UID touches the server while the mailbox's axis is
60
+ * being rebuilt. A trip pauses the push (routine, no alarm) — the marker
61
+ * stays durable and pushes again on the next event or sync tick.
62
+ */
63
+ export const handleFlagPush = async (
64
+ event: FlagPushEvent,
65
+ log: Logger,
66
+ receiveCount = 1,
67
+ ): Promise<void> => {
68
+ const {
69
+ account: accountService,
70
+ mailbox: mailboxService,
71
+ message: messageService,
72
+ threadMessage: threadMessageService,
73
+ flagPush: markerService,
74
+ secrets,
75
+ } = await getClient();
76
+
77
+ const { accountId, accountConfigId, messageId, flagName } = event;
78
+
79
+ const marker = await markerService.find(messageId, flagName);
80
+ if (!marker) {
81
+ log.info(
82
+ { messageId, flagName, accountId },
83
+ "No pending flag-push marker (already confirmed or superseded); nothing to push",
84
+ );
85
+ return;
86
+ }
87
+
88
+ const account = await accountService.get(accountId);
89
+ if (!account) {
90
+ throw new Error(`Account ${accountId} not found`);
91
+ }
92
+ if (isAccountDeleted(account, log)) {
93
+ return;
94
+ }
95
+
96
+ const [message] = await messageService.get([messageId]);
97
+
98
+ // The message row is already gone — some other reconciliation path (body
99
+ // sync, placement move, a prior flag-push exhaustion) already deleted it.
100
+ // The marker is orphaned; drop it without touching IMAP.
101
+ if (!message) {
102
+ await markerService.delete(messageId, flagName);
103
+ log.info(
104
+ { messageId, flagName, accountId },
105
+ "Message row no longer exists; flag-push marker dropped without pushing",
106
+ );
107
+ return;
108
+ }
109
+
110
+ // The worker has picked up the event and is about to actually attempt the
111
+ // IMAP STORE — advance the state engine (pending/queued -> processing).
112
+ // Idempotent to call again on a redelivered event (a prior attempt that
113
+ // died mid-flight already left it here).
114
+ await markerService.updateState(messageId, flagName, "processing");
115
+
116
+ const mailbox = await mailboxService.get(accountId, message.mailboxId);
117
+
118
+ // Cheap frugal skip (epic #1281 invariant 6): a mailbox already known
119
+ // paused never even borrows a connection. Optimization only — the
120
+ // guardConnectionCursor wrap below is the structural guarantee (#1272).
121
+ if (isCursorRebuildNeeded(mailbox.cursorState)) {
122
+ log.info(
123
+ { messageId, flagName, accountId, cursorState: mailbox.cursorState },
124
+ "Mailbox cursor not normal; pausing outbound flag push this round",
125
+ );
126
+ return;
127
+ }
128
+
129
+ await withOAuthLifecycle(
130
+ buildLifecycleDeps(secrets, accountService),
131
+ account,
132
+ log,
133
+ async (credentials) => {
134
+ const scope = createConnectionScopeWithCredentials(account, credentials);
135
+
136
+ await scope
137
+ .getConnection()
138
+ .then(async (rawConnection) => {
139
+ const connection = guardConnectionCursor(
140
+ rawConnection,
141
+ { mailboxService },
142
+ accountId,
143
+ mailbox,
144
+ );
145
+
146
+ await connection.openBox(mailbox.fullPath, false);
147
+
148
+ if (marker.operation === "add") {
149
+ await connection.addFlags([message.uid], [flagName]);
150
+ } else {
151
+ await connection.removeFlags([message.uid], [flagName]);
152
+ }
153
+
154
+ // Confirmed IMAP acknowledgement — clears ONLY here, never on
155
+ // attempt (the defect issue #1273 fixes).
156
+ await markerService.delete(messageId, flagName);
157
+
158
+ log.info(
159
+ {
160
+ messageId,
161
+ flagName,
162
+ accountId,
163
+ operation: marker.operation,
164
+ uid: message.uid,
165
+ mailboxPath: mailbox.fullPath,
166
+ },
167
+ "Flag push confirmed on IMAP; marker cleared",
168
+ );
169
+ })
170
+ .catch(async (error: unknown) => {
171
+ // Expected pause (epic #1281 invariant 3), not a fault: ack and
172
+ // skip rather than propagating into queue retry/DLQ. The marker
173
+ // stays durable; the push resumes once the mailbox returns to
174
+ // normal.
175
+ if (error instanceof MailboxCursorPausedError) {
176
+ log.info(
177
+ { messageId, flagName, accountId, cursorState: error.state },
178
+ "UIDVALIDITY changed; mailbox cursor tripped, pausing outbound flag push",
179
+ );
180
+ return;
181
+ }
182
+
183
+ if (receiveCount < FLAG_PUSH_MAX_ATTEMPTS) {
184
+ // Transient push failure — expected (connections drop). No
185
+ // alarm; queue redelivery retries from the still-durable marker.
186
+ throw error;
187
+ }
188
+
189
+ // Redelivery budget exhausted: resolve into exactly one of the
190
+ // two terminal outcomes (epic invariant 3) instead of
191
+ // dead-lettering with no diagnosis.
192
+ const { outcome } = await resolveExhaustedFlagPushFailure(
193
+ { markerService, messageService, threadMessageService, log },
194
+ {
195
+ accountId,
196
+ accountConfigId,
197
+ messageId,
198
+ flagName,
199
+ uid: message.uid,
200
+ mailboxPath: mailbox.fullPath,
201
+ getConnection: scope.getConnection,
202
+ },
203
+ );
204
+
205
+ if (outcome === "reconciled") {
206
+ metrics.addMetric(
207
+ "flagPushStaleRowReconciled",
208
+ MetricUnit.Count,
209
+ 1,
210
+ );
211
+ return;
212
+ }
213
+
214
+ metrics.addMetric("flagPushFailed", MetricUnit.Count, 1);
215
+ log.error(
216
+ { error: error instanceof Error ? error.message : String(error) },
217
+ "Flag push retry exhausted; message still exists at its mailbox",
218
+ );
219
+ // Terminal — never re-thrown, so the caller acks either way.
220
+ })
221
+ .finally(() => scope.disconnect());
222
+ },
223
+ );
224
+ };