@remit/mailbox-service 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 +147 -0
- package/package.json +65 -0
- package/src/account-credentials.ts +128 -0
- package/src/adversarial-mime.e2e.test.ts +311 -0
- package/src/attribute-mapper.test.ts +78 -0
- package/src/attribute-mapper.ts +147 -0
- package/src/body-part-mapper.property.test.ts +354 -0
- package/src/body-part-mapper.ts +360 -0
- package/src/body-sync-queue.test.ts +124 -0
- package/src/body-sync-queue.ts +131 -0
- package/src/body-sync-terminal.test.ts +265 -0
- package/src/body-sync-terminal.ts +172 -0
- package/src/body-sync.ts +1202 -0
- package/src/connection-factory.ts +102 -0
- package/src/connection-test.ts +208 -0
- package/src/credentials.test.ts +181 -0
- package/src/filters/match.test.ts +181 -0
- package/src/filters/match.ts +143 -0
- package/src/filters/pipeline.ts +243 -0
- package/src/flag-push-terminal.test.ts +156 -0
- package/src/flag-push-terminal.ts +106 -0
- package/src/flag-push.test.ts +408 -0
- package/src/flag-push.ts +198 -0
- package/src/flag-queue.ts +500 -0
- package/src/heuristics/classifyByHeaders.test.ts +578 -0
- package/src/heuristics/classifyByHeaders.ts +369 -0
- package/src/heuristics/classifyPlacement.ts +96 -0
- package/src/heuristics/socialDomains.ts +23 -0
- package/src/heuristics/transactionalDomains.ts +26 -0
- package/src/imapflow-connect-retry.test.ts +108 -0
- package/src/imapflow-connection.e2e.test.ts +355 -0
- package/src/imapflow-connection.integ.test.ts +327 -0
- package/src/imapflow-connection.test.ts +161 -0
- package/src/imapflow-connection.ts +1374 -0
- package/src/index.ts +214 -0
- package/src/mailbox-cursor-rebuild.test.ts +171 -0
- package/src/mailbox-cursor-rebuild.ts +146 -0
- package/src/mailbox-cursor.test.ts +249 -0
- package/src/mailbox-cursor.ts +177 -0
- package/src/mailbox-management.test.ts +103 -0
- package/src/mailbox-management.ts +235 -0
- package/src/mailbox-queue.ts +258 -0
- package/src/mailbox-sync.test.ts +153 -0
- package/src/mailbox-sync.ts +590 -0
- package/src/message-move.ts +825 -0
- package/src/message-parser.ts +26 -0
- package/src/message-sync.ts +1021 -0
- package/src/mime-walker.test.ts +311 -0
- package/src/mime-walker.ts +305 -0
- package/src/outbox-queue.ts +294 -0
- package/src/pass-through-unit-of-work.ts +17 -0
- package/src/placement-move-terminal.test.ts +179 -0
- package/src/placement-move-terminal.ts +104 -0
- package/src/placement-move.ts +224 -0
- package/src/snippet.test.ts +213 -0
- package/src/snippet.ts +152 -0
- package/src/stale-message-reconcile.test.ts +82 -0
- package/src/stale-message-reconcile.ts +55 -0
- package/src/test-helpers/isolated-mailbox.ts +44 -0
- package/src/test-helpers/mailfuzz-connection.ts +27 -0
- package/src/test-helpers/mime-tree-generator.ts +461 -0
- package/src/text/normalizer.test.ts +223 -0
- package/src/text/normalizer.ts +140 -0
- package/src/types/mailparser-augment.d.ts +15 -0
- package/src/types/natural-porter-stemmer.d.ts +50 -0
- package/src/types/stopword.d.ts +69 -0
- package/src/types.ts +349 -0
- package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
- package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
- package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
- package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
- package/test/fixtures/mime/02-flat-text-html.eml +10 -0
- package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
- package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
- package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
- package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
- package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
- package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
- package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
- package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
- package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
- package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
- package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
- package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
- package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
- package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
- package/test/fixtures/mime/10-calendar-invite.eml +33 -0
- package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
- package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
- package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
- package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
- package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
- package/test/fixtures/mime/12-base64-binary.eml +20 -0
- package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
- package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
- package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
- package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
- package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
- package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
- package/test/fixtures/mime/15-empty-text-part.eml +18 -0
- package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
- package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
- package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
- package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
SendMessageBatchCommand,
|
|
4
|
+
SendMessageCommand,
|
|
5
|
+
type SQSClient,
|
|
6
|
+
} from "@aws-sdk/client-sqs";
|
|
7
|
+
import type {
|
|
8
|
+
IMailboxRepository,
|
|
9
|
+
IMailboxSpecialUseRepository,
|
|
10
|
+
IMessageRepository,
|
|
11
|
+
IThreadMessageRepository,
|
|
12
|
+
} from "@remit/data-ports";
|
|
13
|
+
import { base36uuid } from "@remit/data-ports/id";
|
|
14
|
+
import { MessageStatus, MessageSyncStatus } from "@remit/domain-enums";
|
|
15
|
+
import { createQueueProducer } from "@remit/sqs-client/producer";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Event types for message move/delete operations.
|
|
19
|
+
* These match the worker event types in remit-imap-worker/events.ts.
|
|
20
|
+
*/
|
|
21
|
+
interface MessageDeleteEvent {
|
|
22
|
+
type: "MESSAGE_DELETE";
|
|
23
|
+
eventId: string;
|
|
24
|
+
timestamp: number;
|
|
25
|
+
accountId: string;
|
|
26
|
+
messageId: string;
|
|
27
|
+
mailboxId: string;
|
|
28
|
+
mailboxPath: string;
|
|
29
|
+
uid: number;
|
|
30
|
+
operation: "move_to_trash" | "permanent_delete";
|
|
31
|
+
destinationMailboxId?: string;
|
|
32
|
+
destinationMailboxPath?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface MessageMoveEvent {
|
|
36
|
+
type: "MESSAGE_MOVE";
|
|
37
|
+
eventId: string;
|
|
38
|
+
timestamp: number;
|
|
39
|
+
accountId: string;
|
|
40
|
+
messageId: string;
|
|
41
|
+
sourceMailboxId: string;
|
|
42
|
+
sourceMailboxPath: string;
|
|
43
|
+
destinationMailboxId: string;
|
|
44
|
+
destinationMailboxPath: string;
|
|
45
|
+
uid: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface EmptyTrashEvent {
|
|
49
|
+
type: "EMPTY_TRASH";
|
|
50
|
+
eventId: string;
|
|
51
|
+
timestamp: number;
|
|
52
|
+
accountId: string;
|
|
53
|
+
trashMailboxId: string;
|
|
54
|
+
trashMailboxPath: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface MessageCopyEvent {
|
|
58
|
+
type: "MESSAGE_COPY";
|
|
59
|
+
eventId: string;
|
|
60
|
+
timestamp: number;
|
|
61
|
+
accountId: string;
|
|
62
|
+
sourceMessageId: string;
|
|
63
|
+
newMessageId: string;
|
|
64
|
+
sourceMailboxId: string;
|
|
65
|
+
sourceMailboxPath: string;
|
|
66
|
+
destinationMailboxId: string;
|
|
67
|
+
destinationMailboxPath: string;
|
|
68
|
+
uid: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type MessageMoveQueueEvent =
|
|
72
|
+
| MessageDeleteEvent
|
|
73
|
+
| MessageMoveEvent
|
|
74
|
+
| EmptyTrashEvent
|
|
75
|
+
| MessageCopyEvent;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Logger interface
|
|
79
|
+
*/
|
|
80
|
+
export interface MessageMoveLogger {
|
|
81
|
+
info(obj: Record<string, unknown>, msg: string): void;
|
|
82
|
+
error(obj: Record<string, unknown>, msg: string): void;
|
|
83
|
+
warn?(obj: Record<string, unknown>, msg: string): void;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const noopLogger: MessageMoveLogger = {
|
|
87
|
+
info: () => {},
|
|
88
|
+
error: () => {},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Configuration for MessageMoveService
|
|
93
|
+
*/
|
|
94
|
+
export interface MessageMoveConfig {
|
|
95
|
+
messageService: IMessageRepository;
|
|
96
|
+
mailboxService: IMailboxRepository;
|
|
97
|
+
mailboxSpecialUseService: IMailboxSpecialUseRepository;
|
|
98
|
+
threadMessageService: IThreadMessageRepository;
|
|
99
|
+
sqsQueueUrl: string;
|
|
100
|
+
sqsEndpoint?: string;
|
|
101
|
+
logger?: MessageMoveLogger;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Options for delete operations
|
|
106
|
+
*/
|
|
107
|
+
export interface DeleteOptions {
|
|
108
|
+
/** Move to Trash instead of permanent delete. Default: true */
|
|
109
|
+
toTrash?: boolean;
|
|
110
|
+
/** Permanently delete even if in Trash. Default: false */
|
|
111
|
+
permanent?: boolean;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Service for moving, copying, and deleting messages.
|
|
116
|
+
*
|
|
117
|
+
* Implements optimistic local-first pattern:
|
|
118
|
+
* 1. Updates local DynamoDB state immediately (Message + ThreadMessage)
|
|
119
|
+
* 2. Enqueues event to SQS for worker to sync to IMAP
|
|
120
|
+
*
|
|
121
|
+
* Following RFC 016 for message deletion and moving.
|
|
122
|
+
*/
|
|
123
|
+
export class MessageMoveService {
|
|
124
|
+
private messageService: IMessageRepository;
|
|
125
|
+
private mailboxService: IMailboxRepository;
|
|
126
|
+
private mailboxSpecialUseService: IMailboxSpecialUseRepository;
|
|
127
|
+
private threadMessageService: IThreadMessageRepository;
|
|
128
|
+
private sqs: SQSClient;
|
|
129
|
+
private queueUrl: string;
|
|
130
|
+
private log: MessageMoveLogger;
|
|
131
|
+
|
|
132
|
+
constructor(config: MessageMoveConfig) {
|
|
133
|
+
this.messageService = config.messageService;
|
|
134
|
+
this.mailboxService = config.mailboxService;
|
|
135
|
+
this.mailboxSpecialUseService = config.mailboxSpecialUseService;
|
|
136
|
+
this.threadMessageService = config.threadMessageService;
|
|
137
|
+
this.queueUrl = config.sqsQueueUrl;
|
|
138
|
+
this.log = config.logger ?? noopLogger;
|
|
139
|
+
|
|
140
|
+
this.sqs = createQueueProducer({
|
|
141
|
+
queueUrl: config.sqsQueueUrl,
|
|
142
|
+
endpoint: config.sqsEndpoint,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Delete a message. By default moves to Trash.
|
|
148
|
+
*
|
|
149
|
+
* @param messageId - Message to delete
|
|
150
|
+
* @param accountId - Account ID
|
|
151
|
+
* @param options - Delete options (toTrash, permanent)
|
|
152
|
+
*/
|
|
153
|
+
deleteMessage = async (
|
|
154
|
+
accountConfigId: string,
|
|
155
|
+
messageId: string,
|
|
156
|
+
accountId: string,
|
|
157
|
+
options: DeleteOptions = { toTrash: true },
|
|
158
|
+
): Promise<void> => {
|
|
159
|
+
await this.deleteMessages(accountConfigId, [messageId], accountId, options);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Delete multiple messages using batch operations.
|
|
164
|
+
* By default moves to Trash, unless permanent is specified or already in Trash.
|
|
165
|
+
*
|
|
166
|
+
* @param messageIds - Messages to delete
|
|
167
|
+
* @param accountId - Account ID
|
|
168
|
+
* @param options - Delete options (toTrash, permanent)
|
|
169
|
+
*/
|
|
170
|
+
deleteMessages = async (
|
|
171
|
+
accountConfigId: string,
|
|
172
|
+
messageIds: string[],
|
|
173
|
+
accountId: string,
|
|
174
|
+
options: DeleteOptions = { toTrash: true },
|
|
175
|
+
): Promise<void> => {
|
|
176
|
+
if (messageIds.length === 0) return;
|
|
177
|
+
|
|
178
|
+
// Batch get all messages
|
|
179
|
+
const messages = await this.messageService.get(messageIds);
|
|
180
|
+
if (messages.length === 0) return;
|
|
181
|
+
|
|
182
|
+
// Get unique mailbox IDs and batch fetch mailboxes
|
|
183
|
+
const uniqueMailboxIds = [...new Set(messages.map((m) => m.mailboxId))];
|
|
184
|
+
const mailboxes = await this.mailboxService.get(
|
|
185
|
+
accountId,
|
|
186
|
+
uniqueMailboxIds,
|
|
187
|
+
);
|
|
188
|
+
const mailboxMap = new Map(mailboxes.map((m) => [m.mailboxId, m]));
|
|
189
|
+
|
|
190
|
+
// Find trash mailbox once
|
|
191
|
+
const trashMailbox =
|
|
192
|
+
await this.mailboxSpecialUseService.findTrashMailbox(accountId);
|
|
193
|
+
|
|
194
|
+
// Group messages by operation type
|
|
195
|
+
const moveToTrashMessages: Array<{
|
|
196
|
+
messageId: string;
|
|
197
|
+
message: { mailboxId: string; uid: number };
|
|
198
|
+
sourceMailbox: { mailboxId: string; fullPath: string };
|
|
199
|
+
}> = [];
|
|
200
|
+
const permanentDeleteMessages: Array<{
|
|
201
|
+
messageId: string;
|
|
202
|
+
message: { mailboxId: string; uid: number };
|
|
203
|
+
sourceMailbox: { mailboxId: string; fullPath: string };
|
|
204
|
+
}> = [];
|
|
205
|
+
|
|
206
|
+
for (const message of messages) {
|
|
207
|
+
const sourceMailbox = mailboxMap.get(message.mailboxId);
|
|
208
|
+
if (!sourceMailbox) continue;
|
|
209
|
+
|
|
210
|
+
const isInTrash =
|
|
211
|
+
trashMailbox && message.mailboxId === trashMailbox.mailboxId;
|
|
212
|
+
const shouldMoveToTrash =
|
|
213
|
+
options.toTrash !== false &&
|
|
214
|
+
!options.permanent &&
|
|
215
|
+
!isInTrash &&
|
|
216
|
+
trashMailbox;
|
|
217
|
+
|
|
218
|
+
const entry = {
|
|
219
|
+
messageId: message.messageId,
|
|
220
|
+
message: { mailboxId: message.mailboxId, uid: message.uid },
|
|
221
|
+
sourceMailbox: {
|
|
222
|
+
mailboxId: sourceMailbox.mailboxId,
|
|
223
|
+
fullPath: sourceMailbox.fullPath,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
if (shouldMoveToTrash && trashMailbox) {
|
|
228
|
+
moveToTrashMessages.push(entry);
|
|
229
|
+
} else {
|
|
230
|
+
permanentDeleteMessages.push(entry);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Collect all events for batch SQS send
|
|
235
|
+
const events: MessageDeleteEvent[] = [];
|
|
236
|
+
|
|
237
|
+
// Process move to trash
|
|
238
|
+
if (trashMailbox && moveToTrashMessages.length > 0) {
|
|
239
|
+
for (const { messageId, message, sourceMailbox } of moveToTrashMessages) {
|
|
240
|
+
// Update local state optimistically
|
|
241
|
+
await this.messageService.updateForMove(messageId, {
|
|
242
|
+
mailboxId: trashMailbox.mailboxId,
|
|
243
|
+
status: MessageStatus.moving,
|
|
244
|
+
syncStatus: MessageSyncStatus.pending,
|
|
245
|
+
originalMailboxId: sourceMailbox.mailboxId,
|
|
246
|
+
originalUid: message.uid,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Update ThreadMessage
|
|
250
|
+
await this.updateThreadMessageForMove(
|
|
251
|
+
accountConfigId,
|
|
252
|
+
messageId,
|
|
253
|
+
trashMailbox.mailboxId,
|
|
254
|
+
true,
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
events.push({
|
|
258
|
+
type: "MESSAGE_DELETE",
|
|
259
|
+
eventId: randomUUID(),
|
|
260
|
+
timestamp: Date.now(),
|
|
261
|
+
accountId,
|
|
262
|
+
messageId,
|
|
263
|
+
mailboxId: sourceMailbox.mailboxId,
|
|
264
|
+
mailboxPath: sourceMailbox.fullPath,
|
|
265
|
+
uid: message.uid,
|
|
266
|
+
operation: "move_to_trash",
|
|
267
|
+
destinationMailboxId: trashMailbox.mailboxId,
|
|
268
|
+
destinationMailboxPath: trashMailbox.fullPath,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
this.log.info(
|
|
273
|
+
{
|
|
274
|
+
count: moveToTrashMessages.length,
|
|
275
|
+
trashMailboxId: trashMailbox.mailboxId,
|
|
276
|
+
},
|
|
277
|
+
"Moved messages to trash (local)",
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Process permanent deletes
|
|
282
|
+
for (const {
|
|
283
|
+
messageId,
|
|
284
|
+
message,
|
|
285
|
+
sourceMailbox,
|
|
286
|
+
} of permanentDeleteMessages) {
|
|
287
|
+
// Update local state optimistically
|
|
288
|
+
await this.messageService.update(messageId, {
|
|
289
|
+
status: MessageStatus.deleting,
|
|
290
|
+
syncStatus: MessageSyncStatus.pending,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Delete ThreadMessage rows up-front (one row per mailbox copy).
|
|
294
|
+
// The IMAP worker also deletes them once the IMAP DELETE succeeds —
|
|
295
|
+
// doing it eagerly here closes the visibility window where the inbox
|
|
296
|
+
// list shows a row whose backing Message is being deleted, leading
|
|
297
|
+
// to "Message not found: <id>" on click. See issue #212.
|
|
298
|
+
await this.deleteThreadMessagesForMessage(accountConfigId, messageId);
|
|
299
|
+
|
|
300
|
+
events.push({
|
|
301
|
+
type: "MESSAGE_DELETE",
|
|
302
|
+
eventId: randomUUID(),
|
|
303
|
+
timestamp: Date.now(),
|
|
304
|
+
accountId,
|
|
305
|
+
messageId,
|
|
306
|
+
mailboxId: sourceMailbox.mailboxId,
|
|
307
|
+
mailboxPath: sourceMailbox.fullPath,
|
|
308
|
+
uid: message.uid,
|
|
309
|
+
operation: "permanent_delete",
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (permanentDeleteMessages.length > 0) {
|
|
314
|
+
this.log.info(
|
|
315
|
+
{ count: permanentDeleteMessages.length },
|
|
316
|
+
"Marked messages for permanent deletion (local)",
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Batch send events to SQS
|
|
321
|
+
await this.enqueueEventsBatch(events);
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Move a message to another mailbox.
|
|
326
|
+
*
|
|
327
|
+
* @param messageId - Message to move
|
|
328
|
+
* @param destinationMailboxId - Destination mailbox ID
|
|
329
|
+
* @param accountId - Account ID
|
|
330
|
+
*/
|
|
331
|
+
moveMessage = async (
|
|
332
|
+
accountConfigId: string,
|
|
333
|
+
messageId: string,
|
|
334
|
+
destinationMailboxId: string,
|
|
335
|
+
accountId: string,
|
|
336
|
+
): Promise<void> => {
|
|
337
|
+
const message = await this.messageService.get(messageId);
|
|
338
|
+
const sourceMailbox = await this.mailboxService.get(
|
|
339
|
+
accountId,
|
|
340
|
+
message.mailboxId,
|
|
341
|
+
);
|
|
342
|
+
const destinationMailbox = await this.mailboxService.get(
|
|
343
|
+
accountId,
|
|
344
|
+
destinationMailboxId,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
// Check if moving to/from Trash
|
|
348
|
+
const trashMailbox =
|
|
349
|
+
await this.mailboxSpecialUseService.findTrashMailbox(accountId);
|
|
350
|
+
const isMovingToTrash = Boolean(
|
|
351
|
+
trashMailbox && destinationMailboxId === trashMailbox.mailboxId,
|
|
352
|
+
);
|
|
353
|
+
const isMovingFromTrash = Boolean(
|
|
354
|
+
trashMailbox && message.mailboxId === trashMailbox.mailboxId,
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// Update local state optimistically
|
|
358
|
+
await this.messageService.updateForMove(messageId, {
|
|
359
|
+
mailboxId: destinationMailboxId,
|
|
360
|
+
status: MessageStatus.moving,
|
|
361
|
+
syncStatus: MessageSyncStatus.pending,
|
|
362
|
+
originalMailboxId: sourceMailbox.mailboxId,
|
|
363
|
+
originalUid: message.uid,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// Update ThreadMessage
|
|
367
|
+
await this.updateThreadMessageForMove(
|
|
368
|
+
accountConfigId,
|
|
369
|
+
messageId,
|
|
370
|
+
destinationMailboxId,
|
|
371
|
+
isMovingToTrash,
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
// If moving FROM Trash, clear isDeleted
|
|
375
|
+
if (isMovingFromTrash) {
|
|
376
|
+
await this.updateThreadMessageDeleted(accountConfigId, messageId, false);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
this.log.info(
|
|
380
|
+
{
|
|
381
|
+
messageId,
|
|
382
|
+
from: sourceMailbox.fullPath,
|
|
383
|
+
to: destinationMailbox.fullPath,
|
|
384
|
+
},
|
|
385
|
+
"Moved message (local)",
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
// Enqueue IMAP sync
|
|
389
|
+
const event: MessageMoveEvent = {
|
|
390
|
+
type: "MESSAGE_MOVE",
|
|
391
|
+
eventId: randomUUID(),
|
|
392
|
+
timestamp: Date.now(),
|
|
393
|
+
accountId,
|
|
394
|
+
messageId,
|
|
395
|
+
sourceMailboxId: sourceMailbox.mailboxId,
|
|
396
|
+
sourceMailboxPath: sourceMailbox.fullPath,
|
|
397
|
+
destinationMailboxId,
|
|
398
|
+
destinationMailboxPath: destinationMailbox.fullPath,
|
|
399
|
+
uid: message.uid,
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
await this.enqueueEvent(event);
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Move multiple messages to another mailbox.
|
|
407
|
+
*
|
|
408
|
+
* @param messageIds - Messages to move
|
|
409
|
+
* @param destinationMailboxId - Destination mailbox ID
|
|
410
|
+
* @param accountId - Account ID
|
|
411
|
+
*/
|
|
412
|
+
moveMessages = async (
|
|
413
|
+
accountConfigId: string,
|
|
414
|
+
messageIds: string[],
|
|
415
|
+
destinationMailboxId: string,
|
|
416
|
+
accountId: string,
|
|
417
|
+
): Promise<void> => {
|
|
418
|
+
for (const messageId of messageIds) {
|
|
419
|
+
await this.moveMessage(
|
|
420
|
+
accountConfigId,
|
|
421
|
+
messageId,
|
|
422
|
+
destinationMailboxId,
|
|
423
|
+
accountId,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Copy a message to another mailbox.
|
|
430
|
+
* Creates a new message record locally and enqueues IMAP COPY.
|
|
431
|
+
*
|
|
432
|
+
* @param messageId - Message to copy
|
|
433
|
+
* @param destinationMailboxId - Destination mailbox ID
|
|
434
|
+
* @param accountId - Account ID
|
|
435
|
+
* @returns The new message ID for the copy
|
|
436
|
+
*/
|
|
437
|
+
copyMessage = async (
|
|
438
|
+
accountConfigId: string,
|
|
439
|
+
messageId: string,
|
|
440
|
+
destinationMailboxId: string,
|
|
441
|
+
accountId: string,
|
|
442
|
+
): Promise<string> => {
|
|
443
|
+
const sourceMessage = await this.messageService.get(messageId);
|
|
444
|
+
const sourceMailbox = await this.mailboxService.get(
|
|
445
|
+
accountId,
|
|
446
|
+
sourceMessage.mailboxId,
|
|
447
|
+
);
|
|
448
|
+
const destinationMailbox = await this.mailboxService.get(
|
|
449
|
+
accountId,
|
|
450
|
+
destinationMailboxId,
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
// Generate new ID for the copy
|
|
454
|
+
const newMessageId = base36uuid();
|
|
455
|
+
|
|
456
|
+
// Create local copy with moving status (uid=0 until IMAP confirms)
|
|
457
|
+
await this.messageService.create({
|
|
458
|
+
messageId: newMessageId,
|
|
459
|
+
mailboxId: destinationMailboxId,
|
|
460
|
+
uid: 0, // Will be updated by worker after IMAP COPY
|
|
461
|
+
sequenceNumber: 0, // Will be updated by worker
|
|
462
|
+
rfc822Size: sourceMessage.rfc822Size,
|
|
463
|
+
internalDate: sourceMessage.internalDate,
|
|
464
|
+
messageIdHeader: sourceMessage.messageIdHeader,
|
|
465
|
+
envelopeId: sourceMessage.envelopeId, // Share envelope with source
|
|
466
|
+
rootBodyPartId: sourceMessage.rootBodyPartId, // Share body parts with source
|
|
467
|
+
status: MessageStatus.moving,
|
|
468
|
+
syncStatus: MessageSyncStatus.pending,
|
|
469
|
+
bodyStorageKey: sourceMessage.bodyStorageKey,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// Copy ThreadMessage entry
|
|
473
|
+
const sourceThreadMessage = await this.threadMessageService.getByMessageId(
|
|
474
|
+
accountConfigId,
|
|
475
|
+
messageId,
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
await this.threadMessageService.create({
|
|
479
|
+
accountConfigId: sourceThreadMessage.accountConfigId,
|
|
480
|
+
threadId: sourceThreadMessage.threadId,
|
|
481
|
+
messageId: newMessageId,
|
|
482
|
+
mailboxId: destinationMailboxId,
|
|
483
|
+
uid: 0, // Will be updated by worker
|
|
484
|
+
messageIdHeader: sourceThreadMessage.messageIdHeader,
|
|
485
|
+
inReplyTo: sourceThreadMessage.inReplyTo,
|
|
486
|
+
referenceOrder: sourceThreadMessage.referenceOrder,
|
|
487
|
+
fromEmail: sourceThreadMessage.fromEmail,
|
|
488
|
+
fromName: sourceThreadMessage.fromName,
|
|
489
|
+
subject: sourceThreadMessage.subject,
|
|
490
|
+
internalDate: sourceThreadMessage.internalDate,
|
|
491
|
+
sentDate: sourceThreadMessage.sentDate,
|
|
492
|
+
isRead: sourceThreadMessage.isRead,
|
|
493
|
+
hasAttachment: sourceThreadMessage.hasAttachment,
|
|
494
|
+
star: sourceThreadMessage.star,
|
|
495
|
+
hasStars: sourceThreadMessage.hasStars,
|
|
496
|
+
isDeleted: false,
|
|
497
|
+
snippet: sourceThreadMessage.snippet,
|
|
498
|
+
category: sourceThreadMessage.category,
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
this.log.info(
|
|
502
|
+
{
|
|
503
|
+
sourceMessageId: messageId,
|
|
504
|
+
newMessageId,
|
|
505
|
+
from: sourceMailbox.fullPath,
|
|
506
|
+
to: destinationMailbox.fullPath,
|
|
507
|
+
},
|
|
508
|
+
"Created message copy (local)",
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
// Enqueue IMAP sync
|
|
512
|
+
const event: MessageCopyEvent = {
|
|
513
|
+
type: "MESSAGE_COPY",
|
|
514
|
+
eventId: randomUUID(),
|
|
515
|
+
timestamp: Date.now(),
|
|
516
|
+
accountId,
|
|
517
|
+
sourceMessageId: messageId,
|
|
518
|
+
newMessageId,
|
|
519
|
+
sourceMailboxId: sourceMailbox.mailboxId,
|
|
520
|
+
sourceMailboxPath: sourceMailbox.fullPath,
|
|
521
|
+
destinationMailboxId,
|
|
522
|
+
destinationMailboxPath: destinationMailbox.fullPath,
|
|
523
|
+
uid: sourceMessage.uid,
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
await this.enqueueEvent(event);
|
|
527
|
+
|
|
528
|
+
return newMessageId;
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Copy multiple messages to another mailbox.
|
|
533
|
+
*
|
|
534
|
+
* @param messageIds - Messages to copy
|
|
535
|
+
* @param destinationMailboxId - Destination mailbox ID
|
|
536
|
+
* @param accountId - Account ID
|
|
537
|
+
* @returns Array of new message IDs for the copies
|
|
538
|
+
*/
|
|
539
|
+
copyMessages = async (
|
|
540
|
+
accountConfigId: string,
|
|
541
|
+
messageIds: string[],
|
|
542
|
+
destinationMailboxId: string,
|
|
543
|
+
accountId: string,
|
|
544
|
+
): Promise<string[]> => {
|
|
545
|
+
const newMessageIds: string[] = [];
|
|
546
|
+
for (const messageId of messageIds) {
|
|
547
|
+
const newId = await this.copyMessage(
|
|
548
|
+
accountConfigId,
|
|
549
|
+
messageId,
|
|
550
|
+
destinationMailboxId,
|
|
551
|
+
accountId,
|
|
552
|
+
);
|
|
553
|
+
newMessageIds.push(newId);
|
|
554
|
+
}
|
|
555
|
+
return newMessageIds;
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Restore a message from Trash to its original mailbox.
|
|
560
|
+
*
|
|
561
|
+
* @param messageId - Message to restore
|
|
562
|
+
* @param accountId - Account ID
|
|
563
|
+
*/
|
|
564
|
+
restoreMessage = async (
|
|
565
|
+
accountConfigId: string,
|
|
566
|
+
messageId: string,
|
|
567
|
+
accountId: string,
|
|
568
|
+
): Promise<void> => {
|
|
569
|
+
const message = await this.messageService.get(messageId);
|
|
570
|
+
|
|
571
|
+
if (!message.originalMailboxId) {
|
|
572
|
+
throw new Error("Message has no original mailbox to restore to");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
await this.moveMessage(
|
|
576
|
+
accountConfigId,
|
|
577
|
+
messageId,
|
|
578
|
+
message.originalMailboxId,
|
|
579
|
+
accountId,
|
|
580
|
+
);
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Empty the Trash mailbox (permanent delete all).
|
|
585
|
+
*
|
|
586
|
+
* @param accountId - Account ID
|
|
587
|
+
*/
|
|
588
|
+
emptyTrash = async (
|
|
589
|
+
accountConfigId: string,
|
|
590
|
+
accountId: string,
|
|
591
|
+
): Promise<void> => {
|
|
592
|
+
const trashMailbox =
|
|
593
|
+
await this.mailboxSpecialUseService.findTrashMailbox(accountId);
|
|
594
|
+
|
|
595
|
+
if (!trashMailbox) {
|
|
596
|
+
throw new Error("No Trash mailbox found for account");
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Get all messages in Trash
|
|
600
|
+
const messages = await this.messageService.listAllByMailbox(
|
|
601
|
+
trashMailbox.mailboxId,
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
// Mark all as deleting locally
|
|
605
|
+
for (const message of messages) {
|
|
606
|
+
await this.messageService.update(message.messageId, {
|
|
607
|
+
status: MessageStatus.deleting,
|
|
608
|
+
syncStatus: MessageSyncStatus.pending,
|
|
609
|
+
});
|
|
610
|
+
await this.updateThreadMessageDeleted(
|
|
611
|
+
accountConfigId,
|
|
612
|
+
message.messageId,
|
|
613
|
+
true,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
this.log.info(
|
|
618
|
+
{
|
|
619
|
+
accountId,
|
|
620
|
+
trashMailboxId: trashMailbox.mailboxId,
|
|
621
|
+
count: messages.length,
|
|
622
|
+
},
|
|
623
|
+
"Marked all trash messages for deletion (local)",
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
// Enqueue single event for worker to handle batch
|
|
627
|
+
const event: EmptyTrashEvent = {
|
|
628
|
+
type: "EMPTY_TRASH",
|
|
629
|
+
eventId: randomUUID(),
|
|
630
|
+
timestamp: Date.now(),
|
|
631
|
+
accountId,
|
|
632
|
+
trashMailboxId: trashMailbox.mailboxId,
|
|
633
|
+
trashMailboxPath: trashMailbox.fullPath,
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
await this.enqueueEvent(event);
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Update ThreadMessage for move operations.
|
|
641
|
+
* Updates mailboxId and optionally isDeleted.
|
|
642
|
+
*/
|
|
643
|
+
private updateThreadMessageForMove = async (
|
|
644
|
+
accountConfigId: string,
|
|
645
|
+
messageId: string,
|
|
646
|
+
newMailboxId: string,
|
|
647
|
+
isDeleted: boolean,
|
|
648
|
+
): Promise<void> => {
|
|
649
|
+
const threadMessage = await this.threadMessageService.getByMessageId(
|
|
650
|
+
accountConfigId,
|
|
651
|
+
messageId,
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
this.log.info(
|
|
655
|
+
{
|
|
656
|
+
messageId,
|
|
657
|
+
accountConfigId: threadMessage.accountConfigId,
|
|
658
|
+
threadMessageId: threadMessage.threadMessageId,
|
|
659
|
+
sentDate: threadMessage.sentDate,
|
|
660
|
+
newMailboxId,
|
|
661
|
+
isDeleted,
|
|
662
|
+
},
|
|
663
|
+
"Updating ThreadMessage for move",
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
await this.threadMessageService.update(
|
|
667
|
+
threadMessage.accountConfigId,
|
|
668
|
+
threadMessage.threadMessageId,
|
|
669
|
+
{ mailboxId: newMailboxId, isDeleted },
|
|
670
|
+
{
|
|
671
|
+
// Composites contain CURRENT values for condition checking
|
|
672
|
+
// ElectroDB uses these to verify the item state before updating
|
|
673
|
+
composites: {
|
|
674
|
+
mailboxId: threadMessage.mailboxId,
|
|
675
|
+
sentDate: threadMessage.sentDate,
|
|
676
|
+
isRead: threadMessage.isRead,
|
|
677
|
+
isDeleted: threadMessage.isDeleted,
|
|
678
|
+
hasStars: threadMessage.hasStars,
|
|
679
|
+
hasAttachment: threadMessage.hasAttachment,
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
this.log.info(
|
|
685
|
+
{
|
|
686
|
+
messageId,
|
|
687
|
+
threadMessageId: threadMessage.threadMessageId,
|
|
688
|
+
newMailboxId,
|
|
689
|
+
isDeleted,
|
|
690
|
+
},
|
|
691
|
+
"Updated ThreadMessage for move",
|
|
692
|
+
);
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Delete every ThreadMessage row that points at this messageId.
|
|
697
|
+
*
|
|
698
|
+
* A single Message can have multiple ThreadMessage rows — one per mailbox
|
|
699
|
+
* the message exists in (e.g. INBOX + a label/folder copy). Deleting them
|
|
700
|
+
* up-front in the permanent-delete optimistic step prevents stale rows from
|
|
701
|
+
* leaking into mailbox listings while IMAP catches up. See issue #212.
|
|
702
|
+
*/
|
|
703
|
+
private deleteThreadMessagesForMessage = async (
|
|
704
|
+
accountConfigId: string,
|
|
705
|
+
messageId: string,
|
|
706
|
+
): Promise<void> => {
|
|
707
|
+
const rows = await this.threadMessageService.findAllByMessageId(
|
|
708
|
+
accountConfigId,
|
|
709
|
+
messageId,
|
|
710
|
+
);
|
|
711
|
+
for (const row of rows) {
|
|
712
|
+
await this.threadMessageService.delete(
|
|
713
|
+
row.accountConfigId,
|
|
714
|
+
row.threadMessageId,
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
this.log.info(
|
|
718
|
+
{ messageId, deletedRows: rows.length },
|
|
719
|
+
"Deleted ThreadMessage rows for permanent-delete",
|
|
720
|
+
);
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Update ThreadMessage.isDeleted flag only.
|
|
725
|
+
*/
|
|
726
|
+
private updateThreadMessageDeleted = async (
|
|
727
|
+
accountConfigId: string,
|
|
728
|
+
messageId: string,
|
|
729
|
+
isDeleted: boolean,
|
|
730
|
+
): Promise<void> => {
|
|
731
|
+
const threadMessage = await this.threadMessageService.getByMessageId(
|
|
732
|
+
accountConfigId,
|
|
733
|
+
messageId,
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
await this.threadMessageService.update(
|
|
737
|
+
threadMessage.accountConfigId,
|
|
738
|
+
threadMessage.threadMessageId,
|
|
739
|
+
{ isDeleted },
|
|
740
|
+
{
|
|
741
|
+
// Composites contain CURRENT values for condition checking
|
|
742
|
+
composites: {
|
|
743
|
+
sentDate: threadMessage.sentDate,
|
|
744
|
+
mailboxId: threadMessage.mailboxId,
|
|
745
|
+
isRead: threadMessage.isRead,
|
|
746
|
+
isDeleted: threadMessage.isDeleted,
|
|
747
|
+
hasStars: threadMessage.hasStars,
|
|
748
|
+
hasAttachment: threadMessage.hasAttachment,
|
|
749
|
+
},
|
|
750
|
+
},
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
this.log.info(
|
|
754
|
+
{
|
|
755
|
+
messageId,
|
|
756
|
+
threadMessageId: threadMessage.threadMessageId,
|
|
757
|
+
isDeleted,
|
|
758
|
+
},
|
|
759
|
+
"Updated ThreadMessage.isDeleted",
|
|
760
|
+
);
|
|
761
|
+
};
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Enqueue a message move/delete event to SQS.
|
|
765
|
+
*
|
|
766
|
+
* FIFO queues require MessageGroupId; standard queues reject it. We detect
|
|
767
|
+
* FIFO queues by the `.fifo` suffix and group by accountId so events for
|
|
768
|
+
* different accounts can be processed in parallel.
|
|
769
|
+
*/
|
|
770
|
+
private enqueueEvent = async (
|
|
771
|
+
event: MessageMoveQueueEvent,
|
|
772
|
+
): Promise<void> => {
|
|
773
|
+
const useFifo = this.queueUrl.endsWith(".fifo");
|
|
774
|
+
await this.sqs.send(
|
|
775
|
+
new SendMessageCommand({
|
|
776
|
+
QueueUrl: this.queueUrl,
|
|
777
|
+
MessageBody: JSON.stringify(event),
|
|
778
|
+
...(useFifo && {
|
|
779
|
+
MessageGroupId: event.accountId,
|
|
780
|
+
MessageDeduplicationId: event.eventId,
|
|
781
|
+
}),
|
|
782
|
+
}),
|
|
783
|
+
);
|
|
784
|
+
|
|
785
|
+
this.log.info(
|
|
786
|
+
{ eventId: event.eventId, type: event.type },
|
|
787
|
+
"Enqueued message event",
|
|
788
|
+
);
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Enqueue multiple events to SQS using batch send.
|
|
793
|
+
* SQS batch limit is 10 messages, so we chunk if needed.
|
|
794
|
+
*
|
|
795
|
+
* FIFO queues require MessageGroupId on each batch entry; standard queues
|
|
796
|
+
* reject it. We detect FIFO queues by the `.fifo` suffix.
|
|
797
|
+
*/
|
|
798
|
+
private enqueueEventsBatch = async (
|
|
799
|
+
events: MessageMoveQueueEvent[],
|
|
800
|
+
): Promise<void> => {
|
|
801
|
+
if (events.length === 0) return;
|
|
802
|
+
|
|
803
|
+
const SQS_BATCH_SIZE = 10;
|
|
804
|
+
const useFifo = this.queueUrl.endsWith(".fifo");
|
|
805
|
+
|
|
806
|
+
for (let i = 0; i < events.length; i += SQS_BATCH_SIZE) {
|
|
807
|
+
const batch = events.slice(i, i + SQS_BATCH_SIZE);
|
|
808
|
+
await this.sqs.send(
|
|
809
|
+
new SendMessageBatchCommand({
|
|
810
|
+
QueueUrl: this.queueUrl,
|
|
811
|
+
Entries: batch.map((event, idx) => ({
|
|
812
|
+
Id: `${i + idx}`,
|
|
813
|
+
MessageBody: JSON.stringify(event),
|
|
814
|
+
...(useFifo && {
|
|
815
|
+
MessageGroupId: event.accountId,
|
|
816
|
+
MessageDeduplicationId: event.eventId,
|
|
817
|
+
}),
|
|
818
|
+
})),
|
|
819
|
+
}),
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
this.log.info({ count: events.length }, "Enqueued message events batch");
|
|
824
|
+
};
|
|
825
|
+
}
|