@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.
Files changed (124) hide show
  1. package/README.md +147 -0
  2. package/package.json +65 -0
  3. package/src/account-credentials.ts +128 -0
  4. package/src/adversarial-mime.e2e.test.ts +311 -0
  5. package/src/attribute-mapper.test.ts +78 -0
  6. package/src/attribute-mapper.ts +147 -0
  7. package/src/body-part-mapper.property.test.ts +354 -0
  8. package/src/body-part-mapper.ts +360 -0
  9. package/src/body-sync-queue.test.ts +124 -0
  10. package/src/body-sync-queue.ts +131 -0
  11. package/src/body-sync-terminal.test.ts +265 -0
  12. package/src/body-sync-terminal.ts +172 -0
  13. package/src/body-sync.ts +1202 -0
  14. package/src/connection-factory.ts +102 -0
  15. package/src/connection-test.ts +208 -0
  16. package/src/credentials.test.ts +181 -0
  17. package/src/filters/match.test.ts +181 -0
  18. package/src/filters/match.ts +143 -0
  19. package/src/filters/pipeline.ts +243 -0
  20. package/src/flag-push-terminal.test.ts +156 -0
  21. package/src/flag-push-terminal.ts +106 -0
  22. package/src/flag-push.test.ts +408 -0
  23. package/src/flag-push.ts +198 -0
  24. package/src/flag-queue.ts +500 -0
  25. package/src/heuristics/classifyByHeaders.test.ts +578 -0
  26. package/src/heuristics/classifyByHeaders.ts +369 -0
  27. package/src/heuristics/classifyPlacement.ts +96 -0
  28. package/src/heuristics/socialDomains.ts +23 -0
  29. package/src/heuristics/transactionalDomains.ts +26 -0
  30. package/src/imapflow-connect-retry.test.ts +108 -0
  31. package/src/imapflow-connection.e2e.test.ts +355 -0
  32. package/src/imapflow-connection.integ.test.ts +327 -0
  33. package/src/imapflow-connection.test.ts +161 -0
  34. package/src/imapflow-connection.ts +1374 -0
  35. package/src/index.ts +214 -0
  36. package/src/mailbox-cursor-rebuild.test.ts +171 -0
  37. package/src/mailbox-cursor-rebuild.ts +146 -0
  38. package/src/mailbox-cursor.test.ts +249 -0
  39. package/src/mailbox-cursor.ts +177 -0
  40. package/src/mailbox-management.test.ts +103 -0
  41. package/src/mailbox-management.ts +235 -0
  42. package/src/mailbox-queue.ts +258 -0
  43. package/src/mailbox-sync.test.ts +153 -0
  44. package/src/mailbox-sync.ts +590 -0
  45. package/src/message-move.ts +825 -0
  46. package/src/message-parser.ts +26 -0
  47. package/src/message-sync.ts +1021 -0
  48. package/src/mime-walker.test.ts +311 -0
  49. package/src/mime-walker.ts +305 -0
  50. package/src/outbox-queue.ts +294 -0
  51. package/src/pass-through-unit-of-work.ts +17 -0
  52. package/src/placement-move-terminal.test.ts +179 -0
  53. package/src/placement-move-terminal.ts +104 -0
  54. package/src/placement-move.ts +224 -0
  55. package/src/snippet.test.ts +213 -0
  56. package/src/snippet.ts +152 -0
  57. package/src/stale-message-reconcile.test.ts +82 -0
  58. package/src/stale-message-reconcile.ts +55 -0
  59. package/src/test-helpers/isolated-mailbox.ts +44 -0
  60. package/src/test-helpers/mailfuzz-connection.ts +27 -0
  61. package/src/test-helpers/mime-tree-generator.ts +461 -0
  62. package/src/text/normalizer.test.ts +223 -0
  63. package/src/text/normalizer.ts +140 -0
  64. package/src/types/mailparser-augment.d.ts +15 -0
  65. package/src/types/natural-porter-stemmer.d.ts +50 -0
  66. package/src/types/stopword.d.ts +69 -0
  67. package/src/types.ts +349 -0
  68. package/test/fixtures/mime/01-flat-text-plain.bodyparts.json +8 -0
  69. package/test/fixtures/mime/01-flat-text-plain.eml +10 -0
  70. package/test/fixtures/mime/01-flat-text-plain.expected.json +10 -0
  71. package/test/fixtures/mime/02-flat-text-html.bodyparts.json +8 -0
  72. package/test/fixtures/mime/02-flat-text-html.eml +10 -0
  73. package/test/fixtures/mime/02-flat-text-html.expected.json +10 -0
  74. package/test/fixtures/mime/03-alternative-text-html.bodyparts.json +20 -0
  75. package/test/fixtures/mime/03-alternative-text-html.eml +19 -0
  76. package/test/fixtures/mime/03-alternative-text-html.expected.json +16 -0
  77. package/test/fixtures/mime/04-mixed-html-pdf-attachment.bodyparts.json +22 -0
  78. package/test/fixtures/mime/04-mixed-html-pdf-attachment.eml +20 -0
  79. package/test/fixtures/mime/04-mixed-html-pdf-attachment.expected.json +16 -0
  80. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.bodyparts.json +22 -0
  81. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.eml +21 -0
  82. package/test/fixtures/mime/05-mixed-html-octet-stream-pdf.expected.json +16 -0
  83. package/test/fixtures/mime/06-related-html-cid-image.bodyparts.json +23 -0
  84. package/test/fixtures/mime/06-related-html-cid-image.eml +21 -0
  85. package/test/fixtures/mime/06-related-html-cid-image.expected.json +16 -0
  86. package/test/fixtures/mime/07-mixed-alternative-related-attachment.bodyparts.json +55 -0
  87. package/test/fixtures/mime/07-mixed-alternative-related-attachment.eml +47 -0
  88. package/test/fixtures/mime/07-mixed-alternative-related-attachment.expected.json +34 -0
  89. package/test/fixtures/mime/08-two-pdfs-same-filename.bodyparts.json +30 -0
  90. package/test/fixtures/mime/08-two-pdfs-same-filename.eml +26 -0
  91. package/test/fixtures/mime/08-two-pdfs-same-filename.expected.json +22 -0
  92. package/test/fixtures/mime/09-attachment-no-filename.bodyparts.json +21 -0
  93. package/test/fixtures/mime/09-attachment-no-filename.eml +20 -0
  94. package/test/fixtures/mime/09-attachment-no-filename.expected.json +16 -0
  95. package/test/fixtures/mime/10-calendar-invite.bodyparts.json +22 -0
  96. package/test/fixtures/mime/10-calendar-invite.eml +33 -0
  97. package/test/fixtures/mime/10-calendar-invite.expected.json +16 -0
  98. package/test/fixtures/mime/11-quoted-printable-text.bodyparts.json +8 -0
  99. package/test/fixtures/mime/11-quoted-printable-text.eml +10 -0
  100. package/test/fixtures/mime/11-quoted-printable-text.expected.json +10 -0
  101. package/test/fixtures/mime/12-base64-binary.bodyparts.json +22 -0
  102. package/test/fixtures/mime/12-base64-binary.eml +20 -0
  103. package/test/fixtures/mime/12-base64-binary.expected.json +16 -0
  104. package/test/fixtures/mime/13-message-rfc822-forward.bodyparts.json +22 -0
  105. package/test/fixtures/mime/13-message-rfc822-forward.eml +28 -0
  106. package/test/fixtures/mime/13-message-rfc822-forward.expected.json +16 -0
  107. package/test/fixtures/mime/14-deeply-nested-5-levels.bodyparts.json +38 -0
  108. package/test/fixtures/mime/14-deeply-nested-5-levels.eml +34 -0
  109. package/test/fixtures/mime/14-deeply-nested-5-levels.expected.json +10 -0
  110. package/test/fixtures/mime/15-empty-text-part.bodyparts.json +21 -0
  111. package/test/fixtures/mime/15-empty-text-part.eml +18 -0
  112. package/test/fixtures/mime/15-empty-text-part.expected.json +16 -0
  113. package/test/fixtures/mime/16-empty-non-text-part.bodyparts.json +30 -0
  114. package/test/fixtures/mime/16-empty-non-text-part.eml +24 -0
  115. package/test/fixtures/mime/16-empty-non-text-part.expected.json +22 -0
  116. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +21 -0
  117. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +31 -0
  118. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +33 -0
  119. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +42 -0
  120. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +25 -0
  121. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +15 -0
  122. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +17 -0
  123. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +18 -0
  124. 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
+ }