@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
package/src/emit.ts ADDED
@@ -0,0 +1,142 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
3
+ import { AwsQueryProtocol } from "@aws-sdk/core/protocols";
4
+ import { resolveSqsCredentials } from "@remit/sqs-client";
5
+ import { env } from "expect-env";
6
+ import type {
7
+ ImapEvent,
8
+ SyncMailboxesEvent,
9
+ SyncMessagesEvent,
10
+ } from "./events.js";
11
+
12
+ type EventInput = Omit<ImapEvent, "eventId" | "timestamp">;
13
+
14
+ const mailboxesQueueUrl = env.SQS_QUEUE_URL_MAILBOXES;
15
+ const messagesQueueUrl = env.SQS_QUEUE_URL_MESSAGES;
16
+ // SYNC_MESSAGE_BODY events route to the single standard body queue (#612). It is
17
+ // a standard (non-FIFO) queue, so isFifoQueue() below skips the FIFO
18
+ // MessageGroupId/dedup parameters automatically.
19
+ const bodyQueueUrl = env.SQS_QUEUE_URL_BODY;
20
+ const flagsQueueUrl = env.SQS_QUEUE_URL_FLAGS;
21
+ const mailboxMgmtQueueUrl = env.SQS_QUEUE_URL_MAILBOX_MGMT;
22
+ const messageMgmtQueueUrl = env.SQS_QUEUE_URL_MESSAGE_MGMT;
23
+
24
+ const isLocal = mailboxesQueueUrl.startsWith("http://localhost");
25
+
26
+ const sqs = new SQSClient({
27
+ endpoint: isLocal ? new URL(mailboxesQueueUrl).origin : undefined,
28
+ ...(isLocal && { protocol: AwsQueryProtocol }),
29
+ credentials: resolveSqsCredentials(),
30
+ });
31
+
32
+ const queueUrlMap: Record<ImapEvent["type"], string> = {
33
+ SYNC_MAILBOXES: mailboxesQueueUrl,
34
+ SYNC_MESSAGES: messagesQueueUrl,
35
+ SYNC_MESSAGE_BODY: bodyQueueUrl,
36
+ MAILBOX_CREATE: mailboxMgmtQueueUrl,
37
+ MAILBOX_RENAME: mailboxMgmtQueueUrl,
38
+ MAILBOX_DELETE: mailboxMgmtQueueUrl,
39
+ MESSAGE_DELETE: messageMgmtQueueUrl,
40
+ MESSAGE_MOVE: messageMgmtQueueUrl,
41
+ MESSAGE_COPY: messageMgmtQueueUrl,
42
+ EMPTY_TRASH: messageMgmtQueueUrl,
43
+ APPEND_SENT_MESSAGE: messageMgmtQueueUrl,
44
+ // Rides messageMgmtQueue (issue #1271) rather than a dedicated queue — its
45
+ // payload carries only our message id (never a UID, unlike the legacy
46
+ // MESSAGE_MOVE event); per-event-type payload shape, same queue.
47
+ PLACEMENT_MOVE_PUSH: messageMgmtQueueUrl,
48
+ // Rides the existing flags queue (issue #1273) — this is imap-worker's OWN
49
+ // re-arm hint (the periodic per-mailbox sync tick catching up a marker
50
+ // stuck `pending`), distinct from `FlagPushService`'s user-facing hint
51
+ // (remit-mailbox-service, sent from the API on its own SQS client onto
52
+ // SQS_QUEUE_URL). Both land on a queue this worker already consumes;
53
+ // dispatch is by `type`, not by which queue delivered the message.
54
+ FLAG_PUSH: flagsQueueUrl,
55
+ };
56
+
57
+ /**
58
+ * FIFO queue event types that support deduplication.
59
+ * Management events (MAILBOX_*, MESSAGE_*, EMPTY_TRASH) are not deduplicated
60
+ * because each operation is unique.
61
+ */
62
+ const fifoEventTypes = new Set([
63
+ "SYNC_MAILBOXES",
64
+ "SYNC_MESSAGES",
65
+ // flagsQueue is FIFO and requires a MessageGroupId on every message.
66
+ // Content-based deduplication (queue-level setting) covers the dedup id —
67
+ // no per-event case is needed in getDeduplicationId below, same as the
68
+ // (standard-queue) management events that fall through its default.
69
+ "FLAG_PUSH",
70
+ ]);
71
+
72
+ /**
73
+ * Generate a deduplication ID for FIFO queue events.
74
+ * SQS deduplication window is 5 minutes - duplicate messages within
75
+ * this window are rejected.
76
+ */
77
+ export const getDeduplicationId = (event: EventInput): string | undefined => {
78
+ switch (event.type) {
79
+ case "SYNC_MAILBOXES": {
80
+ const e = event as Omit<SyncMailboxesEvent, "eventId" | "timestamp">;
81
+ return `SYNC_MAILBOXES:${e.accountId}`;
82
+ }
83
+
84
+ case "SYNC_MESSAGES": {
85
+ const e = event as Omit<SyncMessagesEvent, "eventId" | "timestamp">;
86
+ // A continuation carries a per-batch cursor so batches 2..N are not
87
+ // deduped against the initial event (which has none) or each other; the
88
+ // cursor-less initial id still dedups concurrent fresh syncs of a mailbox.
89
+ return e.resumeCursor === undefined
90
+ ? `SYNC_MESSAGES:${e.mailboxId}`
91
+ : `SYNC_MESSAGES:${e.mailboxId}:${e.resumeCursor}`;
92
+ }
93
+
94
+ default:
95
+ // Management events are not deduplicated
96
+ return undefined;
97
+ }
98
+ };
99
+
100
+ /**
101
+ * Check if queue URL is a FIFO queue (ends with .fifo)
102
+ */
103
+ const isFifoQueue = (queueUrl: string): boolean => queueUrl.endsWith(".fifo");
104
+
105
+ export interface EmitEventOptions {
106
+ /**
107
+ * Delay delivery of the message by this many seconds (0-900).
108
+ * Useful for retry backoff to avoid overwhelming IMAP servers.
109
+ */
110
+ delaySeconds?: number;
111
+ }
112
+
113
+ export const emitEvent = async (
114
+ event: EventInput,
115
+ options?: EmitEventOptions,
116
+ ) => {
117
+ const fullEvent: ImapEvent = {
118
+ ...event,
119
+ eventId: randomUUID(),
120
+ timestamp: Date.now(),
121
+ } as ImapEvent;
122
+
123
+ const queueUrl = queueUrlMap[event.type];
124
+ const useFifo = isFifoQueue(queueUrl) && fifoEventTypes.has(event.type);
125
+
126
+ // ElasticMQ FIFO queues don't support per-message DelaySeconds
127
+ const useDelay = options?.delaySeconds && !isLocal;
128
+
129
+ await sqs.send(
130
+ new SendMessageCommand({
131
+ QueueUrl: queueUrl,
132
+ MessageBody: JSON.stringify(fullEvent),
133
+ // Delay delivery for retry backoff (skip for local ElasticMQ)
134
+ ...(useDelay && { DelaySeconds: options.delaySeconds }),
135
+ // FIFO queue parameters - only set if queue is FIFO
136
+ ...(useFifo && {
137
+ MessageGroupId: event.accountId,
138
+ MessageDeduplicationId: getDeduplicationId(event),
139
+ }),
140
+ }),
141
+ );
142
+ };
package/src/events.ts ADDED
@@ -0,0 +1,221 @@
1
+ export interface BaseEvent {
2
+ accountId: string;
3
+ eventId: string; // Idempotency key
4
+ timestamp: number; // Unix timestamp
5
+ }
6
+
7
+ export interface SyncMailboxesEvent extends BaseEvent {
8
+ type: "SYNC_MAILBOXES";
9
+ }
10
+
11
+ export interface SyncMessagesEvent extends BaseEvent {
12
+ type: "SYNC_MESSAGES";
13
+ mailboxId: string;
14
+ fullSync?: boolean; // If true, ignore lastSyncUid
15
+ /**
16
+ * Set on a continuation event (the "next batch" a batch emits when it drains
17
+ * only part of a mailbox). Carries the batch's remaining-message count so the
18
+ * FIFO deduplication id is distinct per batch — otherwise every continuation
19
+ * for a mailbox shares one dedup id and the 5-minute window silently drops
20
+ * batches 2..N, capping any mailbox over one batch. The value is not used by
21
+ * the handler (the resume point comes from the persisted watermark); it only
22
+ * makes the dedup id unique.
23
+ */
24
+ resumeCursor?: number;
25
+ }
26
+
27
+ /**
28
+ * A message to body-sync, carrying the UID resolved at envelope-sync time so
29
+ * the consumer can issue one ranged FETCH without a per-message DDB lookup.
30
+ */
31
+ export interface SyncMessageBodyTarget {
32
+ messageId: string;
33
+ uid: number;
34
+ }
35
+
36
+ export interface SyncMessageBodyEvent extends BaseEvent {
37
+ type: "SYNC_MESSAGE_BODY";
38
+ mailboxId: string;
39
+ /**
40
+ * Message ids to sync. Always present for backward compatibility; the
41
+ * consumer falls back to this list (looking up each UID) when `messages`
42
+ * is absent.
43
+ */
44
+ messageIds: string[];
45
+ /**
46
+ * Preferred shape: messageId+uid pairs. When present, the consumer skips the
47
+ * per-message UID lookup and fetches the whole batch in one ranged FETCH.
48
+ * Optional so older in-flight events (ids only) still process.
49
+ */
50
+ messages?: SyncMessageBodyTarget[];
51
+ /**
52
+ * Set only by the read-miss re-arm cue (`BodySyncQueueService.requestBodySync`,
53
+ * called when a `/content` read finds `bodyStorageKey` set but the storage
54
+ * object missing). Tells the consumer to bypass the "already stored" skip
55
+ * guard and re-fetch + rewrite the body even though the DB row already
56
+ * carries a `bodyStorageKey` — otherwise stale metadata (key set, object
57
+ * gone) makes the read-miss retry loop forever, since the bulk skip guard
58
+ * would keep treating the message as already synced. Optional so older
59
+ * in-flight events (no force) keep the original skip behavior.
60
+ */
61
+ force?: boolean;
62
+ }
63
+
64
+ export interface MailboxCreateEvent extends BaseEvent {
65
+ type: "MAILBOX_CREATE";
66
+ mailboxId: string;
67
+ path: string;
68
+ subscribe?: boolean;
69
+ }
70
+
71
+ export interface MailboxRenameEvent extends BaseEvent {
72
+ type: "MAILBOX_RENAME";
73
+ mailboxId: string;
74
+ oldPath: string;
75
+ newPath: string;
76
+ }
77
+
78
+ export interface MailboxDeleteEvent extends BaseEvent {
79
+ type: "MAILBOX_DELETE";
80
+ mailboxId: string;
81
+ path: string;
82
+ }
83
+
84
+ export type MailboxManagementEvent =
85
+ | MailboxCreateEvent
86
+ | MailboxRenameEvent
87
+ | MailboxDeleteEvent;
88
+
89
+ /**
90
+ * Event for deleting a message (move to trash or permanent delete).
91
+ */
92
+ export interface MessageDeleteEvent extends BaseEvent {
93
+ type: "MESSAGE_DELETE";
94
+ messageId: string;
95
+ mailboxId: string;
96
+ mailboxPath: string;
97
+ uid: number;
98
+ operation: "move_to_trash" | "permanent_delete";
99
+ destinationMailboxId?: string;
100
+ destinationMailboxPath?: string;
101
+ }
102
+
103
+ /**
104
+ * Event for moving a message to another mailbox.
105
+ */
106
+ export interface MessageMoveEvent extends BaseEvent {
107
+ type: "MESSAGE_MOVE";
108
+ messageId: string;
109
+ sourceMailboxId: string;
110
+ sourceMailboxPath: string;
111
+ destinationMailboxId: string;
112
+ destinationMailboxPath: string;
113
+ uid: number;
114
+ }
115
+
116
+ /**
117
+ * Event for emptying the Trash mailbox.
118
+ */
119
+ export interface EmptyTrashEvent extends BaseEvent {
120
+ type: "EMPTY_TRASH";
121
+ trashMailboxId: string;
122
+ trashMailboxPath: string;
123
+ }
124
+
125
+ /**
126
+ * Event for copying a message to another mailbox.
127
+ */
128
+ export interface MessageCopyEvent extends BaseEvent {
129
+ type: "MESSAGE_COPY";
130
+ sourceMessageId: string;
131
+ newMessageId: string;
132
+ sourceMailboxId: string;
133
+ sourceMailboxPath: string;
134
+ destinationMailboxId: string;
135
+ destinationMailboxPath: string;
136
+ uid: number;
137
+ }
138
+
139
+ /**
140
+ * Event for appending a sent message to the Sent mailbox via IMAP APPEND.
141
+ */
142
+ export interface AppendSentMessageEvent extends BaseEvent {
143
+ type: "APPEND_SENT_MESSAGE";
144
+ outboxMessageId: string;
145
+ }
146
+
147
+ /**
148
+ * Drains one pending placement-move marker (issue #1271, epic #1281) to
149
+ * IMAP. Deliberately carries no UID and no destination — both are resolved
150
+ * fresh from the Message row and the `MessagePlacementMove` marker at push
151
+ * time (epic invariant 1), so this event stays valid across any amount of
152
+ * queue delay or an unrelated UIDVALIDITY rebuild (#1272). If the marker is
153
+ * gone by the time this is processed (confirmed, superseded, or an external
154
+ * delete already reconciled it), the handler is a no-op.
155
+ */
156
+ export interface PlacementMovePushEvent extends BaseEvent {
157
+ type: "PLACEMENT_MOVE_PUSH";
158
+ accountConfigId: string;
159
+ messageId: string;
160
+ }
161
+
162
+ /**
163
+ * Wake-up hint for one pending flag-push marker (issue #1273, epic #1281).
164
+ * Carries only our message id + the flag field — never a UID and never the
165
+ * desired add/remove transition (both live on the `MessageFlagPush` marker,
166
+ * resolved fresh at push time, epic invariant 1). Produced two ways: by
167
+ * `FlagPushService` (remit-mailbox-service) right after a user's flag flip
168
+ * commits locally, and by the periodic per-mailbox sync tick
169
+ * (`sync-messages.ts`) re-arming any marker it finds still stuck `pending`
170
+ * (a prior enqueue that never landed). If the marker is gone or has moved
171
+ * past `pending` by the time this is processed, the handler is a no-op.
172
+ */
173
+ export interface FlagPushEvent extends BaseEvent {
174
+ type: "FLAG_PUSH";
175
+ accountConfigId: string;
176
+ messageId: string;
177
+ flagName: string;
178
+ }
179
+
180
+ export interface DeleteAccountObjectsEvent {
181
+ type: "DELETE_ACCOUNT_OBJECTS";
182
+ accountConfigId: string;
183
+ continuationToken?: string;
184
+ }
185
+
186
+ /**
187
+ * Sent by the account-deletion fanout worker once per accountId under a
188
+ * deleted AccountConfig. The actual stop semantics already happen via the
189
+ * account-tombstone fence (`isActive=false` + `deletedAt`) flipped by the
190
+ * deletion API: any in-flight or future event for the account is dropped
191
+ * by `isAccountDeleted`. This event exists as an explicit signal in the
192
+ * cascade contract — when the imap-worker grows real per-account drain or
193
+ * connection-teardown semantics it hangs off this hook.
194
+ */
195
+ export interface ImapWorkerStopEvent {
196
+ type: "IMAP_WORKER_STOP";
197
+ accountConfigId: string;
198
+ accountId: string;
199
+ }
200
+
201
+ /** Union of all event types the worker can process (including non-IMAP ones). */
202
+ export type WorkerEvent =
203
+ | ImapEvent
204
+ | DeleteAccountObjectsEvent
205
+ | ImapWorkerStopEvent;
206
+
207
+ export type MessageManagementEvent =
208
+ | MessageDeleteEvent
209
+ | MessageMoveEvent
210
+ | EmptyTrashEvent
211
+ | MessageCopyEvent;
212
+
213
+ export type ImapEvent =
214
+ | SyncMailboxesEvent
215
+ | SyncMessagesEvent
216
+ | SyncMessageBodyEvent
217
+ | MailboxManagementEvent
218
+ | MessageManagementEvent
219
+ | AppendSentMessageEvent
220
+ | PlacementMovePushEvent
221
+ | FlagPushEvent;
@@ -0,0 +1,163 @@
1
+ import { getClient } from "@remit/backend/client";
2
+ import type {
3
+ IMailboxRepository,
4
+ IMailboxSpecialUseRepository,
5
+ OutboxMessageItem,
6
+ } from "@remit/data-ports";
7
+ import { MailboxSpecialUse } from "@remit/domain-enums";
8
+ import type { Logger } from "@remit/logger-lambda";
9
+ import nodemailer from "nodemailer";
10
+ import { isAccountDeleted } from "../account-check.js";
11
+ import { createConnectionScopeWithCredentials } from "../connection-scope.js";
12
+ import type { AppendSentMessageEvent } from "../events.js";
13
+ import { withOAuthLifecycle } from "../with-oauth-lifecycle.js";
14
+ import { buildLifecycleDeps } from "../with-oauth-lifecycle-deps.js";
15
+
16
+ const findSentMailbox = async (
17
+ mailboxSpecialUseService: IMailboxSpecialUseRepository,
18
+ mailboxService: IMailboxRepository,
19
+ accountId: string,
20
+ ): Promise<{ mailboxId: string; fullPath: string } | null> => {
21
+ const bySpecialUse = await mailboxSpecialUseService.findBySpecialUse(
22
+ accountId,
23
+ MailboxSpecialUse.Sent,
24
+ );
25
+ if (bySpecialUse) {
26
+ return bySpecialUse;
27
+ }
28
+
29
+ const commonSentNames = [
30
+ "Sent",
31
+ "Sent Items",
32
+ "Sent Messages",
33
+ "[Gmail]/Sent Mail",
34
+ ];
35
+ const mailboxResult = await mailboxService.listByAccount(accountId);
36
+
37
+ for (const name of commonSentNames) {
38
+ const found = mailboxResult.items.find(
39
+ (m) => m.fullPath.toLowerCase() === name.toLowerCase(),
40
+ );
41
+ if (found) {
42
+ return { mailboxId: found.mailboxId, fullPath: found.fullPath };
43
+ }
44
+ }
45
+
46
+ return null;
47
+ };
48
+
49
+ const buildRawMessage = async (outbox: OutboxMessageItem): Promise<Buffer> => {
50
+ const from = outbox.fromName
51
+ ? `${outbox.fromName} <${outbox.fromAddress}>`
52
+ : outbox.fromAddress;
53
+
54
+ const transport = nodemailer.createTransport({ streamTransport: true });
55
+
56
+ const info = await transport.sendMail({
57
+ from,
58
+ to: outbox.toAddresses.join(", "),
59
+ cc: outbox.ccAddresses?.join(", "),
60
+ bcc: outbox.bccAddresses?.join(", "),
61
+ replyTo: outbox.replyToAddress,
62
+ subject: outbox.subject,
63
+ text: outbox.textBody,
64
+ html: outbox.htmlBody,
65
+ messageId: `<${outbox.messageIdValue}>`,
66
+ inReplyTo: outbox.inReplyTo ? `<${outbox.inReplyTo}>` : undefined,
67
+ references: outbox.references?.map((r) => `<${r}>`).join(" "),
68
+ date: outbox.sentAt ? new Date(outbox.sentAt) : new Date(),
69
+ });
70
+
71
+ const chunks: Buffer[] = [];
72
+ for await (const chunk of info.message as AsyncIterable<Buffer>) {
73
+ chunks.push(chunk);
74
+ }
75
+ return Buffer.concat(chunks);
76
+ };
77
+
78
+ export const handleAppendSentMessage = async (
79
+ event: AppendSentMessageEvent,
80
+ log: Logger,
81
+ ): Promise<void> => {
82
+ const {
83
+ account: accountService,
84
+ outboxMessage: outboxMessageService,
85
+ mailboxSpecialUse: mailboxSpecialUseService,
86
+ mailbox: mailboxService,
87
+ secrets,
88
+ } = await getClient();
89
+
90
+ const { accountId, outboxMessageId } = event;
91
+
92
+ log.info({ event: event.type, accountId, outboxMessageId }, "Handling event");
93
+
94
+ const account = await accountService.get(accountId);
95
+ if (isAccountDeleted(account, log)) {
96
+ return;
97
+ }
98
+
99
+ const outbox = await outboxMessageService.get(
100
+ account.accountConfigId,
101
+ outboxMessageId,
102
+ );
103
+ if (outbox.status !== "sent") {
104
+ log.info(
105
+ { outboxMessageId, status: outbox.status },
106
+ "Outbox message not in sent status, skipping APPEND",
107
+ );
108
+ return;
109
+ }
110
+
111
+ const sentMailbox = await findSentMailbox(
112
+ mailboxSpecialUseService,
113
+ mailboxService,
114
+ accountId,
115
+ );
116
+ if (!sentMailbox) {
117
+ log.info({ accountId }, "No Sent mailbox found, skipping IMAP APPEND");
118
+ return;
119
+ }
120
+
121
+ await withOAuthLifecycle(
122
+ buildLifecycleDeps(secrets, accountService),
123
+ account,
124
+ log,
125
+ async (credentials) => {
126
+ const scope = createConnectionScopeWithCredentials(account, credentials);
127
+
128
+ await scope
129
+ .getConnection()
130
+ .then(async (connection) => {
131
+ const rawMessage = await buildRawMessage(outbox);
132
+
133
+ const result = await connection.append(
134
+ sentMailbox.fullPath,
135
+ rawMessage,
136
+ ["\\Seen"],
137
+ );
138
+
139
+ log.info(
140
+ {
141
+ outboxMessageId,
142
+ sentMailbox: sentMailbox.fullPath,
143
+ uid: result.uid,
144
+ uidValidity: result.uidValidity,
145
+ },
146
+ "Appended sent message to Sent mailbox",
147
+ );
148
+ })
149
+ .finally(() => scope.disconnect());
150
+
151
+ // The message now lives in the IMAP Sent folder. Drop the outbox row so
152
+ // the user does not see it twice in the UI (Outbox + Sent). Issue #178.
153
+ await outboxMessageService.delete(
154
+ account.accountConfigId,
155
+ outboxMessageId,
156
+ );
157
+ log.info(
158
+ { outboxMessageId },
159
+ "Deleted outbox row after successful APPEND to Sent",
160
+ );
161
+ },
162
+ );
163
+ };
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { DeleteAccountObjectsEvent } from "./delete-account-objects.js";
4
+
5
+ describe("DeleteAccountObjects handler", () => {
6
+ it("event shape is correct", () => {
7
+ const event: DeleteAccountObjectsEvent = {
8
+ type: "DELETE_ACCOUNT_OBJECTS",
9
+ accountConfigId: "test-account-config-id-12345",
10
+ };
11
+
12
+ assert.equal(event.type, "DELETE_ACCOUNT_OBJECTS");
13
+ assert.equal(event.accountConfigId, "test-account-config-id-12345");
14
+ assert.equal(event.continuationToken, undefined);
15
+ });
16
+
17
+ it("event with continuation token is correct", () => {
18
+ const event: DeleteAccountObjectsEvent = {
19
+ type: "DELETE_ACCOUNT_OBJECTS",
20
+ accountConfigId: "test-account-config-id-12345",
21
+ continuationToken: "abc123",
22
+ };
23
+
24
+ assert.equal(event.continuationToken, "abc123");
25
+ });
26
+
27
+ it("S3 prefix follows expected pattern", () => {
28
+ const accountConfigId = "test-account-config-id-12345";
29
+ const prefix = `accounts/${accountConfigId}/`;
30
+
31
+ assert.ok(prefix.startsWith("accounts/"));
32
+ assert.ok(prefix.endsWith("/"));
33
+ assert.ok(prefix.includes(accountConfigId));
34
+ });
35
+
36
+ it("batch size calculation respects limit", () => {
37
+ const BATCH_SIZE = 1_000;
38
+ const keys = Array.from({ length: 2500 }, (_, i) => `key-${i}`);
39
+
40
+ // Simulate batching
41
+ const batches: string[][] = [];
42
+ for (let i = 0; i < keys.length; i += BATCH_SIZE) {
43
+ batches.push(keys.slice(i, i + BATCH_SIZE));
44
+ }
45
+
46
+ assert.equal(batches.length, 3);
47
+ assert.equal(batches[0].length, 1000);
48
+ assert.equal(batches[1].length, 1000);
49
+ assert.equal(batches[2].length, 500);
50
+ });
51
+
52
+ it("re-enqueue event preserves continuation token", () => {
53
+ const accountConfigId = "test-id";
54
+ const continuationToken = "next-page-token";
55
+
56
+ const reenqueueEvent: DeleteAccountObjectsEvent = {
57
+ type: "DELETE_ACCOUNT_OBJECTS",
58
+ accountConfigId,
59
+ continuationToken,
60
+ };
61
+
62
+ const body = JSON.stringify(reenqueueEvent);
63
+ const parsed = JSON.parse(body) as DeleteAccountObjectsEvent;
64
+
65
+ assert.equal(parsed.type, "DELETE_ACCOUNT_OBJECTS");
66
+ assert.equal(parsed.accountConfigId, accountConfigId);
67
+ assert.equal(parsed.continuationToken, continuationToken);
68
+ });
69
+
70
+ it("timeout detection triggers re-enqueue", () => {
71
+ const MIN_REMAINING_MS = 30_000;
72
+
73
+ // Simulate near-timeout scenario
74
+ const getRemainingTimeMs = () => 25_000;
75
+ assert.ok(getRemainingTimeMs() < MIN_REMAINING_MS);
76
+
77
+ // Simulate enough time
78
+ const getRemainingTimeMsOk = () => 60_000;
79
+ assert.ok(getRemainingTimeMsOk() >= MIN_REMAINING_MS);
80
+ });
81
+ });