@remit/imap-worker 0.0.89 → 0.0.91

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 (171) hide show
  1. package/dist/account-check.d.ts +27 -0
  2. package/dist/account-check.js +70 -0
  3. package/dist/body-sync-gate.d.ts +12 -0
  4. package/dist/body-sync-gate.js +53 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +193 -0
  7. package/dist/config-env.d.ts +5 -0
  8. package/dist/config-env.js +37 -0
  9. package/dist/connection-pool.d.ts +22 -0
  10. package/dist/connection-pool.js +97 -0
  11. package/dist/connection-scope.d.ts +8 -0
  12. package/dist/connection-scope.js +10 -0
  13. package/dist/context.d.ts +46 -0
  14. package/dist/context.js +15 -0
  15. package/dist/e2e-processor-shim.d.ts +2 -0
  16. package/dist/e2e-processor-shim.js +181 -0
  17. package/dist/emit.d.ts +15 -0
  18. package/dist/emit.js +76 -0
  19. package/dist/events.d.ts +203 -0
  20. package/dist/events.js +1 -0
  21. package/dist/failure-kind.d.ts +10 -0
  22. package/{src/failure-kind.ts → dist/failure-kind.js} +6 -6
  23. package/dist/handlers/append-sent-message.d.ts +3 -0
  24. package/dist/handlers/append-sent-message.js +152 -0
  25. package/dist/handlers/delete-account-objects.d.ts +3 -0
  26. package/dist/handlers/delete-account-objects.js +35 -0
  27. package/dist/handlers/empty-trash.d.ts +14 -0
  28. package/dist/handlers/empty-trash.js +217 -0
  29. package/dist/handlers/flag-push.d.ts +38 -0
  30. package/dist/handlers/flag-push.js +195 -0
  31. package/dist/handlers/imported-folder.d.ts +4 -0
  32. package/dist/handlers/imported-folder.js +23 -0
  33. package/dist/handlers/mailbox-management.d.ts +6 -0
  34. package/dist/handlers/mailbox-management.js +294 -0
  35. package/dist/handlers/mailbox-sync-order.d.ts +21 -0
  36. package/dist/handlers/mailbox-sync-order.js +50 -0
  37. package/dist/handlers/message-copy.d.ts +40 -0
  38. package/dist/handlers/message-copy.js +371 -0
  39. package/dist/handlers/message-delete-terminal.d.ts +60 -0
  40. package/dist/handlers/message-delete-terminal.js +77 -0
  41. package/dist/handlers/message-delete.d.ts +57 -0
  42. package/dist/handlers/message-delete.js +520 -0
  43. package/{src/handlers/message-move-terminal.ts → dist/handlers/message-move-terminal.d.ts} +18 -105
  44. package/dist/handlers/message-move-terminal.js +94 -0
  45. package/dist/handlers/message-move.d.ts +146 -0
  46. package/dist/handlers/message-move.js +490 -0
  47. package/dist/handlers/placement-move-push.d.ts +62 -0
  48. package/dist/handlers/placement-move-push.js +307 -0
  49. package/dist/handlers/probe-message-id.d.ts +2 -0
  50. package/dist/handlers/probe-message-id.js +6 -0
  51. package/dist/handlers/sync-mailboxes.d.ts +45 -0
  52. package/dist/handlers/sync-mailboxes.js +233 -0
  53. package/dist/handlers/sync-message-body.d.ts +23 -0
  54. package/dist/handlers/sync-message-body.js +182 -0
  55. package/dist/handlers/sync-messages.d.ts +29 -0
  56. package/dist/handlers/sync-messages.js +285 -0
  57. package/dist/index.d.ts +9 -0
  58. package/dist/index.js +67 -0
  59. package/dist/lambda-context.d.ts +8 -0
  60. package/dist/lambda-context.js +33 -0
  61. package/dist/mailbox-row.d.ts +9 -0
  62. package/dist/mailbox-row.js +6 -0
  63. package/dist/poller.d.ts +1 -0
  64. package/dist/poller.js +51 -0
  65. package/dist/process.d.ts +6 -0
  66. package/dist/process.js +3 -0
  67. package/dist/processor.d.ts +3 -0
  68. package/dist/processor.js +44 -0
  69. package/dist/queues.d.ts +10 -0
  70. package/dist/queues.js +1 -0
  71. package/dist/s3-account-objects.d.ts +3 -0
  72. package/dist/s3-account-objects.js +26 -0
  73. package/dist/scheduler/attachment-sweep.d.ts +11 -0
  74. package/dist/scheduler/attachment-sweep.js +23 -0
  75. package/dist/scheduler/calendar-subscriptions.d.ts +26 -0
  76. package/dist/scheduler/calendar-subscriptions.js +35 -0
  77. package/dist/scheduler/config.d.ts +27 -0
  78. package/{src/scheduler/config.ts → dist/scheduler/config.js} +7 -26
  79. package/{src/scheduler/decide-due.ts → dist/scheduler/decide-due.d.ts} +3 -12
  80. package/dist/scheduler/decide-due.js +17 -0
  81. package/dist/scheduler/handler.d.ts +21 -0
  82. package/{src/scheduler/handler.ts → dist/scheduler/handler.js} +24 -36
  83. package/{src/scheduler/loop.ts → dist/scheduler/loop.d.ts} +7 -23
  84. package/dist/scheduler/loop.js +8 -0
  85. package/dist/scheduler/run-tick.d.ts +62 -0
  86. package/dist/scheduler/run-tick.js +164 -0
  87. package/dist/scheduler/runner.d.ts +2 -0
  88. package/{src/scheduler/runner.ts → dist/scheduler/runner.js} +58 -84
  89. package/dist/scheduler/stranded-placement-redrive.d.ts +16 -0
  90. package/dist/scheduler/stranded-placement-redrive.js +172 -0
  91. package/dist/sqs-queues.d.ts +4 -0
  92. package/dist/sqs-queues.js +22 -0
  93. package/dist/with-oauth-lifecycle-deps.d.ts +5 -0
  94. package/dist/with-oauth-lifecycle-deps.js +17 -0
  95. package/dist/with-oauth-lifecycle.d.ts +56 -0
  96. package/dist/with-oauth-lifecycle.js +92 -0
  97. package/{src/worker-version.ts → dist/worker-version.d.ts} +1 -2
  98. package/dist/worker-version.js +12 -0
  99. package/package.json +75 -69
  100. package/src/account-check.test.ts +0 -149
  101. package/src/account-check.ts +0 -101
  102. package/src/body-sync-gate.test.ts +0 -185
  103. package/src/body-sync-gate.ts +0 -86
  104. package/src/cli.ts +0 -224
  105. package/src/connection-scope.test.ts +0 -266
  106. package/src/connection-scope.ts +0 -314
  107. package/src/credential-less-sync-fence.test.ts +0 -152
  108. package/src/e2e-processor-shim.ts +0 -232
  109. package/src/emit.test.ts +0 -127
  110. package/src/emit.ts +0 -117
  111. package/src/events.ts +0 -252
  112. package/src/failure-kind.test.ts +0 -42
  113. package/src/handlers/append-sent-message.sqlite.test.ts +0 -176
  114. package/src/handlers/append-sent-message.ts +0 -264
  115. package/src/handlers/delete-account-objects.test.ts +0 -128
  116. package/src/handlers/delete-account-objects.ts +0 -94
  117. package/src/handlers/empty-trash.sqlite.test.ts +0 -209
  118. package/src/handlers/empty-trash.ts +0 -352
  119. package/src/handlers/flag-push.ts +0 -285
  120. package/src/handlers/imported-folder.test.ts +0 -63
  121. package/src/handlers/imported-folder.ts +0 -38
  122. package/src/handlers/mailbox-management.sqlite.test.ts +0 -265
  123. package/src/handlers/mailbox-management.ts +0 -486
  124. package/src/handlers/mailbox-sync-order.test.ts +0 -93
  125. package/src/handlers/mailbox-sync-order.ts +0 -65
  126. package/src/handlers/message-copy.sqlite.test.ts +0 -298
  127. package/src/handlers/message-copy.ts +0 -566
  128. package/src/handlers/message-delete-terminal.ts +0 -147
  129. package/src/handlers/message-delete.sqlite.test.ts +0 -291
  130. package/src/handlers/message-delete.ts +0 -885
  131. package/src/handlers/message-move.sqlite.test.ts +0 -271
  132. package/src/handlers/message-move.test.ts +0 -216
  133. package/src/handlers/message-move.ts +0 -752
  134. package/src/handlers/placement-move-push.test.ts +0 -137
  135. package/src/handlers/placement-move-push.ts +0 -475
  136. package/src/handlers/placement-push.sqlite.test.ts +0 -298
  137. package/src/handlers/probe-message-id.sqlite.test.ts +0 -192
  138. package/src/handlers/probe-message-id.ts +0 -15
  139. package/src/handlers/sync-mailboxes.test.ts +0 -183
  140. package/src/handlers/sync-mailboxes.ts +0 -421
  141. package/src/handlers/sync-message-body.sqlite.test.ts +0 -314
  142. package/src/handlers/sync-message-body.test.ts +0 -150
  143. package/src/handlers/sync-message-body.ts +0 -365
  144. package/src/handlers/sync-messages.sqlite.test.ts +0 -183
  145. package/src/handlers/sync-messages.ts +0 -525
  146. package/src/index.test.ts +0 -22
  147. package/src/index.ts +0 -86
  148. package/src/mail-sync-selection.sqlite.test.ts +0 -234
  149. package/src/mailbox-row.ts +0 -20
  150. package/src/pending-sync.sqlite.test.ts +0 -166
  151. package/src/poller.ts +0 -55
  152. package/src/processor.ts +0 -60
  153. package/src/queues.ts +0 -49
  154. package/src/scheduler/attachment-sweep.sqlite.test.ts +0 -75
  155. package/src/scheduler/attachment-sweep.ts +0 -46
  156. package/src/scheduler/calendar-subscriptions.sqlite.test.ts +0 -147
  157. package/src/scheduler/calendar-subscriptions.ts +0 -103
  158. package/src/scheduler/config.test.ts +0 -40
  159. package/src/scheduler/decide-due.test.ts +0 -44
  160. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +0 -196
  161. package/src/scheduler/loop.test.ts +0 -143
  162. package/src/scheduler/run-tick.sqlite.test.ts +0 -191
  163. package/src/scheduler/run-tick.ts +0 -281
  164. package/src/scheduler/stranded-placement-redrive.ts +0 -265
  165. package/src/stranded-placement.sqlite.test.ts +0 -794
  166. package/src/test-helpers/test-world.ts +0 -427
  167. package/src/thread-join.sqlite.test.ts +0 -106
  168. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +0 -269
  169. package/src/with-oauth-lifecycle-deps.ts +0 -71
  170. package/src/with-oauth-lifecycle.ts +0 -158
  171. package/tsconfig.json +0 -8
@@ -1,152 +0,0 @@
1
- /**
2
- * Regression test for issue #1120.
3
- *
4
- * A sync triggered for an account that stores no credential used to fail the
5
- * batch item, so the poller left the message undeleted for its whole
6
- * visibility timeout. `remit-mailboxes.fifo` groups by accountId and a FIFO
7
- * queue never delivers past an in-flight message in a group, so the account
8
- * was fenced from every later sync — including the one the user asks for right
9
- * after saving the password.
10
- *
11
- * Runs against the real QueueStore and the real queue definition the stack
12
- * ships, with the poller's own contract modelled around it: ack deletes the
13
- * message, a batch item failure leaves it in flight.
14
- */
15
-
16
- import assert from "node:assert/strict";
17
- import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
18
- import { join } from "node:path";
19
- import { after, describe, it } from "node:test";
20
- import { fileURLToPath } from "node:url";
21
- import { buildSyncMailboxesMessage } from "@remit/backend/trigger-sync";
22
- import type { AccountItem } from "@remit/data-ports";
23
- import { ConnectionState } from "@remit/domain-enums";
24
- import {
25
- bootstrapQueues,
26
- loadQueuesConfig,
27
- QueueStore,
28
- } from "@remit/queue-sidecar";
29
- import { DEFAULT_VISIBILITY_TIMEOUT_SECONDS } from "@remit/sqs-client/poller";
30
- import {
31
- type OAuthLifecycleDeps,
32
- withOAuthLifecycle,
33
- } from "./with-oauth-lifecycle.js";
34
-
35
- const QUEUE_NAME = "remit-mailboxes.fifo";
36
- const ACCOUNT_ID = "acct_imported";
37
- const T0 = 1_700_000_000_000;
38
-
39
- const here = fileURLToPath(new URL(".", import.meta.url));
40
- const repoRoot = join(here, "..", "..", "..");
41
- const tmpRoot = join(here, "..", ".tmp", "credential-less-sync-fence");
42
-
43
- const silentLogger = {
44
- info: () => {},
45
- warn: () => {},
46
- error: () => {},
47
- debug: () => {},
48
- trace: () => {},
49
- fatal: () => {},
50
- child: () => silentLogger,
51
- } as never;
52
-
53
- /** The account the import wizard lands: no password until its owner types one. */
54
- const importedAccount = {
55
- accountId: ACCOUNT_ID,
56
- accountConfigId: "acfg_1",
57
- username: "alice@example.com",
58
- email: "alice@example.com",
59
- authType: "password",
60
- imapHost: "imap.example.com",
61
- imapPort: 993,
62
- imapTls: true,
63
- imapStartTls: false,
64
- isActive: true,
65
- connectionState: ConnectionState.CredentialsMissing,
66
- createdAt: T0,
67
- updatedAt: T0,
68
- } as unknown as AccountItem;
69
-
70
- const enqueueSync = (store: QueueStore, now: number): void => {
71
- const message = buildSyncMailboxesMessage({ accountId: ACCOUNT_ID });
72
- store.sendMessage({
73
- queueName: QUEUE_NAME,
74
- body: JSON.stringify(message.body),
75
- groupId: message.groupId,
76
- deduplicationId: message.deduplicationId,
77
- now,
78
- });
79
- };
80
-
81
- describe("a credential-less sync trigger (issue #1120)", () => {
82
- after(() => {
83
- rmSync(tmpRoot, { recursive: true, force: true });
84
- });
85
-
86
- it("is acked, leaving the account's queue group deliverable", async () => {
87
- mkdirSync(tmpRoot, { recursive: true });
88
- const store = new QueueStore(
89
- join(mkdtempSync(join(tmpRoot, "store-")), "queue.db"),
90
- );
91
- bootstrapQueues(
92
- store,
93
- loadQueuesConfig(join(repoRoot, "deploy/vps/queues.json")),
94
- );
95
-
96
- const stateUpdates: string[] = [];
97
- const deps: OAuthLifecycleDeps = {
98
- secrets: {
99
- decrypt: async () => "",
100
- encrypt: async () => ({}) as never,
101
- },
102
- tokenService: { getAccessToken: async () => ({}) as never },
103
- persistRotatedToken: async () => {},
104
- updateConnectionState: async (_accountId, state) => {
105
- stateUpdates.push(state);
106
- },
107
- };
108
-
109
- // The GET /config poll while the user is still typing the password.
110
- enqueueSync(store, T0);
111
-
112
- const [received] = store.receiveMessages({
113
- queueName: QUEUE_NAME,
114
- maxMessages: 1,
115
- visibilityTimeoutSeconds: DEFAULT_VISIBILITY_TIMEOUT_SECONDS,
116
- now: T0,
117
- });
118
- assert.ok(received, "the trigger is on the queue");
119
-
120
- // The poller's contract: an ack deletes the message, a batch item failure
121
- // leaves it in flight until the visibility timeout expires.
122
- let batchItemFailure: unknown;
123
- await withOAuthLifecycle(deps, importedAccount, silentLogger, async () => {
124
- throw new Error("IMAP work must not run without a credential");
125
- }).then(
126
- () => store.deleteMessage(QUEUE_NAME, received.receiptHandle),
127
- (err: unknown) => {
128
- batchItemFailure = err;
129
- },
130
- );
131
-
132
- // The refresh the user presses after saving the password.
133
- enqueueSync(store, T0 + 2_000);
134
- const deliverable = store.receiveMessages({
135
- queueName: QUEUE_NAME,
136
- maxMessages: 10,
137
- now: T0 + 3_000,
138
- });
139
-
140
- assert.equal(
141
- deliverable.length,
142
- 1,
143
- "the account's group must stay deliverable, not fenced for the visibility timeout",
144
- );
145
- assert.equal(
146
- batchItemFailure,
147
- undefined,
148
- "the event is acked, not retried",
149
- );
150
- assert.deepEqual(stateUpdates, [ConnectionState.CredentialsMissing]);
151
- });
152
- });
@@ -1,232 +0,0 @@
1
- #!/usr/bin/env node
2
- import cluster from "node:cluster";
3
- import { randomUUID } from "node:crypto";
4
- import {
5
- DeleteMessageCommand,
6
- ReceiveMessageCommand,
7
- } from "@aws-sdk/client-sqs";
8
- import { createLogger } from "@remit/logger";
9
- import { installNodeLogger } from "@remit/logger-lambda/node";
10
- import { createSqsClient } from "@remit/sqs-client/producer";
11
- import type { Context, SQSBatchResponse, SQSEvent } from "aws-lambda";
12
- import { env } from "expect-env";
13
- import { handler } from "./index.js";
14
-
15
- installNodeLogger();
16
-
17
- /**
18
- * E2E-only queue drainer.
19
- *
20
- * Production binds the Lambda `handler` (`src/index.ts`) to each queue via an
21
- * SQS event-source mapping. The e2e/CI stack runs on ElasticMQ, which has no
22
- * event-source mapping, so this process supplies that missing piece: it
23
- * long-polls each queue, wraps every received batch in an `SQSEvent`, and
24
- * invokes the exact production `handler`. It then honours the returned
25
- * `batchItemFailures` the way the SQS service would — deleting the messages
26
- * that succeeded and leaving the failures un-deleted so their visibility
27
- * timeout lapses and SQS redelivers them. No processing or failure logic lives
28
- * here; the prod handler owns all of it.
29
- *
30
- * This is a test harness, not production code. If it crashes the e2e suite
31
- * fails loudly, which is the desired signal — there is no crash net.
32
- */
33
-
34
- // Collect all unique queue URLs to poll. Every required queue URL crashes at
35
- // init via expect-env instead of silently dropping queues.
36
- const queueUrls = [
37
- ...new Set([
38
- // FIFO queues for sync operations
39
- env.SQS_QUEUE_URL_MAILBOXES,
40
- env.SQS_QUEUE_URL_MESSAGES,
41
- env.SQS_QUEUE_URL_FLAGS,
42
- // Standard body queue (#612) + management queues
43
- env.SQS_QUEUE_URL_BODY,
44
- env.SQS_QUEUE_URL_MAILBOX_MGMT,
45
- env.SQS_QUEUE_URL_MESSAGE_MGMT,
46
- ]),
47
- ];
48
-
49
- if (cluster.isPrimary) {
50
- // Primary process: fork a worker for each queue
51
- const log = createLogger();
52
- log.info({ queueUrls, workerCount: queueUrls.length }, "Primary started");
53
-
54
- // Track which queue each worker handles for restart
55
- const workerQueues = new Map<number, string>();
56
-
57
- // Fork a worker for each queue
58
- for (const queueUrl of queueUrls) {
59
- const worker = cluster.fork({ WORKER_QUEUE_URL: queueUrl });
60
- workerQueues.set(worker.id, queueUrl);
61
- const queueName = new URL(queueUrl).pathname.split("/").pop();
62
- log.info({ workerId: worker.id, queueName }, "Forked worker for queue");
63
- }
64
-
65
- // Restart workers that crash
66
- cluster.on("exit", (worker, code, signal) => {
67
- const queueUrl = workerQueues.get(worker.id);
68
- const queueName = queueUrl
69
- ? new URL(queueUrl).pathname.split("/").pop()
70
- : "unknown";
71
-
72
- if (signal) {
73
- log.info({ workerId: worker.id, queueName, signal }, "Worker killed");
74
- } else if (code !== 0) {
75
- log.error(
76
- { workerId: worker.id, queueName, code },
77
- "Worker crashed, exiting primary",
78
- );
79
- process.exit(1);
80
- } else {
81
- log.info({ workerId: worker.id, queueName }, "Worker exited cleanly");
82
- workerQueues.delete(worker.id);
83
- }
84
- });
85
-
86
- // Graceful shutdown: signal all workers
87
- const shutdown = () => {
88
- log.info("Primary received shutdown signal, stopping workers...");
89
- for (const worker of Object.values(cluster.workers ?? {})) {
90
- worker?.process.kill("SIGTERM");
91
- }
92
- };
93
-
94
- process.on("SIGINT", shutdown);
95
- process.on("SIGTERM", shutdown);
96
- } else {
97
- // Worker process: poll a single queue
98
- const queueUrl = env.WORKER_QUEUE_URL;
99
- const queueName = new URL(queueUrl).pathname.split("/").pop();
100
- const log = createLogger().child({ queue: queueName });
101
-
102
- const maxMessages = 10; // SQS API limit
103
-
104
- const sqs = createSqsClient({ queueUrl });
105
-
106
- let isShuttingDown = false;
107
-
108
- process.on("SIGINT", () => {
109
- log.info("Worker received SIGINT, shutting down...");
110
- isShuttingDown = true;
111
- });
112
-
113
- process.on("SIGTERM", () => {
114
- log.info("Worker received SIGTERM, shutting down...");
115
- isShuttingDown = true;
116
- });
117
-
118
- // Minimal Lambda Context: `withTelemetry` reads `functionName` for the line it
119
- // logs per invocation and `awsRequestId` for the scope it opens around it, so
120
- // every line one batch produces shares a `requestId` (deploy/vps/README.md,
121
- // "Logs"). Minted per batch, like the production poller does — a context built
122
- // once would put the whole run under one id, which correlates nothing.
123
- const nextContext = (): Context =>
124
- ({
125
- functionName: `e2e-imap-worker-${queueName}`,
126
- awsRequestId: randomUUID(),
127
- }) as Context;
128
-
129
- const pollQueue = async (): Promise<void> => {
130
- log.info({ maxMessages }, "Worker started, polling...");
131
-
132
- let consecutiveEmptyPolls = 0;
133
-
134
- while (!isShuttingDown) {
135
- // Use short polling when we just processed messages (likely more waiting)
136
- // Use long polling after empty polls to reduce API calls
137
- const waitTime = consecutiveEmptyPolls > 0 ? 20 : 0;
138
-
139
- const response = await sqs.send(
140
- new ReceiveMessageCommand({
141
- QueueUrl: queueUrl,
142
- MaxNumberOfMessages: maxMessages,
143
- WaitTimeSeconds: waitTime,
144
- VisibilityTimeout: 300,
145
- MessageSystemAttributeNames: ["ApproximateReceiveCount"],
146
- }),
147
- );
148
-
149
- if (!response.Messages || response.Messages.length === 0) {
150
- consecutiveEmptyPolls++;
151
- continue;
152
- }
153
-
154
- consecutiveEmptyPolls = 0;
155
-
156
- const messages = response.Messages.flatMap((m) =>
157
- m.Body && m.ReceiptHandle && m.MessageId
158
- ? [
159
- {
160
- messageId: m.MessageId,
161
- receiptHandle: m.ReceiptHandle,
162
- body: m.Body,
163
- receiveCount: m.Attributes?.ApproximateReceiveCount ?? "1",
164
- },
165
- ]
166
- : [],
167
- );
168
-
169
- if (messages.length === 0) {
170
- continue;
171
- }
172
-
173
- log.info({ count: messages.length }, "Invoking handler for batch");
174
-
175
- const event: SQSEvent = {
176
- Records: messages.map((m) => ({
177
- messageId: m.messageId,
178
- receiptHandle: m.receiptHandle,
179
- body: m.body,
180
- attributes: {
181
- ApproximateReceiveCount: m.receiveCount,
182
- SentTimestamp: "0",
183
- SenderId: "e2e",
184
- ApproximateFirstReceiveTimestamp: "0",
185
- },
186
- messageAttributes: {},
187
- md5OfBody: "",
188
- eventSource: "aws:sqs",
189
- eventSourceARN: queueUrl,
190
- awsRegion: "local",
191
- })),
192
- };
193
-
194
- const result = (await handler(event, nextContext(), () => {})) as
195
- | SQSBatchResponse
196
- | undefined;
197
-
198
- const failedIds = new Set(
199
- (result?.batchItemFailures ?? []).map((f) => f.itemIdentifier),
200
- );
201
-
202
- // Mirror SQS partial-batch-failure semantics: delete the messages the
203
- // handler reported as succeeded; leave failures un-deleted so their
204
- // visibility timeout lapses and SQS redelivers them (and eventually
205
- // dead-letters once maxReceiveCount is hit).
206
- const succeeded = messages.filter((m) => !failedIds.has(m.messageId));
207
-
208
- for (const message of succeeded) {
209
- await sqs.send(
210
- new DeleteMessageCommand({
211
- QueueUrl: queueUrl,
212
- ReceiptHandle: message.receiptHandle,
213
- }),
214
- );
215
- }
216
-
217
- log.info(
218
- { deleted: succeeded.length, leftForRedelivery: failedIds.size },
219
- "Batch processed",
220
- );
221
- }
222
-
223
- log.info("Worker polling stopped");
224
- };
225
-
226
- pollQueue()
227
- .then(() => process.exit(0))
228
- .catch((error) => {
229
- log.error({ error }, "Worker error");
230
- process.exit(1);
231
- });
232
- }
package/src/emit.test.ts DELETED
@@ -1,127 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { afterEach, beforeEach, describe, it } from "node:test";
3
- import {
4
- _resetForTest,
5
- type RemitClient,
6
- setClient,
7
- } from "@remit/backend/client";
8
- import {
9
- type RecordingQueue,
10
- recordingQueue,
11
- } from "@remit/data-ports/recording-queue";
12
- import { MailboxLockRepo } from "@remit/drizzle-service";
13
- import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
14
- import { emitEvent, SYNC_MESSAGES_PENDING } from "./emit.js";
15
- import type {
16
- FlagPushEvent,
17
- SyncMailboxesEvent,
18
- SyncMessagesEvent,
19
- } from "./events.js";
20
- import { setWorkerQueues } from "./queues.js";
21
-
22
- type Emitted<T> = Omit<T, "eventId" | "timestamp">;
23
-
24
- let locks: MailboxLockRepo;
25
- let closeStore: () => void;
26
- let queue: RecordingQueue;
27
-
28
- const sentCommands = () => queue.sent;
29
-
30
- beforeEach(() => {
31
- queue = recordingQueue();
32
- setWorkerQueues({
33
- mailboxes: queue,
34
- messages: queue,
35
- flags: queue,
36
- body: queue,
37
- mailboxManagement: queue,
38
- messageManagement: queue,
39
- });
40
- const store = createShippedSqliteDb();
41
- closeStore = store.close;
42
- locks = new MailboxLockRepo(store.db);
43
- setClient({ mailboxLock: locks } as unknown as RemitClient);
44
- });
45
-
46
- afterEach(() => {
47
- _resetForTest();
48
- closeStore();
49
- });
50
-
51
- describe("emitEvent on a FIFO queue", () => {
52
- it("groups by account and deduplicates on the event's own id", async () => {
53
- const event: Emitted<SyncMessagesEvent> = {
54
- type: "SYNC_MESSAGES",
55
- accountId: "acc-1",
56
- mailboxId: "mbx-1",
57
- };
58
-
59
- await emitEvent(event);
60
-
61
- const [sent] = sentCommands();
62
- if (!sent) throw new Error("expected a send");
63
- assert.equal(sent.groupId, "acc-1");
64
- assert.equal(sent.deduplicationId, sent.body.eventId);
65
- });
66
-
67
- // Issue #37: every SYNC_MESSAGES for a mailbox carried one dedup id, so
68
- // SQS FIFO's 5-minute window discarded the second sync of that mailbox
69
- // before any worker saw it — mail appended after a sync could not be
70
- // fetched until the window elapsed. Two syncs of one mailbox must both
71
- // reach the queue.
72
- it("lets a second sync of the same mailbox through", async () => {
73
- const event: Emitted<SyncMessagesEvent> = {
74
- type: "SYNC_MESSAGES",
75
- accountId: "acc-1",
76
- mailboxId: "mbx-1",
77
- };
78
-
79
- await emitEvent(event);
80
- const [first] = sentCommands();
81
- if (!first) throw new Error("expected a send");
82
- await locks.releaseLock(
83
- "acc-1",
84
- "mbx-1",
85
- SYNC_MESSAGES_PENDING,
86
- String(first.body.eventId),
87
- );
88
- await emitEvent(event);
89
-
90
- const sent = sentCommands();
91
- assert.equal(sent.length, 2);
92
- assert.notEqual(sent[0]?.deduplicationId, sent[1]?.deduplicationId);
93
- });
94
-
95
- it("lets a second sync of the same account's mailbox list through", async () => {
96
- const event: Emitted<SyncMailboxesEvent> = {
97
- type: "SYNC_MAILBOXES",
98
- accountId: "acc-1",
99
- };
100
-
101
- await emitEvent(event);
102
- await emitEvent(event);
103
-
104
- const sent = sentCommands();
105
- assert.equal(sent.length, 2);
106
- assert.notEqual(sent[0]?.deduplicationId, sent[1]?.deduplicationId);
107
- });
108
-
109
- // A FIFO queue without content-based deduplication rejects a send carrying
110
- // no MessageDeduplicationId, so every event routed onto one needs its own —
111
- // not only the sync events.
112
- it("gives a flag-push re-arm a deduplication id", async () => {
113
- const event: Emitted<FlagPushEvent> = {
114
- type: "FLAG_PUSH",
115
- accountId: "acc-1",
116
- accountConfigId: "cfg-1",
117
- messageId: "msg-1",
118
- flagName: "\\Seen",
119
- };
120
-
121
- await emitEvent(event);
122
-
123
- const [sent] = sentCommands();
124
- if (!sent) throw new Error("expected a send");
125
- assert.equal(sent.deduplicationId, sent.body.eventId);
126
- });
127
- });
package/src/emit.ts DELETED
@@ -1,117 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { getClient } from "@remit/backend/client";
3
- import type { FifoQueueProducer } from "@remit/data-ports";
4
- import { recordImapFailure } from "@remit/logger-lambda/metrics";
5
- import type { ImapEvent } from "./events.js";
6
- import { type WorkerQueues, workerQueues } from "./queues.js";
7
-
8
- type EventInput = Omit<ImapEvent, "eventId" | "timestamp">;
9
-
10
- export const SYNC_MESSAGES_PENDING = "SYNC_MESSAGES_PENDING";
11
-
12
- const SYNC_MESSAGES_PENDING_TTL_SECONDS = 300;
13
-
14
- const queueFor = (
15
- type: ImapEvent["type"],
16
- queues: WorkerQueues,
17
- ): FifoQueueProducer<ImapEvent> => {
18
- switch (type) {
19
- case "SYNC_MAILBOXES":
20
- return queues.mailboxes;
21
- case "SYNC_MESSAGES":
22
- return queues.messages;
23
- // SYNC_MESSAGE_BODY routes to the single standard body queue (#612).
24
- case "SYNC_MESSAGE_BODY":
25
- return queues.body;
26
- case "MAILBOX_CREATE":
27
- case "MAILBOX_RENAME":
28
- case "MAILBOX_DELETE":
29
- return queues.mailboxManagement;
30
- case "MESSAGE_DELETE":
31
- case "MESSAGE_MOVE":
32
- case "MESSAGE_COPY":
33
- case "EMPTY_TRASH":
34
- case "APPEND_SENT_MESSAGE":
35
- // Rides messageMgmtQueue (issue #1271) rather than a dedicated queue — its
36
- // payload carries only our message id (never a UID, unlike the legacy
37
- // MESSAGE_MOVE event); per-event-type payload shape, same queue.
38
- case "PLACEMENT_MOVE_PUSH":
39
- return queues.messageManagement;
40
- // Rides the existing flags queue (issue #1273) — this is imap-worker's OWN
41
- // re-arm hint (the periodic per-mailbox sync tick catching up a marker
42
- // stuck `pending`), distinct from `FlagPushService`'s user-facing hint
43
- // sent from the API onto the sync queue. Both land on a queue this worker
44
- // already consumes; dispatch is by `type`, not by which queue delivered
45
- // the message.
46
- case "FLAG_PUSH":
47
- return queues.flags;
48
- }
49
- };
50
-
51
- export interface EmitEventOptions {
52
- /**
53
- * Delay delivery of the message by this many seconds (0-900).
54
- * Useful for retry backoff to avoid overwhelming IMAP servers.
55
- */
56
- delaySeconds?: number;
57
- }
58
-
59
- export const emitEvent = async (
60
- event: EventInput,
61
- options?: EmitEventOptions,
62
- ) => {
63
- const fullEvent: ImapEvent = {
64
- ...event,
65
- eventId: randomUUID(),
66
- timestamp: Date.now(),
67
- } as ImapEvent;
68
-
69
- const queue = queueFor(event.type, workerQueues());
70
-
71
- // The deduplication id is the event's own id, so the queue suppresses a
72
- // re-send of one event (the retry SQS's own idempotency guard is for) and
73
- // nothing else. A shared id per account or per mailbox instead made the
74
- // 5-minute window a rate limiter: the second sync of a mailbox within five
75
- // minutes was discarded before any worker saw it, so mail that arrived after
76
- // a sync could not be fetched until the window elapsed (issue #37).
77
- const send = () =>
78
- queue.send({
79
- body: fullEvent,
80
- groupId: event.accountId,
81
- deduplicationId: fullEvent.eventId,
82
- ...(options?.delaySeconds && { delaySeconds: options.delaySeconds }),
83
- });
84
- if (fullEvent.type !== "SYNC_MESSAGES") {
85
- await send();
86
- return;
87
- }
88
-
89
- const { mailboxLock } = await getClient();
90
- const { accountId, mailboxId, eventId } = fullEvent;
91
- const previous = await mailboxLock.get(
92
- accountId,
93
- mailboxId,
94
- SYNC_MESSAGES_PENDING,
95
- );
96
- const acquired = await mailboxLock.tryAcquireLock(
97
- mailboxId,
98
- SYNC_MESSAGES_PENDING,
99
- accountId,
100
- eventId,
101
- SYNC_MESSAGES_PENDING_TTL_SECONDS,
102
- );
103
- if (!acquired) return;
104
- if (previous && previous.ttl < Math.floor(Date.now() / 1000)) {
105
- recordImapFailure("SYNC_PENDING_EXPIRED", "other");
106
- }
107
-
108
- await send().catch(async (error: unknown) => {
109
- await mailboxLock.releaseLock(
110
- accountId,
111
- mailboxId,
112
- SYNC_MESSAGES_PENDING,
113
- eventId,
114
- );
115
- throw error;
116
- });
117
- };