@remit/imap-worker 0.0.90 → 0.0.92

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 (176) 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 +22 -0
  76. package/dist/scheduler/calendar-subscriptions.js +35 -0
  77. package/dist/scheduler/config.d.ts +29 -0
  78. package/{src/scheduler/config.ts → dist/scheduler/config.js} +9 -28
  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/dist/scheduler/handler.js +50 -0
  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 +66 -0
  86. package/dist/scheduler/run-tick.js +164 -0
  87. package/dist/scheduler/runner.d.ts +2 -0
  88. package/dist/scheduler/runner.js +102 -0
  89. package/dist/scheduler/stranded-placement-redrive.d.ts +16 -0
  90. package/dist/scheduler/stranded-placement-redrive.js +172 -0
  91. package/dist/scheduler/tick.d.ts +5 -0
  92. package/dist/scheduler/tick.js +5 -0
  93. package/dist/sqs-queues.d.ts +4 -0
  94. package/dist/sqs-queues.js +22 -0
  95. package/dist/with-oauth-lifecycle-deps.d.ts +5 -0
  96. package/dist/with-oauth-lifecycle-deps.js +17 -0
  97. package/dist/with-oauth-lifecycle.d.ts +56 -0
  98. package/dist/with-oauth-lifecycle.js +92 -0
  99. package/{src/worker-version.ts → dist/worker-version.d.ts} +1 -2
  100. package/dist/worker-version.js +12 -0
  101. package/package.json +79 -69
  102. package/src/account-check.test.ts +0 -149
  103. package/src/account-check.ts +0 -101
  104. package/src/body-sync-gate.test.ts +0 -185
  105. package/src/body-sync-gate.ts +0 -86
  106. package/src/cli.ts +0 -218
  107. package/src/connection-scope.test.ts +0 -266
  108. package/src/connection-scope.ts +0 -314
  109. package/src/credential-less-sync-fence.test.ts +0 -152
  110. package/src/e2e-processor-shim.ts +0 -232
  111. package/src/emit.test.ts +0 -145
  112. package/src/emit.ts +0 -115
  113. package/src/events.ts +0 -252
  114. package/src/failure-kind.test.ts +0 -42
  115. package/src/handlers/append-sent-message.sqlite.test.ts +0 -176
  116. package/src/handlers/append-sent-message.ts +0 -264
  117. package/src/handlers/delete-account-objects.test.ts +0 -126
  118. package/src/handlers/delete-account-objects.ts +0 -96
  119. package/src/handlers/empty-trash.sqlite.test.ts +0 -209
  120. package/src/handlers/empty-trash.ts +0 -352
  121. package/src/handlers/flag-push.ts +0 -285
  122. package/src/handlers/imported-folder.test.ts +0 -63
  123. package/src/handlers/imported-folder.ts +0 -38
  124. package/src/handlers/mailbox-management.sqlite.test.ts +0 -265
  125. package/src/handlers/mailbox-management.ts +0 -486
  126. package/src/handlers/mailbox-sync-order.test.ts +0 -93
  127. package/src/handlers/mailbox-sync-order.ts +0 -65
  128. package/src/handlers/message-copy.sqlite.test.ts +0 -298
  129. package/src/handlers/message-copy.ts +0 -569
  130. package/src/handlers/message-delete-terminal.ts +0 -147
  131. package/src/handlers/message-delete.sqlite.test.ts +0 -291
  132. package/src/handlers/message-delete.ts +0 -888
  133. package/src/handlers/message-move.sqlite.test.ts +0 -271
  134. package/src/handlers/message-move.test.ts +0 -216
  135. package/src/handlers/message-move.ts +0 -755
  136. package/src/handlers/placement-move-push.test.ts +0 -137
  137. package/src/handlers/placement-move-push.ts +0 -478
  138. package/src/handlers/placement-push.sqlite.test.ts +0 -299
  139. package/src/handlers/probe-message-id.sqlite.test.ts +0 -192
  140. package/src/handlers/probe-message-id.ts +0 -15
  141. package/src/handlers/sync-mailboxes.test.ts +0 -183
  142. package/src/handlers/sync-mailboxes.ts +0 -425
  143. package/src/handlers/sync-message-body.sqlite.test.ts +0 -314
  144. package/src/handlers/sync-message-body.test.ts +0 -150
  145. package/src/handlers/sync-message-body.ts +0 -366
  146. package/src/handlers/sync-messages.sqlite.test.ts +0 -195
  147. package/src/handlers/sync-messages.ts +0 -537
  148. package/src/index.test.ts +0 -22
  149. package/src/index.ts +0 -82
  150. package/src/mail-sync-selection.sqlite.test.ts +0 -227
  151. package/src/mailbox-row.ts +0 -20
  152. package/src/pending-sync.sqlite.test.ts +0 -165
  153. package/src/poller.ts +0 -55
  154. package/src/processor.ts +0 -62
  155. package/src/queues.ts +0 -40
  156. package/src/scheduler/attachment-sweep.sqlite.test.ts +0 -74
  157. package/src/scheduler/attachment-sweep.ts +0 -46
  158. package/src/scheduler/calendar-subscriptions.sqlite.test.ts +0 -147
  159. package/src/scheduler/calendar-subscriptions.ts +0 -103
  160. package/src/scheduler/config.test.ts +0 -40
  161. package/src/scheduler/decide-due.test.ts +0 -44
  162. package/src/scheduler/half-deleted-accounts.sqlite.test.ts +0 -196
  163. package/src/scheduler/handler.ts +0 -69
  164. package/src/scheduler/loop.test.ts +0 -143
  165. package/src/scheduler/run-tick.sqlite.test.ts +0 -191
  166. package/src/scheduler/run-tick.ts +0 -281
  167. package/src/scheduler/runner.ts +0 -125
  168. package/src/scheduler/stranded-placement-redrive.ts +0 -265
  169. package/src/stranded-placement.sqlite.test.ts +0 -799
  170. package/src/test-helpers/test-world.ts +0 -425
  171. package/src/test-helpers/worker-queues.ts +0 -11
  172. package/src/thread-join.sqlite.test.ts +0 -106
  173. package/src/with-oauth-lifecycle-deps.sqlite.test.ts +0 -269
  174. package/src/with-oauth-lifecycle-deps.ts +0 -71
  175. package/src/with-oauth-lifecycle.ts +0 -158
  176. package/tsconfig.json +0 -8
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ import cluster from "node:cluster";
3
+ import { randomUUID } from "node:crypto";
4
+ import { DeleteMessageCommand, ReceiveMessageCommand, } from "@aws-sdk/client-sqs";
5
+ import { createLogger } from "@remit/logger";
6
+ import { installNodeLogger } from "@remit/logger-lambda/node";
7
+ import { createSqsClient } from "@remit/sqs-client/producer";
8
+ import { env } from "expect-env";
9
+ import { handler } from "./index.js";
10
+ installNodeLogger();
11
+ /**
12
+ * E2E-only queue drainer.
13
+ *
14
+ * Production binds the Lambda `handler` (`src/index.ts`) to each queue via an
15
+ * SQS event-source mapping. The e2e/CI stack runs on ElasticMQ, which has no
16
+ * event-source mapping, so this process supplies that missing piece: it
17
+ * long-polls each queue, wraps every received batch in an `SQSEvent`, and
18
+ * invokes the exact production `handler`. It then honours the returned
19
+ * `batchItemFailures` the way the SQS service would — deleting the messages
20
+ * that succeeded and leaving the failures un-deleted so their visibility
21
+ * timeout lapses and SQS redelivers them. No processing or failure logic lives
22
+ * here; the prod handler owns all of it.
23
+ *
24
+ * This is a test harness, not production code. If it crashes the e2e suite
25
+ * fails loudly, which is the desired signal — there is no crash net.
26
+ */
27
+ // Collect all unique queue URLs to poll. Every required queue URL crashes at
28
+ // init via expect-env instead of silently dropping queues.
29
+ const queueUrls = [
30
+ ...new Set([
31
+ // FIFO queues for sync operations
32
+ env.SQS_QUEUE_URL_MAILBOXES,
33
+ env.SQS_QUEUE_URL_MESSAGES,
34
+ env.SQS_QUEUE_URL_FLAGS,
35
+ // Standard body queue (#612) + management queues
36
+ env.SQS_QUEUE_URL_BODY,
37
+ env.SQS_QUEUE_URL_MAILBOX_MGMT,
38
+ env.SQS_QUEUE_URL_MESSAGE_MGMT,
39
+ ]),
40
+ ];
41
+ if (cluster.isPrimary) {
42
+ // Primary process: fork a worker for each queue
43
+ const log = createLogger();
44
+ log.info({ queueUrls, workerCount: queueUrls.length }, "Primary started");
45
+ // Track which queue each worker handles for restart
46
+ const workerQueues = new Map();
47
+ // Fork a worker for each queue
48
+ for (const queueUrl of queueUrls) {
49
+ const worker = cluster.fork({ WORKER_QUEUE_URL: queueUrl });
50
+ workerQueues.set(worker.id, queueUrl);
51
+ const queueName = new URL(queueUrl).pathname.split("/").pop();
52
+ log.info({ workerId: worker.id, queueName }, "Forked worker for queue");
53
+ }
54
+ // Restart workers that crash
55
+ cluster.on("exit", (worker, code, signal) => {
56
+ const queueUrl = workerQueues.get(worker.id);
57
+ const queueName = queueUrl
58
+ ? new URL(queueUrl).pathname.split("/").pop()
59
+ : "unknown";
60
+ if (signal) {
61
+ log.info({ workerId: worker.id, queueName, signal }, "Worker killed");
62
+ }
63
+ else if (code !== 0) {
64
+ log.error({ workerId: worker.id, queueName, code }, "Worker crashed, exiting primary");
65
+ process.exit(1);
66
+ }
67
+ else {
68
+ log.info({ workerId: worker.id, queueName }, "Worker exited cleanly");
69
+ workerQueues.delete(worker.id);
70
+ }
71
+ });
72
+ // Graceful shutdown: signal all workers
73
+ const shutdown = () => {
74
+ log.info("Primary received shutdown signal, stopping workers...");
75
+ for (const worker of Object.values(cluster.workers ?? {})) {
76
+ worker?.process.kill("SIGTERM");
77
+ }
78
+ };
79
+ process.on("SIGINT", shutdown);
80
+ process.on("SIGTERM", shutdown);
81
+ }
82
+ else {
83
+ // Worker process: poll a single queue
84
+ const queueUrl = env.WORKER_QUEUE_URL;
85
+ const queueName = new URL(queueUrl).pathname.split("/").pop();
86
+ const log = createLogger().child({ queue: queueName });
87
+ const maxMessages = 10; // SQS API limit
88
+ const sqs = createSqsClient({ queueUrl });
89
+ let isShuttingDown = false;
90
+ process.on("SIGINT", () => {
91
+ log.info("Worker received SIGINT, shutting down...");
92
+ isShuttingDown = true;
93
+ });
94
+ process.on("SIGTERM", () => {
95
+ log.info("Worker received SIGTERM, shutting down...");
96
+ isShuttingDown = true;
97
+ });
98
+ // Minimal Lambda Context: `withTelemetry` reads `functionName` for the line it
99
+ // logs per invocation and `awsRequestId` for the scope it opens around it, so
100
+ // every line one batch produces shares a `requestId` (deploy/vps/README.md,
101
+ // "Logs"). Minted per batch, like the production poller does — a context built
102
+ // once would put the whole run under one id, which correlates nothing.
103
+ const nextContext = () => ({
104
+ functionName: `e2e-imap-worker-${queueName}`,
105
+ awsRequestId: randomUUID(),
106
+ });
107
+ const pollQueue = async () => {
108
+ log.info({ maxMessages }, "Worker started, polling...");
109
+ let consecutiveEmptyPolls = 0;
110
+ while (!isShuttingDown) {
111
+ // Use short polling when we just processed messages (likely more waiting)
112
+ // Use long polling after empty polls to reduce API calls
113
+ const waitTime = consecutiveEmptyPolls > 0 ? 20 : 0;
114
+ const response = await sqs.send(new ReceiveMessageCommand({
115
+ QueueUrl: queueUrl,
116
+ MaxNumberOfMessages: maxMessages,
117
+ WaitTimeSeconds: waitTime,
118
+ VisibilityTimeout: 300,
119
+ MessageSystemAttributeNames: ["ApproximateReceiveCount"],
120
+ }));
121
+ if (!response.Messages || response.Messages.length === 0) {
122
+ consecutiveEmptyPolls++;
123
+ continue;
124
+ }
125
+ consecutiveEmptyPolls = 0;
126
+ const messages = response.Messages.flatMap((m) => m.Body && m.ReceiptHandle && m.MessageId
127
+ ? [
128
+ {
129
+ messageId: m.MessageId,
130
+ receiptHandle: m.ReceiptHandle,
131
+ body: m.Body,
132
+ receiveCount: m.Attributes?.ApproximateReceiveCount ?? "1",
133
+ },
134
+ ]
135
+ : []);
136
+ if (messages.length === 0) {
137
+ continue;
138
+ }
139
+ log.info({ count: messages.length }, "Invoking handler for batch");
140
+ const event = {
141
+ Records: messages.map((m) => ({
142
+ messageId: m.messageId,
143
+ receiptHandle: m.receiptHandle,
144
+ body: m.body,
145
+ attributes: {
146
+ ApproximateReceiveCount: m.receiveCount,
147
+ SentTimestamp: "0",
148
+ SenderId: "e2e",
149
+ ApproximateFirstReceiveTimestamp: "0",
150
+ },
151
+ messageAttributes: {},
152
+ md5OfBody: "",
153
+ eventSource: "aws:sqs",
154
+ eventSourceARN: queueUrl,
155
+ awsRegion: "local",
156
+ })),
157
+ };
158
+ const result = (await handler(event, nextContext(), () => { }));
159
+ const failedIds = new Set((result?.batchItemFailures ?? []).map((f) => f.itemIdentifier));
160
+ // Mirror SQS partial-batch-failure semantics: delete the messages the
161
+ // handler reported as succeeded; leave failures un-deleted so their
162
+ // visibility timeout lapses and SQS redelivers them (and eventually
163
+ // dead-letters once maxReceiveCount is hit).
164
+ const succeeded = messages.filter((m) => !failedIds.has(m.messageId));
165
+ for (const message of succeeded) {
166
+ await sqs.send(new DeleteMessageCommand({
167
+ QueueUrl: queueUrl,
168
+ ReceiptHandle: message.receiptHandle,
169
+ }));
170
+ }
171
+ log.info({ deleted: succeeded.length, leftForRedelivery: failedIds.size }, "Batch processed");
172
+ }
173
+ log.info("Worker polling stopped");
174
+ };
175
+ pollQueue()
176
+ .then(() => process.exit(0))
177
+ .catch((error) => {
178
+ log.error({ error }, "Worker error");
179
+ process.exit(1);
180
+ });
181
+ }
package/dist/emit.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { IMailboxLockRepository } from "@remit/data-ports";
2
+ import type { ProcessContext, WorkerMetrics } from "./context.js";
3
+ import type { ImapEvent } from "./events.js";
4
+ import type { WorkerQueues } from "./queues.js";
5
+ type EventInput = Omit<ImapEvent, "eventId" | "timestamp">;
6
+ export type EmitEvent = (event: EventInput) => Promise<void>;
7
+ export declare const SYNC_MESSAGES_PENDING = "SYNC_MESSAGES_PENDING";
8
+ export interface EmitDeps {
9
+ queues: WorkerQueues;
10
+ mailboxLock: IMailboxLockRepository;
11
+ metrics: WorkerMetrics;
12
+ }
13
+ export declare const createEmitEvent: ({ queues, mailboxLock, metrics }: EmitDeps) => EmitEvent;
14
+ export declare const emitFrom: (ctx: Pick<ProcessContext, "queues" | "client" | "metrics">) => EmitEvent;
15
+ export {};
package/dist/emit.js ADDED
@@ -0,0 +1,76 @@
1
+ // The deduplication id is the event's own id, so the queue suppresses a
2
+ // re-send of one event (the retry SQS's own idempotency guard is for) and
3
+ // nothing else. A shared id per account or per mailbox instead made the
4
+ // 5-minute window a rate limiter: the second sync of a mailbox within five
5
+ // minutes was discarded before any worker saw it, so mail that arrived after
6
+ // a sync could not be fetched until the window elapsed (issue #37).
7
+ const fifo = (queue) => (event) => queue.send({
8
+ body: event,
9
+ groupId: event.accountId,
10
+ deduplicationId: event.eventId,
11
+ });
12
+ const standard = (queue) => (event) => queue.send({ body: event });
13
+ export const SYNC_MESSAGES_PENDING = "SYNC_MESSAGES_PENDING";
14
+ const SYNC_MESSAGES_PENDING_TTL_SECONDS = 300;
15
+ const senderFor = (type, queues) => {
16
+ switch (type) {
17
+ case "SYNC_MAILBOXES":
18
+ return fifo(queues.mailboxes);
19
+ case "SYNC_MESSAGES":
20
+ return fifo(queues.messages);
21
+ // SYNC_MESSAGE_BODY routes to the single standard body queue (#612).
22
+ case "SYNC_MESSAGE_BODY":
23
+ return standard(queues.body);
24
+ case "MAILBOX_CREATE":
25
+ case "MAILBOX_RENAME":
26
+ case "MAILBOX_DELETE":
27
+ return standard(queues.mailboxManagement);
28
+ case "MESSAGE_DELETE":
29
+ case "MESSAGE_MOVE":
30
+ case "MESSAGE_COPY":
31
+ case "EMPTY_TRASH":
32
+ case "APPEND_SENT_MESSAGE":
33
+ // Rides messageMgmtQueue (issue #1271) rather than a dedicated queue — its
34
+ // payload carries only our message id (never a UID, unlike the legacy
35
+ // MESSAGE_MOVE event); per-event-type payload shape, same queue.
36
+ case "PLACEMENT_MOVE_PUSH":
37
+ return standard(queues.messageManagement);
38
+ // Rides the existing flags queue (issue #1273) — this is imap-worker's OWN
39
+ // re-arm hint (the periodic per-mailbox sync tick catching up a marker
40
+ // stuck `pending`), distinct from `FlagPushService`'s user-facing hint
41
+ // sent from the API onto the sync queue. Both land on a queue this worker
42
+ // already consumes; dispatch is by `type`, not by which queue delivered
43
+ // the message.
44
+ case "FLAG_PUSH":
45
+ return fifo(queues.flags);
46
+ }
47
+ };
48
+ export const createEmitEvent = ({ queues, mailboxLock, metrics }) => async (event) => {
49
+ const fullEvent = {
50
+ ...event,
51
+ eventId: crypto.randomUUID(),
52
+ timestamp: Date.now(),
53
+ };
54
+ const send = () => senderFor(fullEvent.type, queues)(fullEvent);
55
+ if (fullEvent.type !== "SYNC_MESSAGES") {
56
+ await send();
57
+ return;
58
+ }
59
+ const { accountId, mailboxId, eventId } = fullEvent;
60
+ const previous = await mailboxLock.get(accountId, mailboxId, SYNC_MESSAGES_PENDING);
61
+ const acquired = await mailboxLock.tryAcquireLock(mailboxId, SYNC_MESSAGES_PENDING, accountId, eventId, SYNC_MESSAGES_PENDING_TTL_SECONDS);
62
+ if (!acquired)
63
+ return;
64
+ if (previous && previous.ttl < Math.floor(Date.now() / 1000)) {
65
+ metrics.imapFailure("SYNC_PENDING_EXPIRED", "other", 1);
66
+ }
67
+ await send().catch(async (error) => {
68
+ await mailboxLock.releaseLock(accountId, mailboxId, SYNC_MESSAGES_PENDING, eventId);
69
+ throw error;
70
+ });
71
+ };
72
+ export const emitFrom = (ctx) => createEmitEvent({
73
+ queues: ctx.queues,
74
+ mailboxLock: ctx.client.mailboxLock,
75
+ metrics: ctx.metrics,
76
+ });
@@ -0,0 +1,203 @@
1
+ import type { MUTATION_EVENT_SCHEMA_VERSION, PATHLESS_MUTATION_EVENT_SCHEMA_VERSION } from "@remit/data-ports/mutation-events";
2
+ export interface BaseEvent {
3
+ accountId: string;
4
+ eventId: string;
5
+ timestamp: number;
6
+ }
7
+ export interface SyncMailboxesEvent extends BaseEvent {
8
+ type: "SYNC_MAILBOXES";
9
+ /**
10
+ * Set by POST /sync — the only trigger a person asks for by name. It makes
11
+ * the fan-out sync every mailbox, skipping the freshness gate that keeps
12
+ * incidental triggers (config load, OAuth connect, the scheduled tick) from
13
+ * re-enumerating folders that were just enumerated.
14
+ */
15
+ explicitRequest?: boolean;
16
+ }
17
+ export interface SyncMessagesEvent extends BaseEvent {
18
+ type: "SYNC_MESSAGES";
19
+ mailboxId: string;
20
+ fullSync?: boolean;
21
+ }
22
+ /**
23
+ * A message to body-sync, carrying the UID resolved at envelope-sync time so
24
+ * the consumer can issue one ranged FETCH without a per-message DDB lookup.
25
+ */
26
+ export interface SyncMessageBodyTarget {
27
+ messageId: string;
28
+ uid: number;
29
+ }
30
+ export interface SyncMessageBodyEvent extends BaseEvent {
31
+ type: "SYNC_MESSAGE_BODY";
32
+ mailboxId: string;
33
+ /**
34
+ * Message ids to sync. Always present for backward compatibility; the
35
+ * consumer falls back to this list (looking up each UID) when `messages`
36
+ * is absent.
37
+ */
38
+ messageIds: string[];
39
+ /**
40
+ * Preferred shape: messageId+uid pairs. When present, the consumer skips the
41
+ * per-message UID lookup and fetches the whole batch in one ranged FETCH.
42
+ * Optional so older in-flight events (ids only) still process.
43
+ */
44
+ messages?: SyncMessageBodyTarget[];
45
+ /**
46
+ * Set only by the read-miss re-arm cue (`BodySyncQueueService.requestBodySync`,
47
+ * called when a `/content` read finds `bodyStorageKey` set but the storage
48
+ * object missing). Tells the consumer to bypass the "already stored" skip
49
+ * guard and re-fetch + rewrite the body even though the DB row already
50
+ * carries a `bodyStorageKey` — otherwise stale metadata (key set, object
51
+ * gone) makes the read-miss retry loop forever, since the bulk skip guard
52
+ * would keep treating the message as already synced. Optional so older
53
+ * in-flight events (no force) keep the original skip behavior.
54
+ */
55
+ force?: boolean;
56
+ }
57
+ export interface MailboxCreateEvent extends BaseEvent {
58
+ type: "MAILBOX_CREATE";
59
+ mailboxId: string;
60
+ path: string;
61
+ subscribe?: boolean;
62
+ }
63
+ export interface MailboxRenameEvent extends BaseEvent {
64
+ type: "MAILBOX_RENAME";
65
+ mailboxId: string;
66
+ oldPath: string;
67
+ newPath: string;
68
+ }
69
+ export interface MailboxDeleteEvent extends BaseEvent {
70
+ type: "MAILBOX_DELETE";
71
+ mailboxId: string;
72
+ path: string;
73
+ }
74
+ export type MailboxManagementEvent = MailboxCreateEvent | MailboxRenameEvent | MailboxDeleteEvent;
75
+ /**
76
+ * Event for deleting a message (move to trash or permanent delete).
77
+ */
78
+ interface MessageDeleteEventFields extends BaseEvent {
79
+ type: "MESSAGE_DELETE";
80
+ messageId: string;
81
+ mailboxId: string;
82
+ uid: number;
83
+ operation: "move_to_trash" | "permanent_delete";
84
+ destinationMailboxId?: string;
85
+ }
86
+ export interface MessageDeleteEventV2 extends MessageDeleteEventFields {
87
+ schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
88
+ mailboxPath: string;
89
+ destinationMailboxPath?: string;
90
+ }
91
+ export interface MessageDeleteEventV3 extends MessageDeleteEventFields {
92
+ schemaVersion: typeof PATHLESS_MUTATION_EVENT_SCHEMA_VERSION;
93
+ }
94
+ export type MessageDeleteEvent = MessageDeleteEventV2 | MessageDeleteEventV3;
95
+ /**
96
+ * Event for moving a message to another mailbox.
97
+ */
98
+ interface MessageMoveEventFields extends BaseEvent {
99
+ type: "MESSAGE_MOVE";
100
+ messageId: string;
101
+ sourceMailboxId: string;
102
+ destinationMailboxId: string;
103
+ uid: number;
104
+ }
105
+ export interface MessageMoveEventV1 extends MessageMoveEventFields {
106
+ schemaVersion?: undefined;
107
+ sourceMailboxPath: string;
108
+ destinationMailboxPath: string;
109
+ }
110
+ export interface MessageMoveEventV3 extends MessageMoveEventFields {
111
+ schemaVersion: typeof PATHLESS_MUTATION_EVENT_SCHEMA_VERSION;
112
+ }
113
+ export type MessageMoveEvent = MessageMoveEventV1 | MessageMoveEventV3;
114
+ /**
115
+ * Event for emptying the Trash mailbox.
116
+ */
117
+ interface EmptyTrashEventFields extends BaseEvent {
118
+ type: "EMPTY_TRASH";
119
+ trashMailboxId: string;
120
+ /**
121
+ * The Trash folder's UIDVALIDITY at the moment the user consented. The
122
+ * handler compares it against what the SELECT serves: a path reused by a
123
+ * different folder (rename plus recreate) answers with a different value,
124
+ * and this is the only comparison the mutating connection cannot race.
125
+ */
126
+ trashUidValidity: number;
127
+ }
128
+ export interface EmptyTrashEventV2 extends EmptyTrashEventFields {
129
+ schemaVersion: typeof MUTATION_EVENT_SCHEMA_VERSION;
130
+ trashMailboxPath: string;
131
+ }
132
+ export interface EmptyTrashEventV3 extends EmptyTrashEventFields {
133
+ schemaVersion: typeof PATHLESS_MUTATION_EVENT_SCHEMA_VERSION;
134
+ }
135
+ export type EmptyTrashEvent = EmptyTrashEventV2 | EmptyTrashEventV3;
136
+ /**
137
+ * Event for copying a message to another mailbox.
138
+ */
139
+ interface MessageCopyEventFields extends BaseEvent {
140
+ type: "MESSAGE_COPY";
141
+ sourceMessageId: string;
142
+ newMessageId: string;
143
+ sourceMailboxId: string;
144
+ destinationMailboxId: string;
145
+ uid: number;
146
+ }
147
+ export interface MessageCopyEventV1 extends MessageCopyEventFields {
148
+ schemaVersion?: undefined;
149
+ sourceMailboxPath: string;
150
+ destinationMailboxPath: string;
151
+ }
152
+ export interface MessageCopyEventV3 extends MessageCopyEventFields {
153
+ schemaVersion: typeof PATHLESS_MUTATION_EVENT_SCHEMA_VERSION;
154
+ }
155
+ export type MessageCopyEvent = MessageCopyEventV1 | MessageCopyEventV3;
156
+ /**
157
+ * Event for appending a sent message to the Sent mailbox via IMAP APPEND.
158
+ */
159
+ export interface AppendSentMessageEvent extends BaseEvent {
160
+ type: "APPEND_SENT_MESSAGE";
161
+ outboxMessageId: string;
162
+ }
163
+ /**
164
+ * Drains one pending placement-move marker (issue #1271, epic #1281) to
165
+ * IMAP. Deliberately carries no UID and no destination — both are resolved
166
+ * fresh from the Message row and the `MessagePlacementMove` marker at push
167
+ * time (epic invariant 1), so this event stays valid across any amount of
168
+ * queue delay or an unrelated UIDVALIDITY rebuild (#1272). If the marker is
169
+ * gone by the time this is processed (confirmed, superseded, or an external
170
+ * delete already reconciled it), the handler is a no-op.
171
+ */
172
+ export interface PlacementMovePushEvent extends BaseEvent {
173
+ type: "PLACEMENT_MOVE_PUSH";
174
+ accountConfigId: string;
175
+ messageId: string;
176
+ }
177
+ /**
178
+ * Wake-up hint for one pending flag-push marker (issue #1273, epic #1281).
179
+ * Carries only our message id + the flag field — never a UID and never the
180
+ * desired add/remove transition (both live on the `MessageFlagPush` marker,
181
+ * resolved fresh at push time, epic invariant 1). Produced two ways: by
182
+ * `FlagPushService` (remit-mailbox-service) right after a user's flag flip
183
+ * commits locally, and by the periodic per-mailbox sync tick
184
+ * (`sync-messages.ts`) re-arming any marker it finds still stuck `pending`
185
+ * (a prior enqueue that never landed). If the marker is gone or has moved
186
+ * past `pending` by the time this is processed, the handler is a no-op.
187
+ */
188
+ export interface FlagPushEvent extends BaseEvent {
189
+ type: "FLAG_PUSH";
190
+ accountConfigId: string;
191
+ messageId: string;
192
+ flagName: string;
193
+ }
194
+ export interface DeleteAccountObjectsEvent {
195
+ type: "DELETE_ACCOUNT_OBJECTS";
196
+ accountConfigId: string;
197
+ continuationToken?: string;
198
+ }
199
+ /** Union of all event types the worker can process (including non-IMAP ones). */
200
+ export type WorkerEvent = ImapEvent | DeleteAccountObjectsEvent;
201
+ export type MessageManagementEvent = MessageDeleteEvent | MessageMoveEvent | EmptyTrashEvent | MessageCopyEvent;
202
+ export type ImapEvent = SyncMailboxesEvent | SyncMessagesEvent | SyncMessageBodyEvent | MailboxManagementEvent | MessageManagementEvent | AppendSentMessageEvent | PlacementMovePushEvent | FlagPushEvent;
203
+ export {};
package/dist/events.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ import type { ImapFailureKind } from "./context.js";
2
+ /**
3
+ * Which class of failure ended an IMAP operation, for the exported failure
4
+ * counter (standalone-observability D3). `auth` is its own kind because it is
5
+ * the one class that never resolves itself — an expired OAuth grant or a
6
+ * changed password fails identically forever, and it is the most common way a
7
+ * self-hosted mailbox goes quiet. A refresh that cannot mint a token is the
8
+ * same condition arriving one layer earlier.
9
+ */
10
+ export declare const imapFailureKind: (error: unknown) => ImapFailureKind;
@@ -1,7 +1,5 @@
1
- import type { MailFailureKind } from "@remit/logger-lambda/metrics";
2
1
  import { RefreshTokenError } from "@remit/mail-oauth-service";
3
2
  import { MailConnectionError } from "@remit/mailbox-service";
4
-
5
3
  /**
6
4
  * Which class of failure ended an IMAP operation, for the exported failure
7
5
  * counter (standalone-observability D3). `auth` is its own kind because it is
@@ -10,8 +8,10 @@ import { MailConnectionError } from "@remit/mailbox-service";
10
8
  * self-hosted mailbox goes quiet. A refresh that cannot mint a token is the
11
9
  * same condition arriving one layer earlier.
12
10
  */
13
- export const imapFailureKind = (error: unknown): MailFailureKind => {
14
- if (error instanceof RefreshTokenError) return "auth";
15
- if (error instanceof MailConnectionError) return error.kind;
16
- return "other";
11
+ export const imapFailureKind = (error) => {
12
+ if (error instanceof RefreshTokenError)
13
+ return "auth";
14
+ if (error instanceof MailConnectionError)
15
+ return error.kind;
16
+ return "other";
17
17
  };
@@ -0,0 +1,3 @@
1
+ import type { ProcessContext } from "../context.js";
2
+ import type { AppendSentMessageEvent } from "../events.js";
3
+ export declare const handleAppendSentMessage: (event: AppendSentMessageEvent, ctx: ProcessContext, receiveCount: number) => Promise<void>;
@@ -0,0 +1,152 @@
1
+ import { APPENDED_UID_NONE, APPENDED_UID_UNREPORTED, isSentCopyFiled, } from "@remit/data-ports";
2
+ import { isNotFoundError } from "@remit/data-ports/errors";
3
+ import { OutboxMessageStatus } from "@remit/domain-enums";
4
+ import { OutboxAttachmentUnavailableError } from "@remit/mailbox-service/outbox-attachment-content";
5
+ import { buildMailMessage, renderRawMessage, } from "@remit/smtp-service/message-builder";
6
+ import { isAccountDeleted } from "../account-check.js";
7
+ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
8
+ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
9
+ const UNFILED_NO_SENT_MAILBOX = "Sent, but not filed: this account has no folder appointed to the Sent role and none that a Sent folder could be recognised by. Appoint one in the account's folder settings and later messages will be filed there.";
10
+ const UNFILED_SIGNED_OUT = "Sent, but not filed: this account has to be signed in again before a copy can be stored in Sent.";
11
+ const ROW_SURVIVED_ITS_DELETE = "Sent message was filed but its outbox row survived its delete and stays hidden until the boot-time repair";
12
+ const unfiledAppendRefused = (fullPath, error) => error instanceof OutboxAttachmentUnavailableError
13
+ ? `Sent, but not filed: the copy for ${fullPath} could not be built because ${error.message}.`
14
+ : `Sent, but not filed: the mail server refused to store a copy in ${fullPath} (${error instanceof Error ? error.message : String(error)}).`;
15
+ export const handleAppendSentMessage = async (event, ctx, receiveCount) => {
16
+ const { log, pool, config } = ctx;
17
+ const { account: accountService, outboxMessage: outboxMessageService, outboxAttachment: outboxAttachmentService, mailboxSpecialUse: mailboxSpecialUseService, secrets, } = ctx.client;
18
+ const { accountId, outboxMessageId } = event;
19
+ log.info({ event: event.type, accountId, outboxMessageId }, "Handling event");
20
+ const account = await accountService.get(accountId);
21
+ if (isAccountDeleted(account, log)) {
22
+ return;
23
+ }
24
+ // A row this event names can be gone by the time the event is redelivered:
25
+ // its own last attempt deleted it, or the boot repair dropped it on the
26
+ // strength of a recorded uid. Either way the work is done, and re-throwing
27
+ // the same NotFoundError on every redelivery only dead-letters it.
28
+ const outbox = await outboxMessageService
29
+ .get(account.accountConfigId, outboxMessageId)
30
+ .catch((error) => {
31
+ if (isNotFoundError(error))
32
+ return null;
33
+ throw error;
34
+ });
35
+ if (!outbox) {
36
+ log.warn({ accountId, outboxMessageId }, "Skipping APPEND_SENT_MESSAGE: the outbox row is already gone");
37
+ return;
38
+ }
39
+ if (outbox.status !== OutboxMessageStatus.sent) {
40
+ log.info({ outboxMessageId, status: outbox.status }, "Outbox message not in sent status, skipping APPEND");
41
+ return;
42
+ }
43
+ // The message left over SMTP; only the filing can still fail. Settling the
44
+ // row as `unfiled` keeps it in the Outbox list rather than deleting it, so a
45
+ // delivered message stays readable somewhere. Every path out of this handler
46
+ // that is not a confirmed APPEND ends here.
47
+ const settleUnfiled = async (reason) => {
48
+ log.error({ accountId, outboxMessageId, reason }, "Sent message could not be filed, settling the outbox row as unfiled");
49
+ await outboxMessageService.update(account.accountConfigId, outboxMessageId, {
50
+ status: OutboxMessageStatus.unfiled,
51
+ lastError: reason,
52
+ });
53
+ };
54
+ // The copy is in Sent, so the row is a leftover and deleting it is all that
55
+ // is left to do. Both steps are idempotent against a row that is already
56
+ // gone, so nothing here needs to know how far the last attempt got.
57
+ //
58
+ // Files first, row second, the same order outbox-queue.ts discards a draft
59
+ // in. Nothing but this row points at those objects: a row that outlives its
60
+ // files is hidden and the boot repair drops it, while files that outlive
61
+ // their row name a message nothing can reach and are vouched for forever.
62
+ const dropOutboxRow = async () => {
63
+ await outboxAttachmentService.discardAll(account.accountConfigId, accountId, outboxMessageId);
64
+ await outboxMessageService.delete(account.accountConfigId, outboxMessageId);
65
+ log.info({ outboxMessageId }, "Deleted outbox row after successful APPEND to Sent");
66
+ };
67
+ // Everything this recovers from happened after the copy reached Sent, so
68
+ // re-appending is off the table and the delete is the only step left to
69
+ // retry. Below the redrive budget that retry is worth having; at it the
70
+ // record would dead-letter, so the row is left at `sent` for the repair.
71
+ const retryDropWithinBudget = (error) => {
72
+ if (receiveCount < config.attempts.appendSent)
73
+ throw error;
74
+ log.error({ accountId, outboxMessageId, error }, ROW_SURVIVED_ITS_DELETE);
75
+ };
76
+ // A redelivery of an event whose APPEND already landed. The copy is in the
77
+ // user's Sent folder and the only step still owed is the delete that failed
78
+ // last time; starting from the top would file a second copy of a message the
79
+ // user sent once (#858). Ahead of the Sent-mailbox lookup on purpose — a
80
+ // message that is already filed does not need one, and an account that lost
81
+ // its Sent appointment in between must not settle as unfiled.
82
+ if (isSentCopyFiled(outbox)) {
83
+ log.info({ outboxMessageId, appendedUid: outbox.appendedUid }, "Sent copy was already filed by an earlier attempt, skipping the APPEND");
84
+ await dropOutboxRow().catch(retryDropWithinBudget);
85
+ return;
86
+ }
87
+ const sentMailbox = await mailboxSpecialUseService.findSentMailbox(accountId);
88
+ if (!sentMailbox) {
89
+ await settleUnfiled(UNFILED_NO_SENT_MAILBOX);
90
+ return;
91
+ }
92
+ let appendedUid = APPENDED_UID_NONE;
93
+ const failure = await withOAuthLifecycle(buildLifecycleDeps(secrets, accountService, ctx.oauth), account, log, async (credentials) => {
94
+ // Inside the retried unit: a file that cannot be read is retried below
95
+ // the budget and settles `unfiled` at it, rather than dead-lettering a
96
+ // delivered message out of every view.
97
+ const attachments = await outboxAttachmentService.contentsFor(account.accountConfigId, accountId, outboxMessageId);
98
+ const scope = pool.borrow(account, credentials);
99
+ await scope
100
+ .getConnection()
101
+ .then(async (connection) => {
102
+ const rawMessage = await renderRawMessage(buildMailMessage(outbox, attachments));
103
+ const result = await connection.append(sentMailbox.fullPath, rawMessage, ["\\Seen"]);
104
+ log.info({
105
+ outboxMessageId,
106
+ sentMailbox: sentMailbox.fullPath,
107
+ uid: result.uid,
108
+ uidValidity: result.uidValidity,
109
+ }, "Appended sent message to Sent mailbox");
110
+ // A server without UIDPLUS files the copy and names no uid for
111
+ // it, which is still a filed copy and has to read as one.
112
+ appendedUid = result.uid > 0 ? result.uid : APPENDED_UID_UNREPORTED;
113
+ })
114
+ .finally(() => scope.release());
115
+ // The idempotency key, and the only reason a redelivery is safe. It is
116
+ // written before the delete and never after it: from here on every step
117
+ // can fail and be retried without the user gaining a second copy, and
118
+ // the one window that still can is this single row update.
119
+ await outboxMessageService.update(account.accountConfigId, outboxMessageId, { appendedUid });
120
+ // The message now lives in the IMAP Sent folder. Drop the outbox row so
121
+ // the user does not see it twice in the UI (Outbox + Sent). Issue #178.
122
+ await dropOutboxRow();
123
+ }).then(() => null, (error) => {
124
+ // Below the redrive budget this is an ordinary retry — connections
125
+ // drop, servers go away, and the row is still `sent` for the next
126
+ // attempt to pick up. At the budget the record would dead-letter, and a
127
+ // dead-lettered APPEND is exactly how a delivered message goes missing.
128
+ //
129
+ // The budget still binds after a landed APPEND, though no longer to
130
+ // hold off a duplicate: the recorded uid does that, and the retries
131
+ // the budget allows re-drive the delete alone.
132
+ if (receiveCount < config.attempts.appendSent)
133
+ throw error;
134
+ return error;
135
+ });
136
+ // The APPEND landed: the copy is in Sent whatever failed after it, so there
137
+ // is nothing to settle as unfiled. A row that outlives its delete holds
138
+ // `sent`, which every view hides, until the migrator's boot-time stranded-row
139
+ // repair drops it — the next container start, not sooner (#824).
140
+ if (isSentCopyFiled({ appendedUid })) {
141
+ if (failure) {
142
+ log.error({ accountId, outboxMessageId, reason: String(failure) }, ROW_SURVIVED_ITS_DELETE);
143
+ }
144
+ return;
145
+ }
146
+ // A terminal auth failure returns here without throwing — withOAuthLifecycle
147
+ // flips the account to reauth_required and ACKs the record, which without
148
+ // this would leave the row at `sent` and the message nowhere.
149
+ await settleUnfiled(failure
150
+ ? unfiledAppendRefused(sentMailbox.fullPath, failure)
151
+ : UNFILED_SIGNED_OUT);
152
+ };