@remit/mailbox-service 0.0.95 → 0.0.97
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 +1 -1
- package/src/external-move.test.ts +369 -0
- package/src/external-move.ts +272 -0
- package/src/imapflow-connection.ts +9 -0
- package/src/index.ts +6 -0
- package/src/mailbox-intent.ts +109 -0
- package/src/mailbox-management.test.ts +28 -2
- package/src/mailbox-management.ts +453 -69
- package/src/mailbox-presence.ts +6 -5
- package/src/mailbox-queue.ts +133 -13
- package/src/mailbox-rename-intent.test.ts +271 -25
- package/src/mailbox-rename-subtree.test.ts +494 -106
- package/src/mailbox-sync.ts +1 -27
- package/src/mailbox-upstream.ts +83 -0
- package/src/message-sync-changedsince.test.ts +8 -1
- package/src/message-sync-quarantine.test.ts +8 -1
- package/src/message-sync-repoint.test.ts +128 -103
- package/src/message-sync.ts +209 -77
package/src/message-sync.ts
CHANGED
|
@@ -10,7 +10,6 @@ import type {
|
|
|
10
10
|
IThreadMessageRepository,
|
|
11
11
|
IUnitOfWork,
|
|
12
12
|
MailboxItem,
|
|
13
|
-
MessageItem,
|
|
14
13
|
ThreadMessageItem,
|
|
15
14
|
} from "@remit/data-ports";
|
|
16
15
|
import { storedDisplayName } from "@remit/data-ports/display-name";
|
|
@@ -24,17 +23,21 @@ import {
|
|
|
24
23
|
deriveThreadId,
|
|
25
24
|
isValidMessageId,
|
|
26
25
|
} from "@remit/data-ports/id";
|
|
27
|
-
import { isVirtualCopyMailbox } from "@remit/data-ports/virtual-copy";
|
|
28
26
|
import {
|
|
29
27
|
AddressRole,
|
|
30
28
|
MailboxCursorState,
|
|
31
29
|
MessageKeywordFlag,
|
|
32
|
-
MessageStatus,
|
|
33
30
|
MessageSystemFlag,
|
|
34
31
|
StarColor,
|
|
35
32
|
} from "@remit/domain-enums";
|
|
36
33
|
import pMap from "p-map";
|
|
37
34
|
import type { ManagedConnectionFactory } from "./connection-factory.js";
|
|
35
|
+
import {
|
|
36
|
+
confirmDepartures,
|
|
37
|
+
type DepartureVerdicts,
|
|
38
|
+
placementKey,
|
|
39
|
+
sightingContestsPlacement,
|
|
40
|
+
} from "./external-move.js";
|
|
38
41
|
import { guardMailboxCursor, isCursorRebuildNeeded } from "./mailbox-cursor.js";
|
|
39
42
|
import {
|
|
40
43
|
type CursorRebuildRow,
|
|
@@ -132,40 +135,6 @@ export const addressSightingIn = (
|
|
|
132
135
|
return "correspondent";
|
|
133
136
|
};
|
|
134
137
|
|
|
135
|
-
/**
|
|
136
|
-
* Whether a sighting of an already-stored message in this mailbox re-points the
|
|
137
|
-
* row at this folder (#859). A message lives in one folder and the model never
|
|
138
|
-
* holds it in two, so a sighting somewhere else is not a rival claim to the
|
|
139
|
-
* row — it is a move another client made under reader. The last move wins, in
|
|
140
|
-
* both directions, and junk withholding derives from the pointer that results.
|
|
141
|
-
*
|
|
142
|
-
* Two sightings are not moves. Gmail's All Mail, Starred and Important hold
|
|
143
|
-
* every message that also lives in a real folder, so they are views rather than
|
|
144
|
-
* filing locations; following one would empty every other folder's listing. And
|
|
145
|
-
* a row whose own mutation has not settled is in flight, not stale
|
|
146
|
-
* (imap-mutations R1): the server's answer predates the move reader is still
|
|
147
|
-
* waiting on, so taking it would undo the optimistic write. The sync path
|
|
148
|
-
* reconciles rather than waits (imap-mutations R2): it never blocks on a
|
|
149
|
-
* mutation, it re-reads the row on the next sighting after the move settles.
|
|
150
|
-
*
|
|
151
|
-
* In flight is `status`, and `status` alone (#1096, imap-mutations R3). Every
|
|
152
|
-
* outbound mutation writes `moving` or `deleting` and only settling returns the
|
|
153
|
-
* row to `active`, so `active` is precisely the set nothing is coming for —
|
|
154
|
-
* whatever `syncStatus` says about how the row got there. A row put back by
|
|
155
|
-
* `abandonDelete` carries `abandoned`, and it needs this repair more than any
|
|
156
|
-
* other row does: nothing routine settles it, and reader shares its mailboxes,
|
|
157
|
-
* so refusing it left a message whose delete was refused unable to follow a
|
|
158
|
-
* move the user then made in another client.
|
|
159
|
-
*/
|
|
160
|
-
export const repointsOnSighting = (
|
|
161
|
-
mailbox: MailboxItem,
|
|
162
|
-
message: Pick<MessageItem, "mailboxId" | "status">,
|
|
163
|
-
): boolean => {
|
|
164
|
-
if (message.mailboxId === mailbox.mailboxId) return false;
|
|
165
|
-
if (isVirtualCopyMailbox(mailbox)) return false;
|
|
166
|
-
return message.status === MessageStatus.active;
|
|
167
|
-
};
|
|
168
|
-
|
|
169
138
|
/**
|
|
170
139
|
* The name an envelope carries for an address, as it should be stored: any
|
|
171
140
|
* claim to be some other address removed (issue #826), the rest of the name
|
|
@@ -238,6 +207,15 @@ export interface SyncedMessage {
|
|
|
238
207
|
*/
|
|
239
208
|
interface SaveMessageResult extends SyncedMessage {
|
|
240
209
|
owned: boolean;
|
|
210
|
+
/**
|
|
211
|
+
* The sighting contested the placement the row holds and no verdict about
|
|
212
|
+
* the source folder covered it (#1146): the source could not be asked, or
|
|
213
|
+
* the row moved between the probe and this save. The UID holds the watermark
|
|
214
|
+
* so the next round serves the sighting again — nothing else ever will, and
|
|
215
|
+
* a row left mispointed until a cursor rebuild is the silent never-repair
|
|
216
|
+
* this change exists to end.
|
|
217
|
+
*/
|
|
218
|
+
unresolvedSighting: boolean;
|
|
241
219
|
}
|
|
242
220
|
|
|
243
221
|
/**
|
|
@@ -268,6 +246,22 @@ export interface SyncMessagesResult {
|
|
|
268
246
|
cursorStalled: boolean;
|
|
269
247
|
}
|
|
270
248
|
|
|
249
|
+
/**
|
|
250
|
+
* The UIDs of this batch's undecided sightings (#1146). They join `failedUids`
|
|
251
|
+
* for the same reason a save that threw does: the watermark is the only thing
|
|
252
|
+
* that brings a UID back, and a sighting the round could not settle has to come
|
|
253
|
+
* back. A round that keeps failing to settle one trips the stalled-cursor alert,
|
|
254
|
+
* which is the surface for a source folder that has become unaskable.
|
|
255
|
+
*/
|
|
256
|
+
const unresolvedSightingUids = (outcomes: BatchOutcome[]): number[] =>
|
|
257
|
+
outcomes.flatMap((outcome) =>
|
|
258
|
+
outcome.kind === "saved" &&
|
|
259
|
+
outcome.result !== null &&
|
|
260
|
+
outcome.result.unresolvedSighting
|
|
261
|
+
? [outcome.uid]
|
|
262
|
+
: [],
|
|
263
|
+
);
|
|
264
|
+
|
|
271
265
|
const emptySyncResult = (): SyncMessagesResult => ({
|
|
272
266
|
syncedCount: 0,
|
|
273
267
|
syncedMessageIds: [],
|
|
@@ -299,6 +293,32 @@ export const selectUidsToSync = (
|
|
|
299
293
|
return uidsToSync.sort((a, b) => b - a);
|
|
300
294
|
};
|
|
301
295
|
|
|
296
|
+
/** A FETCH row that names a message: everything downstream needs the envelope. */
|
|
297
|
+
const hasEnvelope = (
|
|
298
|
+
msg: ImapMessage,
|
|
299
|
+
): msg is ImapMessage & { envelope: ImapEnvelope } =>
|
|
300
|
+
msg.envelope !== undefined;
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The row identity a sighting resolves to. Folder-independent whenever the
|
|
304
|
+
* message carries a usable `Message-ID` header, which is what lets the same
|
|
305
|
+
* mail in two of an account's folders meet on one row.
|
|
306
|
+
*/
|
|
307
|
+
const messageIdForSighting = (
|
|
308
|
+
accountId: string,
|
|
309
|
+
mailboxId: string,
|
|
310
|
+
msg: ImapMessage & { envelope: ImapEnvelope },
|
|
311
|
+
): string =>
|
|
312
|
+
deriveMessageIdFromSource(accountId, {
|
|
313
|
+
messageId: msg.envelope.messageId,
|
|
314
|
+
uid: msg.uid,
|
|
315
|
+
mailboxId,
|
|
316
|
+
date: msg.envelope.date,
|
|
317
|
+
subject: msg.envelope.subject,
|
|
318
|
+
fromMailbox: msg.envelope.from?.[0]?.mailbox,
|
|
319
|
+
fromHost: msg.envelope.from?.[0]?.host,
|
|
320
|
+
});
|
|
321
|
+
|
|
302
322
|
export class MessageSyncService {
|
|
303
323
|
private log: SyncLogger;
|
|
304
324
|
private unitOfWork: IUnitOfWork;
|
|
@@ -313,7 +333,7 @@ export class MessageSyncService {
|
|
|
313
333
|
* spammers as correspondents.
|
|
314
334
|
*/
|
|
315
335
|
private mailboxSpecialUseService: IMailboxSpecialUseRepository,
|
|
316
|
-
messageService: IMessageRepository,
|
|
336
|
+
private messageService: IMessageRepository,
|
|
317
337
|
envelopeService: IEnvelopeRepository,
|
|
318
338
|
addressService: IAddressRepository,
|
|
319
339
|
private threadMessageService: IThreadMessageRepository,
|
|
@@ -492,7 +512,7 @@ export class MessageSyncService {
|
|
|
492
512
|
// stepped straight over — [23, 22, 21] with 22 missing still advances to
|
|
493
513
|
// 23 and loses 22 for good. A failure is the one thing a watermark is
|
|
494
514
|
// built to stop below.
|
|
495
|
-
const applicable = messages.filter(
|
|
515
|
+
const applicable = messages.filter(hasEnvelope);
|
|
496
516
|
const unusableUids = batchUids.filter(
|
|
497
517
|
(uid) =>
|
|
498
518
|
!applicable.some((msg) => msg.uid === uid) &&
|
|
@@ -511,10 +531,22 @@ export class MessageSyncService {
|
|
|
511
531
|
// rejecting. So one bad message can no longer abort the whole batch (the
|
|
512
532
|
// poison pill that previously froze the mailbox, #817).
|
|
513
533
|
const roles = await this.folderRolesFor(accountId, accountConfigId);
|
|
534
|
+
const departures = await this.probeDepartures(
|
|
535
|
+
mailbox,
|
|
536
|
+
accountId,
|
|
537
|
+
applicable,
|
|
538
|
+
);
|
|
514
539
|
const outcomes = await pMap(
|
|
515
540
|
applicable,
|
|
516
541
|
(msg) =>
|
|
517
|
-
this.trySaveMessage(
|
|
542
|
+
this.trySaveMessage(
|
|
543
|
+
mailbox,
|
|
544
|
+
accountId,
|
|
545
|
+
accountConfigId,
|
|
546
|
+
msg,
|
|
547
|
+
roles,
|
|
548
|
+
departures,
|
|
549
|
+
),
|
|
518
550
|
{ concurrency: MESSAGE_SAVE_CONCURRENCY },
|
|
519
551
|
);
|
|
520
552
|
|
|
@@ -543,7 +575,18 @@ export class MessageSyncService {
|
|
|
543
575
|
"Some messages failed to save; holding watermark below them for retry",
|
|
544
576
|
);
|
|
545
577
|
}
|
|
546
|
-
const
|
|
578
|
+
const unresolvedUids = unresolvedSightingUids(outcomes);
|
|
579
|
+
if (unresolvedUids.length > 0) {
|
|
580
|
+
this.log.warn(
|
|
581
|
+
{ mailboxId, mailboxPath, unresolvedUids },
|
|
582
|
+
"Some sightings reached no verdict about the folder their row points at; holding the watermark below them",
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
const failedUids = new Set([
|
|
586
|
+
...saveFailedUids,
|
|
587
|
+
...unusableUids,
|
|
588
|
+
...unresolvedUids,
|
|
589
|
+
]);
|
|
547
590
|
|
|
548
591
|
// Watermarks advance over every SUCCESSFULLY-consumed UID in the batch,
|
|
549
592
|
// independent of ownership. `selectUidsToSync` reselects work purely by UID
|
|
@@ -744,12 +787,24 @@ export class MessageSyncService {
|
|
|
744
787
|
|
|
745
788
|
const newMessages =
|
|
746
789
|
newUids.length > 0 ? await this.fetchMessageBatch(newUids) : [];
|
|
747
|
-
const applicable = newMessages.filter(
|
|
790
|
+
const applicable = newMessages.filter(hasEnvelope);
|
|
748
791
|
const roles = await this.folderRolesFor(accountId, accountConfigId);
|
|
792
|
+
const departures = await this.probeDepartures(
|
|
793
|
+
mailbox,
|
|
794
|
+
accountId,
|
|
795
|
+
applicable,
|
|
796
|
+
);
|
|
749
797
|
const outcomes = await pMap(
|
|
750
798
|
applicable,
|
|
751
799
|
(msg) =>
|
|
752
|
-
this.trySaveMessage(
|
|
800
|
+
this.trySaveMessage(
|
|
801
|
+
mailbox,
|
|
802
|
+
accountId,
|
|
803
|
+
accountConfigId,
|
|
804
|
+
msg,
|
|
805
|
+
roles,
|
|
806
|
+
departures,
|
|
807
|
+
),
|
|
753
808
|
{ concurrency: MESSAGE_SAVE_CONCURRENCY },
|
|
754
809
|
);
|
|
755
810
|
const syncedMessages: SyncedMessage[] = outcomes.flatMap((o) =>
|
|
@@ -795,6 +850,7 @@ export class MessageSyncService {
|
|
|
795
850
|
const failedUids = new Set([
|
|
796
851
|
...outcomes.flatMap((o) => (o.kind === "failed" ? [o.uid] : [])),
|
|
797
852
|
...unusableUids,
|
|
853
|
+
...unresolvedSightingUids(outcomes),
|
|
798
854
|
]);
|
|
799
855
|
const lowestFailure = failedUids.size
|
|
800
856
|
? Math.min(...failedUids)
|
|
@@ -945,11 +1001,9 @@ export class MessageSyncService {
|
|
|
945
1001
|
// A quarantined UID stays in `batch`, so the cursor still advances over
|
|
946
1002
|
// it; only the work of re-applying it is skipped.
|
|
947
1003
|
const quarantined = await this.quarantineService?.load(accountConfigId);
|
|
948
|
-
const applicable = batch
|
|
949
|
-
(msg) =>
|
|
950
|
-
|
|
951
|
-
msg.envelope !== undefined,
|
|
952
|
-
);
|
|
1004
|
+
const applicable = batch
|
|
1005
|
+
.filter((msg) => !quarantined?.has(mailboxId, box.uidvalidity, msg.uid))
|
|
1006
|
+
.filter(hasEnvelope);
|
|
953
1007
|
|
|
954
1008
|
// A change row carrying no ENVELOPE holds the cursor and is retried; it
|
|
955
1009
|
// is never set aside. On this path the message is usually one already
|
|
@@ -974,10 +1028,22 @@ export class MessageSyncService {
|
|
|
974
1028
|
}
|
|
975
1029
|
|
|
976
1030
|
const roles = await this.folderRolesFor(accountId, accountConfigId);
|
|
1031
|
+
const departures = await this.probeDepartures(
|
|
1032
|
+
mailbox,
|
|
1033
|
+
accountId,
|
|
1034
|
+
applicable,
|
|
1035
|
+
);
|
|
977
1036
|
const outcomes = await pMap(
|
|
978
1037
|
applicable,
|
|
979
1038
|
(msg) =>
|
|
980
|
-
this.tryApplyChange(
|
|
1039
|
+
this.tryApplyChange(
|
|
1040
|
+
mailbox,
|
|
1041
|
+
accountId,
|
|
1042
|
+
accountConfigId,
|
|
1043
|
+
msg,
|
|
1044
|
+
roles,
|
|
1045
|
+
departures,
|
|
1046
|
+
),
|
|
981
1047
|
{ concurrency: MESSAGE_SAVE_CONCURRENCY },
|
|
982
1048
|
);
|
|
983
1049
|
|
|
@@ -990,7 +1056,18 @@ export class MessageSyncService {
|
|
|
990
1056
|
"Some changes failed to apply; holding the sync cursor below them for retry",
|
|
991
1057
|
);
|
|
992
1058
|
}
|
|
993
|
-
const
|
|
1059
|
+
const unresolvedUids = unresolvedSightingUids(outcomes);
|
|
1060
|
+
if (unresolvedUids.length > 0) {
|
|
1061
|
+
this.log.warn(
|
|
1062
|
+
{ mailboxId, mailboxPath, unresolvedUids },
|
|
1063
|
+
"Some sightings reached no verdict about the folder their row points at; holding the sync cursor below them",
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
const failedUids = new Set([
|
|
1067
|
+
...saveFailedUids,
|
|
1068
|
+
...unusableUids,
|
|
1069
|
+
...unresolvedUids,
|
|
1070
|
+
]);
|
|
994
1071
|
|
|
995
1072
|
// Body sync only concerns messages this round created — a metadata
|
|
996
1073
|
// change has no new body to fetch.
|
|
@@ -1087,9 +1164,17 @@ export class MessageSyncService {
|
|
|
1087
1164
|
accountConfigId: string,
|
|
1088
1165
|
msg: ImapMessage,
|
|
1089
1166
|
roles: AccountFolderRoles,
|
|
1167
|
+
departures: DepartureVerdicts,
|
|
1090
1168
|
): Promise<BatchOutcome> {
|
|
1091
1169
|
const mailboxId = mailbox.mailboxId;
|
|
1092
|
-
return this.applyChange(
|
|
1170
|
+
return this.applyChange(
|
|
1171
|
+
mailbox,
|
|
1172
|
+
accountId,
|
|
1173
|
+
accountConfigId,
|
|
1174
|
+
msg,
|
|
1175
|
+
roles,
|
|
1176
|
+
departures,
|
|
1177
|
+
)
|
|
1093
1178
|
.then((result): BatchOutcome => ({ kind: "saved", uid: msg.uid, result }))
|
|
1094
1179
|
.catch((error): BatchOutcome => {
|
|
1095
1180
|
this.log.warn(
|
|
@@ -1111,25 +1196,25 @@ export class MessageSyncService {
|
|
|
1111
1196
|
accountConfigId: string,
|
|
1112
1197
|
msg: ImapMessage,
|
|
1113
1198
|
roles: AccountFolderRoles,
|
|
1199
|
+
departures: DepartureVerdicts,
|
|
1114
1200
|
): Promise<SaveMessageResult | null> {
|
|
1115
|
-
if (!msg
|
|
1116
|
-
|
|
1117
|
-
const messageId =
|
|
1118
|
-
messageId: msg.envelope.messageId,
|
|
1119
|
-
uid: msg.uid,
|
|
1120
|
-
mailboxId: mailbox.mailboxId,
|
|
1121
|
-
date: msg.envelope.date,
|
|
1122
|
-
subject: msg.envelope.subject,
|
|
1123
|
-
fromMailbox: msg.envelope.from?.[0]?.mailbox,
|
|
1124
|
-
fromHost: msg.envelope.from?.[0]?.host,
|
|
1125
|
-
});
|
|
1201
|
+
if (!hasEnvelope(msg)) return null;
|
|
1202
|
+
|
|
1203
|
+
const messageId = messageIdForSighting(accountId, mailbox.mailboxId, msg);
|
|
1126
1204
|
|
|
1127
1205
|
const existing = await this.threadMessageService.findByMessageId(
|
|
1128
1206
|
accountConfigId,
|
|
1129
1207
|
messageId,
|
|
1130
1208
|
);
|
|
1131
1209
|
if (!existing) {
|
|
1132
|
-
return this.saveMessage(
|
|
1210
|
+
return this.saveMessage(
|
|
1211
|
+
mailbox,
|
|
1212
|
+
accountId,
|
|
1213
|
+
accountConfigId,
|
|
1214
|
+
msg,
|
|
1215
|
+
roles,
|
|
1216
|
+
departures,
|
|
1217
|
+
);
|
|
1133
1218
|
}
|
|
1134
1219
|
|
|
1135
1220
|
await this.applyServerFlags(existing, msg.flags);
|
|
@@ -1344,15 +1429,55 @@ export class MessageSyncService {
|
|
|
1344
1429
|
};
|
|
1345
1430
|
}
|
|
1346
1431
|
|
|
1432
|
+
/**
|
|
1433
|
+
* Ask the folders this batch's rows point at whether they still hold them
|
|
1434
|
+
* (#1146).
|
|
1435
|
+
*
|
|
1436
|
+
* Between the batch FETCH and the save pass, in all three rounds, and in that
|
|
1437
|
+
* position on purpose. It SELECTs another folder on the one connection this
|
|
1438
|
+
* sync has, so it may not run while this mailbox's own fetches are still
|
|
1439
|
+
* outstanding; and the save pass has to have the answer already, because the
|
|
1440
|
+
* transaction that re-points a row is where the answer is spent. Nothing
|
|
1441
|
+
* after the save pass touches IMAP, so leaving another folder selected costs
|
|
1442
|
+
* the round nothing.
|
|
1443
|
+
*/
|
|
1444
|
+
private async probeDepartures(
|
|
1445
|
+
mailbox: MailboxItem,
|
|
1446
|
+
accountId: string,
|
|
1447
|
+
applicable: Array<ImapMessage & { envelope: ImapEnvelope }>,
|
|
1448
|
+
): Promise<DepartureVerdicts> {
|
|
1449
|
+
return confirmDepartures(
|
|
1450
|
+
{
|
|
1451
|
+
connection: this.connectionFactory.getConnection(),
|
|
1452
|
+
mailboxService: this.mailboxService,
|
|
1453
|
+
messageService: this.messageService,
|
|
1454
|
+
log: this.log,
|
|
1455
|
+
},
|
|
1456
|
+
accountId,
|
|
1457
|
+
mailbox,
|
|
1458
|
+
applicable.map((msg) =>
|
|
1459
|
+
messageIdForSighting(accountId, mailbox.mailboxId, msg),
|
|
1460
|
+
),
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1347
1464
|
private async trySaveMessage(
|
|
1348
1465
|
mailbox: MailboxItem,
|
|
1349
1466
|
accountId: string,
|
|
1350
1467
|
accountConfigId: string,
|
|
1351
1468
|
msg: ImapMessage,
|
|
1352
1469
|
roles: AccountFolderRoles,
|
|
1470
|
+
departures: DepartureVerdicts,
|
|
1353
1471
|
): Promise<BatchOutcome> {
|
|
1354
1472
|
const mailboxId = mailbox.mailboxId;
|
|
1355
|
-
return this.saveMessage(
|
|
1473
|
+
return this.saveMessage(
|
|
1474
|
+
mailbox,
|
|
1475
|
+
accountId,
|
|
1476
|
+
accountConfigId,
|
|
1477
|
+
msg,
|
|
1478
|
+
roles,
|
|
1479
|
+
departures,
|
|
1480
|
+
)
|
|
1356
1481
|
.then((result): BatchOutcome => ({ kind: "saved", uid: msg.uid, result }))
|
|
1357
1482
|
.catch((error): BatchOutcome => {
|
|
1358
1483
|
this.log.warn(
|
|
@@ -1374,8 +1499,9 @@ export class MessageSyncService {
|
|
|
1374
1499
|
accountConfigId: string,
|
|
1375
1500
|
msg: ImapMessage,
|
|
1376
1501
|
roles: AccountFolderRoles,
|
|
1502
|
+
departures: DepartureVerdicts,
|
|
1377
1503
|
): Promise<SaveMessageResult | null> {
|
|
1378
|
-
if (!msg
|
|
1504
|
+
if (!hasEnvelope(msg)) return null;
|
|
1379
1505
|
|
|
1380
1506
|
const mailboxId = mailbox.mailboxId;
|
|
1381
1507
|
const sighting = addressSightingIn(mailboxId, roles);
|
|
@@ -1383,15 +1509,7 @@ export class MessageSyncService {
|
|
|
1383
1509
|
// Store envelope to preserve narrowing in closures
|
|
1384
1510
|
const envelope = msg.envelope;
|
|
1385
1511
|
|
|
1386
|
-
const messageId =
|
|
1387
|
-
messageId: envelope.messageId,
|
|
1388
|
-
uid: msg.uid,
|
|
1389
|
-
mailboxId,
|
|
1390
|
-
date: envelope.date,
|
|
1391
|
-
subject: envelope.subject,
|
|
1392
|
-
fromMailbox: envelope.from?.[0]?.mailbox,
|
|
1393
|
-
fromHost: envelope.from?.[0]?.host,
|
|
1394
|
-
});
|
|
1512
|
+
const messageId = messageIdForSighting(accountId, mailboxId, msg);
|
|
1395
1513
|
const envelopeId = deriveEnvelopeId(messageId);
|
|
1396
1514
|
const rootBodyPartId = deriveBodyPartId(messageId, ROOT_PART_PATH);
|
|
1397
1515
|
|
|
@@ -1435,6 +1553,7 @@ export class MessageSyncService {
|
|
|
1435
1553
|
// conflict whose stored row points at a different mailbox is foreign-owned
|
|
1436
1554
|
// and must not feed this mailbox's watermark / body-sync (#634).
|
|
1437
1555
|
let owned = false;
|
|
1556
|
+
let unresolvedSighting = false;
|
|
1438
1557
|
|
|
1439
1558
|
// One unit of work for the whole message: on Postgres these repos are
|
|
1440
1559
|
// transaction-bound, so the Envelope, addresses, Message, BodyParts and
|
|
@@ -1487,9 +1606,22 @@ export class MessageSyncService {
|
|
|
1487
1606
|
// database holds under another folder. `upsertWithStatus` left the row
|
|
1488
1607
|
// alone, so the pointer is repaired here — mailbox and UID together,
|
|
1489
1608
|
// because a UID only means anything inside the folder that issued it.
|
|
1609
|
+
//
|
|
1610
|
+
// Only when the message has really left the folder the row points at
|
|
1611
|
+
// (#1146). A sighting alone does not say that: a Gmail label, a Sieve
|
|
1612
|
+
// `fileinto` beside a `keep` and an echoed Sent copy all put the same
|
|
1613
|
+
// message in a second real folder while the first still holds it, and
|
|
1614
|
+
// following those took the mail out of the Inbox. `confirmDepartures`
|
|
1615
|
+
// asked the source before this batch was saved, and its verdict names
|
|
1616
|
+
// the placement it was reached for — so a row that has moved since
|
|
1617
|
+
// matches nothing, and `unresolvedSighting` holds this UID back for
|
|
1618
|
+
// the next round rather than repairing the row against a stale answer.
|
|
1619
|
+
const contested = !created && sightingContestsPlacement(mailbox, item);
|
|
1620
|
+
const verdict = placementKey(messageId, item.mailboxId, item.uid);
|
|
1621
|
+
unresolvedSighting = contested && !departures.settled.has(verdict);
|
|
1490
1622
|
const repointed =
|
|
1491
|
-
|
|
1492
|
-
|
|
1623
|
+
contested &&
|
|
1624
|
+
departures.departed.has(verdict) &&
|
|
1493
1625
|
!(await this.holdsCopyOf(repos.message, messageId, mailboxId));
|
|
1494
1626
|
if (repointed) {
|
|
1495
1627
|
await repos.message.updateUid(messageId, msg.uid, mailboxId);
|
|
@@ -1541,7 +1673,7 @@ export class MessageSyncService {
|
|
|
1541
1673
|
);
|
|
1542
1674
|
});
|
|
1543
1675
|
|
|
1544
|
-
return { messageId, uid: msg.uid, owned };
|
|
1676
|
+
return { messageId, uid: msg.uid, owned, unresolvedSighting };
|
|
1545
1677
|
}
|
|
1546
1678
|
|
|
1547
1679
|
/**
|