@remit/mailbox-service 0.0.94 → 0.0.96

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.
@@ -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((msg) => msg.envelope !== undefined);
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(mailbox, accountId, accountConfigId, msg, roles),
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 failedUids = new Set([...saveFailedUids, ...unusableUids]);
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((msg) => msg.envelope !== undefined);
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(mailbox, accountId, accountConfigId, msg, roles),
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.filter(
949
- (msg) =>
950
- !quarantined?.has(mailboxId, box.uidvalidity, msg.uid) &&
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(mailbox, accountId, accountConfigId, msg, roles),
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 failedUids = new Set([...saveFailedUids, ...unusableUids]);
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(mailbox, accountId, accountConfigId, msg, roles)
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.envelope) return null;
1116
-
1117
- const messageId = deriveMessageIdFromSource(accountId, {
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(mailbox, accountId, accountConfigId, msg, roles);
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(mailbox, accountId, accountConfigId, msg, roles)
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.envelope) return null;
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 = deriveMessageIdFromSource(accountId, {
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
- !created &&
1492
- repointsOnSighting(mailbox, item) &&
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
  /**