@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.
- package/README.md +100 -0
- package/build.mjs +17 -0
- package/package.json +50 -0
- package/src/account-check.test.ts +122 -0
- package/src/account-check.ts +88 -0
- package/src/body-sync-gate.test.ts +185 -0
- package/src/body-sync-gate.ts +86 -0
- package/src/cli.ts +211 -0
- package/src/connection-scope.test.ts +266 -0
- package/src/connection-scope.ts +335 -0
- package/src/e2e-processor-shim.ts +248 -0
- package/src/emit.test.ts +44 -0
- package/src/emit.ts +142 -0
- package/src/events.ts +221 -0
- package/src/handlers/append-sent-message.ts +163 -0
- package/src/handlers/delete-account-objects.test.ts +81 -0
- package/src/handlers/delete-account-objects.ts +116 -0
- package/src/handlers/empty-trash.ts +136 -0
- package/src/handlers/flag-push.test.ts +25 -0
- package/src/handlers/flag-push.ts +224 -0
- package/src/handlers/mailbox-management.ts +266 -0
- package/src/handlers/mailbox-sync-order.test.ts +93 -0
- package/src/handlers/mailbox-sync-order.ts +65 -0
- package/src/handlers/message-copy.ts +219 -0
- package/src/handlers/message-delete.test.ts +176 -0
- package/src/handlers/message-delete.ts +283 -0
- package/src/handlers/message-move.test.ts +168 -0
- package/src/handlers/message-move.ts +298 -0
- package/src/handlers/placement-move-push.test.ts +234 -0
- package/src/handlers/placement-move-push.ts +434 -0
- package/src/handlers/sync-mailboxes.ts +241 -0
- package/src/handlers/sync-message-body.test.ts +375 -0
- package/src/handlers/sync-message-body.ts +337 -0
- package/src/handlers/sync-messages-deleted-account.test.ts +141 -0
- package/src/handlers/sync-messages.test.ts +204 -0
- package/src/handlers/sync-messages.ts +412 -0
- package/src/handlers/sync-reserved-host.test.ts +97 -0
- package/src/index.test.ts +22 -0
- package/src/index.ts +70 -0
- package/src/poller.ts +49 -0
- package/src/processor.test.ts +58 -0
- package/src/processor.ts +66 -0
- package/src/scheduler/config.test.ts +40 -0
- package/src/scheduler/config.ts +52 -0
- package/src/scheduler/decide-due.test.ts +44 -0
- package/src/scheduler/decide-due.ts +26 -0
- package/src/scheduler/handler.ts +52 -0
- package/src/scheduler/local-runner.ts +76 -0
- package/src/scheduler/run-tick.test.ts +248 -0
- package/src/scheduler/run-tick.ts +141 -0
- package/src/with-oauth-lifecycle-deps.ts +62 -0
- package/src/with-oauth-lifecycle.test.ts +227 -0
- package/src/with-oauth-lifecycle.ts +125 -0
- 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
|
+
});
|