@remit/mailbox-service 0.0.110 → 0.0.112
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/package.json +21 -6
- package/src/body-part-mapper.ts +51 -1
- package/src/body-sync-queue.test.ts +39 -99
- package/src/body-sync-terminal.test.ts +115 -277
- package/src/body-sync-terminal.ts +10 -12
- package/src/body-sync.test.ts +925 -0
- package/src/body-sync.ts +226 -200
- package/src/classification-backfill.test.ts +197 -527
- package/src/classify-message.test.ts +11 -74
- package/src/external-move.test.ts +102 -209
- package/src/external-move.ts +13 -0
- package/src/filter-operations.test.ts +319 -0
- package/src/filter-operations.ts +377 -0
- package/src/filter-update.test.ts +313 -0
- package/src/filters/filter-config.test.ts +55 -197
- package/src/filters/filter-config.ts +4 -39
- package/src/filters/match.test.ts +1 -38
- package/src/filters/match.ts +3 -52
- package/src/filters/pipeline.test.ts +158 -289
- package/src/filters/pipeline.ts +56 -170
- package/src/flag-push-terminal.ts +9 -11
- package/src/flag-push.test.ts +76 -372
- package/src/flag-queue.test.ts +197 -0
- package/src/folder-operations.test.ts +273 -0
- package/src/folder-operations.ts +217 -0
- package/src/forwarded-message.test.ts +122 -0
- package/src/imapflow-connect-retry.test.ts +76 -113
- package/src/imapflow-connection.test.ts +34 -0
- package/src/imapflow-connection.ts +19 -15
- package/src/index.ts +59 -12
- package/src/list-id-backfill.test.ts +133 -499
- package/src/mailbox-cursor.test.ts +81 -189
- package/src/mailbox-delete-intent.test.ts +78 -205
- package/src/mailbox-intent.ts +5 -3
- package/src/mailbox-management.test.ts +107 -220
- package/src/mailbox-management.ts +1 -2
- package/src/mailbox-presence.test.ts +36 -34
- package/src/mailbox-queue.ts +2 -20
- package/src/mailbox-rename-intent.test.ts +156 -325
- package/src/mailbox-rename-subtree.test.ts +205 -582
- package/src/mailbox-sync.test.ts +182 -744
- package/src/message-move.test.ts +726 -0
- package/src/message-move.ts +227 -57
- package/src/message-read.ts +87 -0
- package/src/message-save-retry.test.ts +90 -0
- package/src/message-sync-flags.test.ts +10 -1
- package/src/message-sync.test.ts +762 -0
- package/src/message-sync.ts +409 -239
- package/src/mime-walker.ts +16 -15
- package/src/organize-job.ts +93 -0
- package/src/outbox-attachment.test.ts +97 -209
- package/src/outbox-failed-recovery.test.ts +198 -269
- package/src/outbox-queue.test.ts +24 -0
- package/src/outbox-queue.ts +25 -8
- package/src/ownership.ts +140 -0
- package/src/placement-move-terminal.ts +9 -12
- package/src/placement-move.ts +33 -6
- package/src/placement-settled.ts +28 -4
- package/src/placement.test.ts +404 -0
- package/src/quarantine.test.ts +90 -148
- package/src/quarantine.ts +3 -2
- package/src/refusal.ts +29 -0
- package/src/restore-source-placement.ts +0 -4
- package/src/spam-report.test.ts +238 -710
- package/src/sync-warning.test.ts +213 -0
- package/src/sync-warning.ts +56 -0
- package/src/test-helpers/fake-imap.ts +690 -0
- package/src/test-helpers/test-queue.ts +56 -0
- package/src/test-helpers/test-store.ts +299 -0
- package/src/thread-root.test.ts +29 -0
- package/src/thread-root.ts +51 -0
- package/src/types/mailparser-augment.d.ts +8 -0
- package/src/types.ts +1 -1
- package/src/adversarial-mime.e2e.test.ts +0 -307
- package/src/body-sync-auto-read-writeonce.test.ts +0 -219
- package/src/body-sync-calendar-suggestion.test.ts +0 -433
- package/src/body-sync-category-denormalize.test.ts +0 -352
- package/src/body-sync-category-override.test.ts +0 -501
- package/src/body-sync-category-writeonce.test.ts +0 -234
- package/src/body-sync-filter.test.ts +0 -237
- package/src/body-sync-placement-flags.test.ts +0 -347
- package/src/body-sync-placement-writeonce.test.ts +0 -337
- package/src/body-sync-quarantine.test.ts +0 -276
- package/src/body-sync-resync.test.ts +0 -605
- package/src/body-sync-unclassifiable-once.test.ts +0 -260
- package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
- package/src/filters/anchor-drift.ts +0 -55
- package/src/filters/message-embedder.test.ts +0 -39
- package/src/filters/message-embedder.ts +0 -33
- package/src/flag-push-terminal.test.ts +0 -209
- package/src/flag-queue-stars.test.ts +0 -171
- package/src/imapflow-connection.e2e.test.ts +0 -335
- package/src/imapflow-connection.integ.test.ts +0 -316
- package/src/message-delete-junk-reconcile.test.ts +0 -117
- package/src/message-delete-no-trash.test.ts +0 -245
- package/src/message-delete-unsettled-placement.test.ts +0 -396
- package/src/message-move-copy.test.ts +0 -562
- package/src/message-move-empty-trash.test.ts +0 -326
- package/src/message-move-same-mailbox.test.ts +0 -139
- package/src/message-move-unsettled-placement.test.ts +0 -378
- package/src/message-sync-address-spoof.test.ts +0 -293
- package/src/message-sync-category.test.ts +0 -142
- package/src/message-sync-changedsince.test.ts +0 -840
- package/src/message-sync-junk-harvest.test.ts +0 -244
- package/src/message-sync-quarantine.test.ts +0 -416
- package/src/message-sync-repoint.test.ts +0 -419
- package/src/outbox-queue-settled-row.test.ts +0 -140
- package/src/pass-through-unit-of-work.ts +0 -17
- package/src/placement-move-terminal.test.ts +0 -300
- package/src/placement-move-unsettled.test.ts +0 -275
- package/src/placement-transition-race.test.ts +0 -482
- package/src/stale-message-reconcile.test.ts +0 -82
- package/src/stale-message-reconcile.ts +0 -55
- package/src/test-helpers/isolated-mailbox.ts +0 -44
- package/src/test-helpers/mailfuzz-connection.ts +0 -27
- package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
- package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
- package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
- package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
- package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
- package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
- package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
- package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
package/src/message-move.ts
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
placementBindingOf,
|
|
40
40
|
waitForPlacementToSettle,
|
|
41
41
|
} from "./placement-settled.js";
|
|
42
|
+
import { restoreSourcePlacement } from "./restore-source-placement.js";
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
45
|
* What a delete batch did not claim. A row whose placement changed between the
|
|
@@ -109,6 +110,40 @@ interface MessageCopyEvent {
|
|
|
109
110
|
uid: number;
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
/** An event, and the undo of the local write it was enqueued for. */
|
|
114
|
+
interface UndoableEvent<T> {
|
|
115
|
+
event: T;
|
|
116
|
+
handBack: () => Promise<void>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type BatchEnqueueOutcome =
|
|
120
|
+
| { outcome: "sent" }
|
|
121
|
+
| {
|
|
122
|
+
outcome: "unsent";
|
|
123
|
+
unsent: UndoableEvent<MessageDeleteEvent>[];
|
|
124
|
+
fault: unknown;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Undo every write whose event never left, then fail with the queue's error. */
|
|
128
|
+
const handBackThenThrow = async (
|
|
129
|
+
fault: unknown,
|
|
130
|
+
handBacks: ReadonlyArray<() => Promise<void>>,
|
|
131
|
+
): Promise<never> => {
|
|
132
|
+
const undoFaults: unknown[] = [];
|
|
133
|
+
for (const handBack of handBacks) {
|
|
134
|
+
await handBack().catch((undoFault: unknown) => {
|
|
135
|
+
undoFaults.push(undoFault);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (undoFaults.length > 0) {
|
|
139
|
+
throw new AggregateError(
|
|
140
|
+
[fault, ...undoFaults],
|
|
141
|
+
"The queue refused the event, and handing the local write back failed too",
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
throw fault;
|
|
145
|
+
};
|
|
146
|
+
|
|
112
147
|
type MessageMoveQueueEvent =
|
|
113
148
|
| MessageDeleteEvent
|
|
114
149
|
| MessageMoveEvent
|
|
@@ -443,7 +478,7 @@ export class MessageMoveService {
|
|
|
443
478
|
}
|
|
444
479
|
|
|
445
480
|
// Collect all events for batch SQS send
|
|
446
|
-
const events: MessageDeleteEvent[] = [];
|
|
481
|
+
const events: UndoableEvent<MessageDeleteEvent>[] = [];
|
|
447
482
|
|
|
448
483
|
// Process move to trash
|
|
449
484
|
if (trashMailbox && moveToTrashMessages.length > 0) {
|
|
@@ -504,18 +539,27 @@ export class MessageMoveService {
|
|
|
504
539
|
);
|
|
505
540
|
|
|
506
541
|
events.push({
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
542
|
+
event: {
|
|
543
|
+
type: "MESSAGE_DELETE",
|
|
544
|
+
schemaVersion: MUTATION_EVENT_SCHEMA_VERSION,
|
|
545
|
+
eventId: randomUUID(),
|
|
546
|
+
timestamp: Date.now(),
|
|
547
|
+
accountId,
|
|
548
|
+
messageId,
|
|
549
|
+
mailboxId: sourceMailbox.mailboxId,
|
|
550
|
+
mailboxPath: sourceMailbox.fullPath,
|
|
551
|
+
uid: message.uid,
|
|
552
|
+
operation: "move_to_trash",
|
|
553
|
+
destinationMailboxId: trashMailbox.mailboxId,
|
|
554
|
+
destinationMailboxPath: trashMailbox.fullPath,
|
|
555
|
+
},
|
|
556
|
+
handBack: () =>
|
|
557
|
+
this.handBackUnsentMove(
|
|
558
|
+
accountConfigId,
|
|
559
|
+
messageId,
|
|
560
|
+
sourceMailbox.mailboxId,
|
|
561
|
+
message.uid,
|
|
562
|
+
),
|
|
519
563
|
});
|
|
520
564
|
}
|
|
521
565
|
|
|
@@ -573,16 +617,32 @@ export class MessageMoveService {
|
|
|
573
617
|
await this.deleteThreadMessagesForMessage(accountConfigId, messageId);
|
|
574
618
|
|
|
575
619
|
events.push({
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
620
|
+
event: {
|
|
621
|
+
type: "MESSAGE_DELETE",
|
|
622
|
+
schemaVersion: MUTATION_EVENT_SCHEMA_VERSION,
|
|
623
|
+
eventId: randomUUID(),
|
|
624
|
+
timestamp: Date.now(),
|
|
625
|
+
accountId,
|
|
626
|
+
messageId,
|
|
627
|
+
mailboxId: sourceMailbox.mailboxId,
|
|
628
|
+
mailboxPath: sourceMailbox.fullPath,
|
|
629
|
+
uid: message.uid,
|
|
630
|
+
operation: "permanent_delete",
|
|
631
|
+
},
|
|
632
|
+
// The listing rows went above, so the local removal finishes, as the
|
|
633
|
+
// worker's `handBackDelete` does; the folder's next sync re-projects it.
|
|
634
|
+
handBack: async () => {
|
|
635
|
+
const claimed = await this.messageService.transitionPlacement(
|
|
636
|
+
messageId,
|
|
637
|
+
{
|
|
638
|
+
status: MessageStatus.deleting,
|
|
639
|
+
mailboxId: message.mailboxId,
|
|
640
|
+
uid: message.uid,
|
|
641
|
+
},
|
|
642
|
+
{ status: MessageStatus.deleted },
|
|
643
|
+
);
|
|
644
|
+
if (claimed) await this.messageService.delete(messageId);
|
|
645
|
+
},
|
|
586
646
|
});
|
|
587
647
|
}
|
|
588
648
|
|
|
@@ -593,7 +653,6 @@ export class MessageMoveService {
|
|
|
593
653
|
);
|
|
594
654
|
}
|
|
595
655
|
|
|
596
|
-
// Batch send events to SQS
|
|
597
656
|
await this.enqueueEventsBatch(events);
|
|
598
657
|
|
|
599
658
|
return { refusedMessageIds: refused };
|
|
@@ -750,7 +809,14 @@ export class MessageMoveService {
|
|
|
750
809
|
uid: message.uid,
|
|
751
810
|
};
|
|
752
811
|
|
|
753
|
-
await this.enqueueEvent(event)
|
|
812
|
+
await this.enqueueEvent(event, () =>
|
|
813
|
+
this.handBackUnsentMove(
|
|
814
|
+
accountConfigId,
|
|
815
|
+
messageId,
|
|
816
|
+
sourceMailbox.mailboxId,
|
|
817
|
+
message.uid,
|
|
818
|
+
),
|
|
819
|
+
);
|
|
754
820
|
};
|
|
755
821
|
|
|
756
822
|
/**
|
|
@@ -839,14 +905,13 @@ export class MessageMoveService {
|
|
|
839
905
|
// the copy at uid=0 with an undelivered event; upsert no-ops on the row
|
|
840
906
|
// and lets the event be re-enqueued below, keeping the operation
|
|
841
907
|
// idempotent and the copy re-drivable (issue #75).
|
|
842
|
-
await this.messageService.
|
|
908
|
+
const copyRow = await this.messageService.upsertWithStatus({
|
|
843
909
|
messageId: newMessageId,
|
|
844
910
|
mailboxId: destinationMailboxId,
|
|
845
911
|
uid: 0, // Will be updated by worker after IMAP COPY
|
|
846
912
|
sequenceNumber: 0, // Will be updated by worker
|
|
847
913
|
rfc822Size: sourceMessage.rfc822Size,
|
|
848
914
|
internalDate: sourceMessage.internalDate,
|
|
849
|
-
messageIdHeader: sourceMessage.messageIdHeader,
|
|
850
915
|
envelopeId: sourceMessage.envelopeId, // Share envelope with source
|
|
851
916
|
rootBodyPartId: sourceMessage.rootBodyPartId, // Share body parts with source
|
|
852
917
|
status: MessageStatus.moving,
|
|
@@ -871,7 +936,7 @@ export class MessageMoveService {
|
|
|
871
936
|
messageId,
|
|
872
937
|
);
|
|
873
938
|
|
|
874
|
-
await this.threadMessageService.create({
|
|
939
|
+
const copyListing = await this.threadMessageService.create({
|
|
875
940
|
accountConfigId: sourceThreadMessage.accountConfigId,
|
|
876
941
|
threadId: sourceThreadMessage.threadId,
|
|
877
942
|
messageId: newMessageId,
|
|
@@ -923,7 +988,20 @@ export class MessageMoveService {
|
|
|
923
988
|
uid: sourceMessage.uid,
|
|
924
989
|
};
|
|
925
990
|
|
|
926
|
-
await this.enqueueEvent(event)
|
|
991
|
+
await this.enqueueEvent(event, async () => {
|
|
992
|
+
await this.threadMessageService.deleteMany([
|
|
993
|
+
{
|
|
994
|
+
accountConfigId: copyListing.accountConfigId,
|
|
995
|
+
threadMessageId: copyListing.threadMessageId,
|
|
996
|
+
},
|
|
997
|
+
]);
|
|
998
|
+
if (!copyRow.created) return;
|
|
999
|
+
await this.messageService.deleteIfPlacement(newMessageId, {
|
|
1000
|
+
status: MessageStatus.moving,
|
|
1001
|
+
mailboxId: destinationMailboxId,
|
|
1002
|
+
uid: 0,
|
|
1003
|
+
});
|
|
1004
|
+
});
|
|
927
1005
|
|
|
928
1006
|
return newMessageId;
|
|
929
1007
|
};
|
|
@@ -1053,6 +1131,7 @@ export class MessageMoveService {
|
|
|
1053
1131
|
// another lane won: the row is left alone and not counted, rather than
|
|
1054
1132
|
// marked `deleting` for an expunge that would bind somebody else's uid.
|
|
1055
1133
|
const messages: MessageItem[] = [];
|
|
1134
|
+
const undo: Array<() => Promise<void>> = [];
|
|
1056
1135
|
for (const message of candidates) {
|
|
1057
1136
|
const marked = await this.messageService.transitionPlacement(
|
|
1058
1137
|
message.messageId,
|
|
@@ -1074,11 +1153,29 @@ export class MessageMoveService {
|
|
|
1074
1153
|
continue;
|
|
1075
1154
|
}
|
|
1076
1155
|
messages.push(marked);
|
|
1077
|
-
await this.updateThreadMessageDeleted(
|
|
1156
|
+
const wasDeleted = await this.updateThreadMessageDeleted(
|
|
1078
1157
|
accountConfigId,
|
|
1079
1158
|
message.messageId,
|
|
1080
1159
|
true,
|
|
1081
1160
|
);
|
|
1161
|
+
undo.push(async () => {
|
|
1162
|
+
const restored = await this.messageService.transitionPlacement(
|
|
1163
|
+
message.messageId,
|
|
1164
|
+
{
|
|
1165
|
+
status: MessageStatus.deleting,
|
|
1166
|
+
mailboxId: message.mailboxId,
|
|
1167
|
+
uid: message.uid,
|
|
1168
|
+
},
|
|
1169
|
+
{ status: message.status, syncStatus: message.syncStatus },
|
|
1170
|
+
);
|
|
1171
|
+
if (restored && !wasDeleted) {
|
|
1172
|
+
await this.updateThreadMessageDeleted(
|
|
1173
|
+
accountConfigId,
|
|
1174
|
+
message.messageId,
|
|
1175
|
+
false,
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
});
|
|
1082
1179
|
}
|
|
1083
1180
|
|
|
1084
1181
|
this.log.info(
|
|
@@ -1103,7 +1200,9 @@ export class MessageMoveService {
|
|
|
1103
1200
|
trashUidValidity,
|
|
1104
1201
|
};
|
|
1105
1202
|
|
|
1106
|
-
await this.enqueueEvent(event)
|
|
1203
|
+
await this.enqueueEvent(event, async () => {
|
|
1204
|
+
for (const restore of undo) await restore();
|
|
1205
|
+
});
|
|
1107
1206
|
|
|
1108
1207
|
return { deletedCount: messages.length };
|
|
1109
1208
|
};
|
|
@@ -1250,7 +1349,7 @@ export class MessageMoveService {
|
|
|
1250
1349
|
accountConfigId: string,
|
|
1251
1350
|
messageId: string,
|
|
1252
1351
|
isDeleted: boolean,
|
|
1253
|
-
): Promise<
|
|
1352
|
+
): Promise<boolean> => {
|
|
1254
1353
|
const threadMessage = await this.threadMessageService.getByMessageId(
|
|
1255
1354
|
accountConfigId,
|
|
1256
1355
|
messageId,
|
|
@@ -1281,6 +1380,7 @@ export class MessageMoveService {
|
|
|
1281
1380
|
},
|
|
1282
1381
|
"Updated ThreadMessage.isDeleted",
|
|
1283
1382
|
);
|
|
1383
|
+
return threadMessage.isDeleted;
|
|
1284
1384
|
};
|
|
1285
1385
|
|
|
1286
1386
|
/**
|
|
@@ -1292,18 +1392,21 @@ export class MessageMoveService {
|
|
|
1292
1392
|
*/
|
|
1293
1393
|
private enqueueEvent = async (
|
|
1294
1394
|
event: MessageMoveQueueEvent,
|
|
1395
|
+
handBack: () => Promise<void>,
|
|
1295
1396
|
): Promise<void> => {
|
|
1296
1397
|
const useFifo = this.queueUrl.endsWith(".fifo");
|
|
1297
|
-
await this.sqs
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1398
|
+
await this.sqs
|
|
1399
|
+
.send(
|
|
1400
|
+
new SendMessageCommand({
|
|
1401
|
+
QueueUrl: this.queueUrl,
|
|
1402
|
+
MessageBody: JSON.stringify(event),
|
|
1403
|
+
...(useFifo && {
|
|
1404
|
+
MessageGroupId: event.accountId,
|
|
1405
|
+
MessageDeduplicationId: event.eventId,
|
|
1406
|
+
}),
|
|
1304
1407
|
}),
|
|
1305
|
-
|
|
1306
|
-
|
|
1408
|
+
)
|
|
1409
|
+
.catch((fault: unknown) => handBackThenThrow(fault, [handBack]));
|
|
1307
1410
|
|
|
1308
1411
|
this.log.info(
|
|
1309
1412
|
{ eventId: event.eventId, type: event.type },
|
|
@@ -1319,30 +1422,97 @@ export class MessageMoveService {
|
|
|
1319
1422
|
* reject it. We detect FIFO queues by the `.fifo` suffix.
|
|
1320
1423
|
*/
|
|
1321
1424
|
private enqueueEventsBatch = async (
|
|
1322
|
-
events:
|
|
1425
|
+
events: UndoableEvent<MessageDeleteEvent>[],
|
|
1323
1426
|
): Promise<void> => {
|
|
1324
|
-
|
|
1427
|
+
const enqueued = await this.sendEventsBatch(events);
|
|
1428
|
+
if (enqueued.outcome === "sent") return;
|
|
1429
|
+
await handBackThenThrow(
|
|
1430
|
+
enqueued.fault,
|
|
1431
|
+
enqueued.unsent.map(({ handBack }) => handBack),
|
|
1432
|
+
);
|
|
1433
|
+
};
|
|
1325
1434
|
|
|
1435
|
+
// A batch send answers 200 with per-entry refusals in `Failed`.
|
|
1436
|
+
private sendEventsBatch = async (
|
|
1437
|
+
events: UndoableEvent<MessageDeleteEvent>[],
|
|
1438
|
+
): Promise<BatchEnqueueOutcome> => {
|
|
1326
1439
|
const SQS_BATCH_SIZE = 10;
|
|
1327
1440
|
const useFifo = this.queueUrl.endsWith(".fifo");
|
|
1328
1441
|
|
|
1329
1442
|
for (let i = 0; i < events.length; i += SQS_BATCH_SIZE) {
|
|
1330
1443
|
const batch = events.slice(i, i + SQS_BATCH_SIZE);
|
|
1331
|
-
await this.sqs
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1444
|
+
const sent = await this.sqs
|
|
1445
|
+
.send(
|
|
1446
|
+
new SendMessageBatchCommand({
|
|
1447
|
+
QueueUrl: this.queueUrl,
|
|
1448
|
+
Entries: batch.map(({ event }, idx) => ({
|
|
1449
|
+
Id: `${i + idx}`,
|
|
1450
|
+
MessageBody: JSON.stringify(event),
|
|
1451
|
+
...(useFifo && {
|
|
1452
|
+
MessageGroupId: event.accountId,
|
|
1453
|
+
MessageDeduplicationId: event.eventId,
|
|
1454
|
+
}),
|
|
1455
|
+
})),
|
|
1456
|
+
}),
|
|
1457
|
+
)
|
|
1458
|
+
.then(
|
|
1459
|
+
(result) => ({
|
|
1460
|
+
refusedIds: new Set((result.Failed ?? []).map((entry) => entry.Id)),
|
|
1461
|
+
}),
|
|
1462
|
+
(fault: unknown) => ({ fault }),
|
|
1463
|
+
);
|
|
1464
|
+
if ("fault" in sent) {
|
|
1465
|
+
return {
|
|
1466
|
+
outcome: "unsent",
|
|
1467
|
+
unsent: events.slice(i),
|
|
1468
|
+
fault: sent.fault,
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
if (sent.refusedIds.size > 0) {
|
|
1472
|
+
const refused = batch.filter((_, idx) =>
|
|
1473
|
+
sent.refusedIds.has(`${i + idx}`),
|
|
1474
|
+
);
|
|
1475
|
+
return {
|
|
1476
|
+
outcome: "unsent",
|
|
1477
|
+
unsent: [...refused, ...events.slice(i + SQS_BATCH_SIZE)],
|
|
1478
|
+
fault: new Error(
|
|
1479
|
+
`The queue refused ${refused.length} of ${events.length} delete event(s)`,
|
|
1480
|
+
),
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
if (events.length > 0) {
|
|
1486
|
+
this.log.info({ count: events.length }, "Enqueued message events batch");
|
|
1344
1487
|
}
|
|
1488
|
+
return { outcome: "sent" };
|
|
1489
|
+
};
|
|
1345
1490
|
|
|
1346
|
-
|
|
1491
|
+
private handBackUnsentMove = async (
|
|
1492
|
+
accountConfigId: string,
|
|
1493
|
+
messageId: string,
|
|
1494
|
+
sourceMailboxId: string,
|
|
1495
|
+
uid: number,
|
|
1496
|
+
): Promise<void> => {
|
|
1497
|
+
await restoreSourcePlacement(
|
|
1498
|
+
{
|
|
1499
|
+
messageService: this.messageService,
|
|
1500
|
+
threadMessageService: this.threadMessageService,
|
|
1501
|
+
},
|
|
1502
|
+
{
|
|
1503
|
+
accountConfigId,
|
|
1504
|
+
messageId,
|
|
1505
|
+
sourceMailboxId,
|
|
1506
|
+
uid,
|
|
1507
|
+
syncStatus: MessageSyncStatus.synced,
|
|
1508
|
+
abandonedMutation: MessageMutation.none,
|
|
1509
|
+
},
|
|
1510
|
+
);
|
|
1511
|
+
await this.addressService.reconcileJunkOnlyForMessage(
|
|
1512
|
+
messageId,
|
|
1513
|
+
await this.mailboxSpecialUseService.resolveJunkRolesForConfig(
|
|
1514
|
+
accountConfigId,
|
|
1515
|
+
),
|
|
1516
|
+
);
|
|
1347
1517
|
};
|
|
1348
1518
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { isStorageNotFoundError } from "@remit/storage-service";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The slice of `BodySyncService` the read path needs to lazily materialize
|
|
5
|
+
* deferred per-part objects.
|
|
6
|
+
*/
|
|
7
|
+
export interface BodyPartMaterializer {
|
|
8
|
+
ensureBodyPartsStored(
|
|
9
|
+
accountConfigId: string,
|
|
10
|
+
accountId: string,
|
|
11
|
+
messageId: string,
|
|
12
|
+
bodyStorageKey: string,
|
|
13
|
+
): Promise<unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MaterializeLogger {
|
|
17
|
+
warn(obj: Record<string, unknown>, msg: string): void;
|
|
18
|
+
error(obj: Record<string, unknown>, msg: string): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Re-arms the body-sync cue for one message (the read-path slice of
|
|
23
|
+
* `BodySyncQueueService`).
|
|
24
|
+
*/
|
|
25
|
+
export interface BodySyncCue {
|
|
26
|
+
requestBodySync(input: {
|
|
27
|
+
accountId: string;
|
|
28
|
+
mailboxId: string;
|
|
29
|
+
messageId: string;
|
|
30
|
+
uid?: number;
|
|
31
|
+
}): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface MaterializeResult {
|
|
35
|
+
/** `false` when the body object was missing — the caller must not serve the body. */
|
|
36
|
+
ready: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Materialize the deferred per-part storage objects for a message before any
|
|
41
|
+
* part is read. Distinguishes the two failure modes the body contract cares
|
|
42
|
+
* about:
|
|
43
|
+
*
|
|
44
|
+
* - Missing storage object (never synced or lost) — re-arm the SYNC_MESSAGE_BODY
|
|
45
|
+
* cue so the worker (re)stores the body, and report `ready: false`. Not a
|
|
46
|
+
* 500: a retry succeeds once the worker catches up.
|
|
47
|
+
* - Any other error — rethrow so the request 500s and the bug is observable.
|
|
48
|
+
* Never swallowed, never "best-effort".
|
|
49
|
+
*
|
|
50
|
+
* No-op (`ready: true`) when the body isn't stored yet (no `bodyStorageKey`) or
|
|
51
|
+
* when the parts were written eagerly.
|
|
52
|
+
*/
|
|
53
|
+
export const materializeBodyParts = async (
|
|
54
|
+
materializer: BodyPartMaterializer,
|
|
55
|
+
args: {
|
|
56
|
+
accountConfigId: string;
|
|
57
|
+
accountId: string;
|
|
58
|
+
mailboxId: string;
|
|
59
|
+
messageId: string;
|
|
60
|
+
uid?: number;
|
|
61
|
+
bodyStorageKey: string | undefined;
|
|
62
|
+
cue: BodySyncCue | undefined;
|
|
63
|
+
logger: MaterializeLogger;
|
|
64
|
+
},
|
|
65
|
+
): Promise<MaterializeResult> => {
|
|
66
|
+
const { accountConfigId, accountId, mailboxId, messageId, uid } = args;
|
|
67
|
+
if (!args.bodyStorageKey) return { ready: true };
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await materializer.ensureBodyPartsStored(
|
|
71
|
+
accountConfigId,
|
|
72
|
+
accountId,
|
|
73
|
+
messageId,
|
|
74
|
+
args.bodyStorageKey,
|
|
75
|
+
);
|
|
76
|
+
return { ready: true };
|
|
77
|
+
} catch (error: unknown) {
|
|
78
|
+
if (!isStorageNotFoundError(error)) throw error;
|
|
79
|
+
|
|
80
|
+
args.logger.warn(
|
|
81
|
+
{ messageId, accountId, mailboxId },
|
|
82
|
+
"Body object missing on read; re-arming body-sync cue and signalling retry",
|
|
83
|
+
);
|
|
84
|
+
await args.cue?.requestBodySync({ accountId, mailboxId, messageId, uid });
|
|
85
|
+
return { ready: false };
|
|
86
|
+
}
|
|
87
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import type {
|
|
4
|
+
AccountItem,
|
|
5
|
+
IThreadMessageRepository,
|
|
6
|
+
MailboxItem,
|
|
7
|
+
} from "@remit/data-ports";
|
|
8
|
+
import { MessageSyncService } from "./message-sync.js";
|
|
9
|
+
import { SyncWarningService } from "./sync-warning.js";
|
|
10
|
+
import { eml, FakeImapServer } from "./test-helpers/fake-imap.js";
|
|
11
|
+
import {
|
|
12
|
+
openStore,
|
|
13
|
+
type Store,
|
|
14
|
+
seedAccount,
|
|
15
|
+
seedMailbox,
|
|
16
|
+
silentLog,
|
|
17
|
+
} from "./test-helpers/test-store.js";
|
|
18
|
+
|
|
19
|
+
let store: Store;
|
|
20
|
+
let imap: FakeImapServer;
|
|
21
|
+
let account: AccountItem;
|
|
22
|
+
let inbox: MailboxItem;
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
store = await openStore();
|
|
26
|
+
imap = new FakeImapServer();
|
|
27
|
+
account = await seedAccount(store);
|
|
28
|
+
inbox = await seedMailbox(store, account, "INBOX");
|
|
29
|
+
imap.addFolder("INBOX");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => store.close());
|
|
33
|
+
|
|
34
|
+
/** The thread store, refusing its first create: the last write of a save. */
|
|
35
|
+
const failingFirstCreate = (
|
|
36
|
+
inner: IThreadMessageRepository,
|
|
37
|
+
): IThreadMessageRepository => {
|
|
38
|
+
let refused = false;
|
|
39
|
+
return Object.assign(Object.create(inner), {
|
|
40
|
+
create: (...args: Parameters<IThreadMessageRepository["create"]>) => {
|
|
41
|
+
if (!refused) {
|
|
42
|
+
refused = true;
|
|
43
|
+
return Promise.reject(new Error("the thread write failed"));
|
|
44
|
+
}
|
|
45
|
+
return inner.create(...args);
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const syncOnce = (threadMessage: IThreadMessageRepository) => {
|
|
51
|
+
const connection = imap.connection();
|
|
52
|
+
return new MessageSyncService(
|
|
53
|
+
{ getConnection: () => connection, close: async () => {} },
|
|
54
|
+
store.mailbox,
|
|
55
|
+
store.mailboxSpecialUse,
|
|
56
|
+
store.message,
|
|
57
|
+
store.envelope,
|
|
58
|
+
store.address,
|
|
59
|
+
threadMessage,
|
|
60
|
+
new SyncWarningService(store.syncWarning),
|
|
61
|
+
silentLog,
|
|
62
|
+
).syncMessages(inbox.mailboxId, account.accountId, account.accountConfigId);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
describe("a message save that fails at its last write", () => {
|
|
66
|
+
it("is completed by the next round, with one row of each", async () => {
|
|
67
|
+
imap.deliver(
|
|
68
|
+
"INBOX",
|
|
69
|
+
eml({ Subject: "Halfway", "Message-ID": "<halfway@example.test>" }),
|
|
70
|
+
);
|
|
71
|
+
const threadMessage = failingFirstCreate(store.threadMessage);
|
|
72
|
+
|
|
73
|
+
await syncOnce(threadMessage);
|
|
74
|
+
await syncOnce(threadMessage);
|
|
75
|
+
|
|
76
|
+
const messages = await store.message.listAllByMailbox(inbox.mailboxId);
|
|
77
|
+
assert.equal(messages.length, 1);
|
|
78
|
+
const [message] = messages;
|
|
79
|
+
assert.ok(message);
|
|
80
|
+
assert.equal(
|
|
81
|
+
(
|
|
82
|
+
await store.threadMessage.findAllByMessageId(
|
|
83
|
+
account.accountConfigId,
|
|
84
|
+
message.messageId,
|
|
85
|
+
)
|
|
86
|
+
).length,
|
|
87
|
+
1,
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -6,12 +6,14 @@ import type {
|
|
|
6
6
|
IEnvelopeRepository,
|
|
7
7
|
IMailboxRepository,
|
|
8
8
|
IMessageRepository,
|
|
9
|
+
ISyncWarningRepository,
|
|
9
10
|
IThreadMessageRepository,
|
|
10
11
|
ThreadMessageItem,
|
|
11
12
|
} from "@remit/data-ports";
|
|
12
13
|
import { MessageCategory, MessageSystemFlag } from "@remit/domain-enums";
|
|
13
14
|
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
14
15
|
import { MessageSyncService } from "./message-sync.js";
|
|
16
|
+
import { SyncWarningService } from "./sync-warning.js";
|
|
15
17
|
import { noFolderRoles } from "./test-helpers/folder-roles.js";
|
|
16
18
|
import type { ImapEnvelope } from "./types.js";
|
|
17
19
|
|
|
@@ -46,6 +48,8 @@ const captureCreate = (): {
|
|
|
46
48
|
} => {
|
|
47
49
|
const inputs: CreateThreadMessageInput[] = [];
|
|
48
50
|
const repo = {
|
|
51
|
+
findByMessageId: async () => null,
|
|
52
|
+
rekeyThread: async () => ({ moved: 0, dropped: 0 }),
|
|
49
53
|
create: async (input: CreateThreadMessageInput) => {
|
|
50
54
|
inputs.push(input);
|
|
51
55
|
return input as unknown as ThreadMessageItem;
|
|
@@ -65,6 +69,7 @@ type CreateThreadForMessage = (
|
|
|
65
69
|
threadMessageService: IThreadMessageRepository,
|
|
66
70
|
messageId: string,
|
|
67
71
|
mailboxId: string,
|
|
72
|
+
accountId: string,
|
|
68
73
|
accountConfigId: string,
|
|
69
74
|
uid: number,
|
|
70
75
|
internalDate: number,
|
|
@@ -84,9 +89,12 @@ const createThreadWithFlags = async (
|
|
|
84
89
|
stub<IMailboxRepository>(),
|
|
85
90
|
noFolderRoles,
|
|
86
91
|
stub<IMessageRepository>(),
|
|
87
|
-
|
|
92
|
+
{
|
|
93
|
+
listQueuedStructureRefetches: async () => [],
|
|
94
|
+
} as unknown as IEnvelopeRepository,
|
|
88
95
|
stub<IAddressRepository>(),
|
|
89
96
|
stub<IThreadMessageRepository>(),
|
|
97
|
+
new SyncWarningService(stub<ISyncWarningRepository>()),
|
|
90
98
|
);
|
|
91
99
|
const { repo, inputs } = captureCreate();
|
|
92
100
|
const now = Date.now();
|
|
@@ -97,6 +105,7 @@ const createThreadWithFlags = async (
|
|
|
97
105
|
repo,
|
|
98
106
|
MESSAGE_ID,
|
|
99
107
|
MAILBOX_ID,
|
|
108
|
+
"acct-1",
|
|
100
109
|
ACCOUNT_CONFIG_ID,
|
|
101
110
|
42,
|
|
102
111
|
now,
|