@remit/mailbox-service 0.0.112 → 0.0.114

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 (270) hide show
  1. package/dist/account-credentials.d.ts +83 -0
  2. package/dist/account-credentials.js +104 -0
  3. package/dist/attribute-mapper.d.ts +36 -0
  4. package/dist/attribute-mapper.js +119 -0
  5. package/dist/body-parse.d.ts +28 -0
  6. package/{src/body-parse.ts → dist/body-parse.js} +18 -26
  7. package/dist/body-part-mapper.d.ts +92 -0
  8. package/dist/body-part-mapper.js +301 -0
  9. package/dist/body-sync-queue.d.ts +59 -0
  10. package/dist/body-sync-queue.js +56 -0
  11. package/dist/body-sync-terminal.d.ts +58 -0
  12. package/dist/body-sync-terminal.js +95 -0
  13. package/dist/body-sync.d.ts +351 -0
  14. package/dist/body-sync.js +1204 -0
  15. package/dist/calendar-parts.d.ts +23 -0
  16. package/dist/calendar-parts.js +18 -0
  17. package/dist/classification-backfill.d.ts +91 -0
  18. package/dist/classification-backfill.js +180 -0
  19. package/dist/classify-message.d.ts +31 -0
  20. package/dist/classify-message.js +89 -0
  21. package/dist/connection-factory.d.ts +54 -0
  22. package/dist/connection-factory.js +54 -0
  23. package/dist/connection-test.d.ts +41 -0
  24. package/dist/connection-test.js +152 -0
  25. package/dist/external-move.d.ts +94 -0
  26. package/dist/external-move.js +173 -0
  27. package/dist/filter-operations.d.ts +42 -0
  28. package/dist/filter-operations.js +211 -0
  29. package/dist/filters/filter-config.d.ts +14 -0
  30. package/dist/filters/filter-config.js +10 -0
  31. package/{src/filters/list-id.ts → dist/filters/list-id.d.ts} +2 -16
  32. package/dist/filters/list-id.js +26 -0
  33. package/dist/filters/match.d.ts +69 -0
  34. package/dist/filters/match.js +165 -0
  35. package/dist/filters/pipeline.d.ts +64 -0
  36. package/dist/filters/pipeline.js +100 -0
  37. package/{src/flag-push-terminal.ts → dist/flag-push-terminal.d.ts} +16 -72
  38. package/dist/flag-push-terminal.js +63 -0
  39. package/dist/flag-push.d.ts +69 -0
  40. package/dist/flag-push.js +111 -0
  41. package/dist/flag-queue.d.ts +175 -0
  42. package/dist/flag-queue.js +291 -0
  43. package/dist/folder-operations.d.ts +39 -0
  44. package/dist/folder-operations.js +112 -0
  45. package/dist/folder-placement.d.ts +13 -0
  46. package/dist/folder-placement.js +23 -0
  47. package/dist/heuristics/authenticationResults.d.ts +10 -0
  48. package/dist/heuristics/authenticationResults.js +187 -0
  49. package/dist/heuristics/classifyByHeaders.d.ts +82 -0
  50. package/dist/heuristics/classifyByHeaders.js +365 -0
  51. package/dist/heuristics/classifyPlacement.d.ts +67 -0
  52. package/dist/heuristics/classifyPlacement.js +153 -0
  53. package/dist/heuristics/machineSenders.d.ts +21 -0
  54. package/{src/heuristics/machineSenders.ts → dist/heuristics/machineSenders.js} +34 -40
  55. package/dist/heuristics/senderMismatch.d.ts +81 -0
  56. package/dist/heuristics/senderMismatch.js +192 -0
  57. package/dist/heuristics/socialDomains.d.ts +14 -0
  58. package/{src/heuristics/socialDomains.ts → dist/heuristics/socialDomains.js} +9 -9
  59. package/dist/heuristics/transactionalDomains.d.ts +18 -0
  60. package/{src/heuristics/transactionalDomains.ts → dist/heuristics/transactionalDomains.js} +8 -8
  61. package/dist/imapflow-connection.d.ts +377 -0
  62. package/dist/imapflow-connection.js +1147 -0
  63. package/dist/index.d.ts +63 -0
  64. package/dist/index.js +65 -0
  65. package/dist/list-id-backfill.d.ts +83 -0
  66. package/dist/list-id-backfill.js +132 -0
  67. package/dist/mailbox-cursor-rebuild.d.ts +74 -0
  68. package/dist/mailbox-cursor-rebuild.js +71 -0
  69. package/{src/mailbox-cursor.ts → dist/mailbox-cursor.d.ts} +20 -98
  70. package/dist/mailbox-cursor.js +102 -0
  71. package/dist/mailbox-intent.d.ts +41 -0
  72. package/{src/mailbox-intent.ts → dist/mailbox-intent.js} +38 -64
  73. package/dist/mailbox-management.d.ts +240 -0
  74. package/dist/mailbox-management.js +576 -0
  75. package/dist/mailbox-presence.d.ts +52 -0
  76. package/{src/mailbox-presence.ts → dist/mailbox-presence.js} +19 -30
  77. package/dist/mailbox-queue.d.ts +129 -0
  78. package/dist/mailbox-queue.js +216 -0
  79. package/dist/mailbox-sync.d.ts +93 -0
  80. package/dist/mailbox-sync.js +490 -0
  81. package/dist/mailbox-upstream.d.ts +38 -0
  82. package/{src/mailbox-upstream.ts → dist/mailbox-upstream.js} +37 -47
  83. package/dist/message-category.d.ts +51 -0
  84. package/{src/message-category.ts → dist/message-category.js} +28 -71
  85. package/dist/message-move.d.ts +285 -0
  86. package/dist/message-move.js +858 -0
  87. package/dist/message-parser.d.ts +14 -0
  88. package/dist/message-parser.js +17 -0
  89. package/{src/message-presence.ts → dist/message-presence.d.ts} +1 -13
  90. package/dist/message-presence.js +29 -0
  91. package/dist/message-read.d.ts +51 -0
  92. package/dist/message-read.js +31 -0
  93. package/dist/message-sync.d.ts +345 -0
  94. package/dist/message-sync.js +1358 -0
  95. package/dist/mime-walker.d.ts +102 -0
  96. package/dist/mime-walker.js +198 -0
  97. package/dist/organize-job.d.ts +20 -0
  98. package/dist/organize-job.js +37 -0
  99. package/dist/outbox-attachment-content.d.ts +37 -0
  100. package/dist/outbox-attachment-content.js +50 -0
  101. package/dist/outbox-attachment-filename.d.ts +19 -0
  102. package/dist/outbox-attachment-filename.js +64 -0
  103. package/dist/outbox-attachment.d.ts +148 -0
  104. package/dist/outbox-attachment.js +273 -0
  105. package/dist/outbox-queue.d.ts +73 -0
  106. package/dist/outbox-queue.js +280 -0
  107. package/{src/outbox-status.ts → dist/outbox-status.d.ts} +1 -6
  108. package/dist/outbox-status.js +18 -0
  109. package/dist/ownership.d.ts +24 -0
  110. package/dist/ownership.js +83 -0
  111. package/dist/placement-move-terminal.d.ts +67 -0
  112. package/dist/placement-move-terminal.js +88 -0
  113. package/dist/placement-move.d.ts +83 -0
  114. package/dist/placement-move.js +216 -0
  115. package/{src/placement-settled.ts → dist/placement-settled.d.ts} +13 -68
  116. package/dist/placement-settled.js +75 -0
  117. package/dist/quarantine.d.ts +95 -0
  118. package/dist/quarantine.js +131 -0
  119. package/dist/refusal.d.ts +31 -0
  120. package/dist/refusal.js +3 -0
  121. package/{src/restore-source-placement.ts → dist/restore-source-placement.d.ts} +29 -97
  122. package/dist/restore-source-placement.js +79 -0
  123. package/dist/search-text-backfill.d.ts +15 -0
  124. package/dist/search-text-backfill.js +33 -0
  125. package/dist/snippet.d.ts +61 -0
  126. package/dist/snippet.js +129 -0
  127. package/dist/spam-report.d.ts +86 -0
  128. package/dist/spam-report.js +241 -0
  129. package/dist/sync-warning.d.ts +21 -0
  130. package/dist/sync-warning.js +26 -0
  131. package/dist/sync-watermarks.d.ts +138 -0
  132. package/dist/sync-watermarks.js +205 -0
  133. package/dist/test-helpers/fake-imap.d.ts +151 -0
  134. package/dist/test-helpers/fake-imap.js +508 -0
  135. package/dist/test-helpers/folder-roles.d.ts +34 -0
  136. package/dist/test-helpers/folder-roles.js +47 -0
  137. package/dist/test-helpers/mime-tree-generator.d.ts +82 -0
  138. package/dist/test-helpers/mime-tree-generator.js +329 -0
  139. package/dist/test-helpers/test-store.d.ts +69 -0
  140. package/dist/test-helpers/test-store.js +193 -0
  141. package/dist/text/normalizer.d.ts +20 -0
  142. package/dist/text/normalizer.js +102 -0
  143. package/dist/thread-message-rows.d.ts +69 -0
  144. package/dist/thread-message-rows.js +40 -0
  145. package/dist/thread-root.d.ts +7 -0
  146. package/dist/thread-root.js +25 -0
  147. package/dist/types.d.ts +343 -0
  148. package/dist/types.js +19 -0
  149. package/package.json +86 -88
  150. package/src/account-credentials.ts +0 -210
  151. package/src/attribute-mapper.test.ts +0 -104
  152. package/src/attribute-mapper.ts +0 -148
  153. package/src/body-parse.test.ts +0 -55
  154. package/src/body-part-mapper.property.test.ts +0 -354
  155. package/src/body-part-mapper.ts +0 -410
  156. package/src/body-sync-queue.test.ts +0 -64
  157. package/src/body-sync-queue.ts +0 -131
  158. package/src/body-sync-terminal.test.ts +0 -148
  159. package/src/body-sync-terminal.ts +0 -174
  160. package/src/body-sync.test.ts +0 -925
  161. package/src/body-sync.ts +0 -1891
  162. package/src/calendar-parts.ts +0 -41
  163. package/src/classification-backfill.test.ts +0 -285
  164. package/src/classification-backfill.ts +0 -363
  165. package/src/classify-message.test.ts +0 -142
  166. package/src/classify-message.ts +0 -116
  167. package/src/connection-factory.ts +0 -102
  168. package/src/connection-test.ts +0 -208
  169. package/src/credentials.test.ts +0 -181
  170. package/src/external-move.test.ts +0 -262
  171. package/src/external-move.ts +0 -285
  172. package/src/filter-operations.test.ts +0 -319
  173. package/src/filter-operations.ts +0 -377
  174. package/src/filter-update.test.ts +0 -313
  175. package/src/filters/filter-config.test.ts +0 -87
  176. package/src/filters/filter-config.ts +0 -34
  177. package/src/filters/list-id.test.ts +0 -50
  178. package/src/filters/match.test.ts +0 -392
  179. package/src/filters/match.ts +0 -225
  180. package/src/filters/pipeline.test.ts +0 -199
  181. package/src/filters/pipeline.ts +0 -170
  182. package/src/flag-push.test.ts +0 -112
  183. package/src/flag-push.ts +0 -198
  184. package/src/flag-queue.test.ts +0 -197
  185. package/src/flag-queue.ts +0 -509
  186. package/src/folder-operations.test.ts +0 -273
  187. package/src/folder-operations.ts +0 -217
  188. package/src/folder-placement.ts +0 -56
  189. package/src/forwarded-message.test.ts +0 -122
  190. package/src/heuristics/authenticationResults.test.ts +0 -189
  191. package/src/heuristics/authenticationResults.ts +0 -212
  192. package/src/heuristics/classifyByHeaders.test.ts +0 -889
  193. package/src/heuristics/classifyByHeaders.ts +0 -467
  194. package/src/heuristics/classifyPlacement.test.ts +0 -366
  195. package/src/heuristics/classifyPlacement.ts +0 -211
  196. package/src/heuristics/classifyRealMail.test.ts +0 -242
  197. package/src/heuristics/machineSenders.test.ts +0 -101
  198. package/src/heuristics/senderMismatch.test.ts +0 -346
  199. package/src/heuristics/senderMismatch.ts +0 -259
  200. package/src/imapflow-connect-retry.test.ts +0 -97
  201. package/src/imapflow-connection.test.ts +0 -503
  202. package/src/imapflow-connection.ts +0 -1476
  203. package/src/index.ts +0 -397
  204. package/src/list-id-backfill.test.ts +0 -207
  205. package/src/list-id-backfill.ts +0 -274
  206. package/src/mailbox-cursor-rebuild.test.ts +0 -171
  207. package/src/mailbox-cursor-rebuild.ts +0 -146
  208. package/src/mailbox-cursor.test.ts +0 -141
  209. package/src/mailbox-delete-intent.test.ts +0 -119
  210. package/src/mailbox-management.test.ts +0 -243
  211. package/src/mailbox-management.ts +0 -838
  212. package/src/mailbox-presence.test.ts +0 -74
  213. package/src/mailbox-queue.ts +0 -397
  214. package/src/mailbox-rename-intent.test.ts +0 -220
  215. package/src/mailbox-rename-subtree.test.ts +0 -287
  216. package/src/mailbox-sync.test.ts +0 -286
  217. package/src/mailbox-sync.ts +0 -724
  218. package/src/message-move.test.ts +0 -726
  219. package/src/message-move.ts +0 -1518
  220. package/src/message-parser.ts +0 -26
  221. package/src/message-presence.test.ts +0 -50
  222. package/src/message-read.ts +0 -87
  223. package/src/message-save-retry.test.ts +0 -90
  224. package/src/message-sync-flags.test.ts +0 -167
  225. package/src/message-sync.test.ts +0 -762
  226. package/src/message-sync.ts +0 -2133
  227. package/src/mime-walker.test.ts +0 -311
  228. package/src/mime-walker.ts +0 -306
  229. package/src/organize-job.ts +0 -93
  230. package/src/outbox-attachment-content.test.ts +0 -153
  231. package/src/outbox-attachment-content.ts +0 -104
  232. package/src/outbox-attachment-filename.test.ts +0 -132
  233. package/src/outbox-attachment-filename.ts +0 -76
  234. package/src/outbox-attachment.test.ts +0 -626
  235. package/src/outbox-attachment.ts +0 -536
  236. package/src/outbox-failed-recovery.test.ts +0 -299
  237. package/src/outbox-queue.test.ts +0 -315
  238. package/src/outbox-queue.ts +0 -525
  239. package/src/ownership.ts +0 -140
  240. package/src/placement-move-terminal.ts +0 -145
  241. package/src/placement-move.ts +0 -363
  242. package/src/placement-settled.test.ts +0 -147
  243. package/src/placement.test.ts +0 -404
  244. package/src/quarantine.test.ts +0 -133
  245. package/src/quarantine.ts +0 -229
  246. package/src/refusal.ts +0 -29
  247. package/src/snippet.test.ts +0 -213
  248. package/src/snippet.ts +0 -152
  249. package/src/spam-report.test.ts +0 -329
  250. package/src/spam-report.ts +0 -357
  251. package/src/sync-warning.test.ts +0 -213
  252. package/src/sync-warning.ts +0 -56
  253. package/src/sync-watermarks.test.ts +0 -409
  254. package/src/sync-watermarks.ts +0 -319
  255. package/src/system-flag-wire-format.test.ts +0 -77
  256. package/src/test-helpers/fake-imap.ts +0 -690
  257. package/src/test-helpers/folder-roles.ts +0 -66
  258. package/src/test-helpers/mime-tree-generator.ts +0 -461
  259. package/src/test-helpers/test-queue.ts +0 -56
  260. package/src/test-helpers/test-store.ts +0 -299
  261. package/src/text/normalizer.test.ts +0 -223
  262. package/src/text/normalizer.ts +0 -140
  263. package/src/thread-message-rows.ts +0 -77
  264. package/src/thread-root.test.ts +0 -29
  265. package/src/thread-root.ts +0 -51
  266. package/src/types/mailparser-augment.d.ts +0 -23
  267. package/src/types/natural-porter-stemmer.d.ts +0 -50
  268. package/src/types/stopword.d.ts +0 -69
  269. package/src/types.ts +0 -371
  270. package/tsconfig.json +0 -8
@@ -0,0 +1,1358 @@
1
+ import { storedDisplayName } from "@remit/data-ports/display-name";
2
+ import { deriveAddressId, deriveBodyPartId, deriveCopyMessageId, deriveEnvelopeId, deriveMessageId, deriveMessageIdFromSource, deriveThreadId, isValidMessageId, wireMessageId, } from "@remit/data-ports/id";
3
+ import { AddressRole, MailboxCursorState, MessageKeywordFlag, MessageSystemFlag, StarColor, SyncWarningStage, } from "@remit/domain-enums";
4
+ import pMap from "p-map";
5
+ import { confirmDepartures, placementKey, sightingContestsPlacement, } from "./external-move.js";
6
+ import { guardMailboxCursor, isCursorRebuildNeeded } from "./mailbox-cursor.js";
7
+ import { matchCursorRebuild, } from "./mailbox-cursor-rebuild.js";
8
+ import { ROOT_PART_PATH, walkMimeStructure } from "./mime-walker.js";
9
+ import { advanceChangeCursor, advanceUidWatermarks, dropAppliedPrefix, formatChangeCursor, hasChangeCursor, orderByModseq, parseChangeCursor, parseModseq, } from "./sync-watermarks.js";
10
+ import { messageReferencesOf, parentOf, threadRootOf } from "./thread-root.js";
11
+ const MESSAGE_SAVE_CONCURRENCY = 10;
12
+ // Some IMAP servers (e.g. Hostnet) emit these literal placeholders in the
13
+ // ENVELOPE when they cannot parse a From header, instead of leaving the
14
+ // address parts empty.
15
+ const HOSTNET_MISSING_MAILBOX = "missing_mailbox";
16
+ const HOSTNET_MISSING_DOMAIN = "missing_domain";
17
+ /**
18
+ * A From address is only usable when it looks like a real mailbox: both parts
19
+ * present, neither is a known "could not parse" sentinel, and the host carries
20
+ * an actual domain (at least one dot). Detect this structurally so a fabricated
21
+ * string like `missing_mailbox@missing_domain` is never persisted as a sender.
22
+ */
23
+ export const isParseableEmailAddress = (address) => {
24
+ if (!address)
25
+ return false;
26
+ const mailbox = address.mailbox?.trim();
27
+ const host = address.host?.trim();
28
+ if (!mailbox || !host)
29
+ return false;
30
+ if (mailbox === HOSTNET_MISSING_MAILBOX || host === HOSTNET_MISSING_DOMAIN) {
31
+ return false;
32
+ }
33
+ return host.includes(".");
34
+ };
35
+ /**
36
+ * What a sighting of an address in this mailbox is worth. Junk withholds the
37
+ * sender from autocomplete (#822), Trash decides nothing either way, and
38
+ * anywhere else is a correspondent.
39
+ *
40
+ * Decided by mailbox IDENTITY against the account's resolved roles, not by the
41
+ * folder's flag and name: a role belongs to exactly one folder (RFC 032), and
42
+ * the folder it belongs to is whichever one the user appointed. Reading the
43
+ * name here meant a second folder called `Spam` also withheld its senders,
44
+ * while the folder the user actually appointed harvested every spammer in it as
45
+ * a correspondent (#837).
46
+ */
47
+ export const addressSightingIn = (mailboxId, roles) => {
48
+ if (roles.junkMailboxId !== null && mailboxId === roles.junkMailboxId) {
49
+ return "junk";
50
+ }
51
+ if (roles.trashMailboxId !== null && mailboxId === roles.trashMailboxId) {
52
+ return "discarded";
53
+ }
54
+ return "correspondent";
55
+ };
56
+ /**
57
+ * The name an envelope carries for an address, as it should be stored: any
58
+ * claim to be some other address removed (issue #826), the rest of the name
59
+ * kept. Refused wherever a name lands — the address book, the envelope the
60
+ * message header renders, and the sender label on the thread row.
61
+ */
62
+ export const harvestedDisplayName = (address) => {
63
+ if (!address?.name)
64
+ return "";
65
+ return storedDisplayName(address.name, `${address.mailbox}@${address.host}`);
66
+ };
67
+ /**
68
+ * Parse an external `Date:` header into an epoch-millisecond integer.
69
+ *
70
+ * The IMAP envelope `date` is a raw RFC 2822 header copied verbatim from the
71
+ * message. It can be missing, malformed, or in a format `Date` cannot parse —
72
+ * `new Date(raw).getTime()` then yields `NaN`. `NaN` is not a valid integer
73
+ * and ElectroDB rejects it, which previously threw on the envelope upsert and
74
+ * (because the batch aborted on the first rejection) stalled the whole mailbox.
75
+ *
76
+ * When the header is unparseable we fall back to `fallbackMs` — the IMAP
77
+ * server's own INTERNALDATE receive time, always a valid integer. The raw
78
+ * header is preserved separately in `dateRaw`, so nothing is lost.
79
+ */
80
+ export const parseHeaderDate = (raw, fallbackMs) => {
81
+ if (raw !== undefined && raw !== "") {
82
+ const parsed = new Date(raw).getTime();
83
+ if (Number.isFinite(parsed)) {
84
+ return { value: parsed, usedFallback: false };
85
+ }
86
+ }
87
+ return { value: fallbackMs, usedFallback: true };
88
+ };
89
+ const noopLogger = {
90
+ info: () => { },
91
+ warn: () => { },
92
+ error: () => { },
93
+ };
94
+ /**
95
+ * The UIDs of this batch's undecided sightings (#1146). They join `failedUids`
96
+ * for the same reason a save that threw does: the watermark is the only thing
97
+ * that brings a UID back, and a sighting the round could not settle has to come
98
+ * back. A round that keeps failing to settle one trips the stalled-cursor alert,
99
+ * which is the surface for a source folder that has become unaskable.
100
+ */
101
+ const unresolvedSightingUids = (outcomes) => outcomes.flatMap((outcome) => outcome.kind === "saved" &&
102
+ outcome.result !== null &&
103
+ outcome.result.unresolvedSighting
104
+ ? [outcome.uid]
105
+ : []);
106
+ const syncedUidsOf = (outcomes) => outcomes.flatMap((outcome) => outcome.kind === "saved" && !outcome.result?.unresolvedSighting
107
+ ? [outcome.uid]
108
+ : []);
109
+ const saveFailedUidsOf = (outcomes) => outcomes.flatMap((outcome) => outcome.kind === "failed" ? [outcome.uid] : []);
110
+ const envelopeLessUidsOf = (messages) => messages.flatMap((msg) => (hasEnvelope(msg) ? [] : [msg.uid]));
111
+ const emptySyncResult = () => ({
112
+ syncedCount: 0,
113
+ syncedMessageIds: [],
114
+ syncedMessages: [],
115
+ hasMore: false,
116
+ remainingCount: 0,
117
+ cursorStalled: false,
118
+ });
119
+ /**
120
+ * Pick the UIDs a full-enumeration round should sync, newest first.
121
+ *
122
+ * 1. New messages: UIDs above the forward watermark.
123
+ * 2. Backfill: UIDs below the lowest one synced so far.
124
+ * 3. A mailbox with no watermarks at all syncs everything.
125
+ */
126
+ export const selectUidsToSync = (allUids, lastSyncUid, highWaterMarkUid) => {
127
+ const newUids = allUids.filter((uid) => uid > highWaterMarkUid);
128
+ const backfillUids = lastSyncUid > 1 ? allUids.filter((uid) => uid < lastSyncUid) : [];
129
+ const isFreshSync = highWaterMarkUid === 0 && lastSyncUid === 0;
130
+ const uidsToSync = isFreshSync ? [...allUids] : [...newUids, ...backfillUids];
131
+ return uidsToSync.sort((a, b) => b - a);
132
+ };
133
+ /** A FETCH row that names a message: everything downstream needs the envelope. */
134
+ const hasEnvelope = (msg) => msg.envelope !== undefined;
135
+ /**
136
+ * The row identity a sighting resolves to. Folder-independent whenever the
137
+ * message carries a usable `Message-ID` header, which is what lets the same
138
+ * mail in two of an account's folders meet on one row.
139
+ */
140
+ const messageIdForSighting = (accountId, mailboxId, msg) => deriveMessageIdFromSource(accountId, {
141
+ messageId: msg.envelope.messageId,
142
+ uid: msg.uid,
143
+ mailboxId,
144
+ date: msg.envelope.date,
145
+ subject: msg.envelope.subject,
146
+ fromMailbox: msg.envelope.from?.[0]?.mailbox,
147
+ fromHost: msg.envelope.from?.[0]?.host,
148
+ });
149
+ export class MessageSyncService {
150
+ connectionFactory;
151
+ mailboxService;
152
+ mailboxSpecialUseService;
153
+ messageService;
154
+ envelopeService;
155
+ addressService;
156
+ threadMessageService;
157
+ syncWarningService;
158
+ flagPushMarkerService;
159
+ messageFlagService;
160
+ quarantineService;
161
+ log;
162
+ constructor(connectionFactory, mailboxService,
163
+ /**
164
+ * The account’s folder-role map. Required, not optional: which folder
165
+ * holds the Junk role decides whether a sighting withholds its sender
166
+ * from autocomplete, and a construction without it would quietly harvest
167
+ * spammers as correspondents.
168
+ */
169
+ mailboxSpecialUseService, messageService, envelopeService, addressService, threadMessageService, syncWarningService, logger,
170
+ /**
171
+ * Pending outbound flag-push markers (#1273). Supplied, an inbound
172
+ * metadata change never overwrites a local flip that IMAP has not been
173
+ * told about yet; omitted, the server always wins.
174
+ */
175
+ flagPushMarkerService,
176
+ /**
177
+ * The canonical flag record. Supplied, an inbound metadata change lands
178
+ * on the same record the outbound flip path reads, so a user's next
179
+ * flip is never dismissed as redundant.
180
+ */
181
+ messageFlagService,
182
+ /**
183
+ * The set of messages already set aside (issue #72). Message sync never
184
+ * writes a record — only the body path can attribute a failure to the
185
+ * message — but it reads the set so a uid the body path quarantined is
186
+ * not re-fetched on every round.
187
+ */
188
+ quarantineService) {
189
+ this.connectionFactory = connectionFactory;
190
+ this.mailboxService = mailboxService;
191
+ this.mailboxSpecialUseService = mailboxSpecialUseService;
192
+ this.messageService = messageService;
193
+ this.envelopeService = envelopeService;
194
+ this.addressService = addressService;
195
+ this.threadMessageService = threadMessageService;
196
+ this.syncWarningService = syncWarningService;
197
+ this.flagPushMarkerService = flagPushMarkerService;
198
+ this.messageFlagService = messageFlagService;
199
+ this.quarantineService = quarantineService;
200
+ this.log = logger ?? noopLogger;
201
+ }
202
+ /**
203
+ * Sync ONE batch of messages for a mailbox using newest-first strategy.
204
+ *
205
+ * Uses dual-watermark tracking:
206
+ * - highWaterMarkUid: highest UID ever seen (detects new messages)
207
+ * - lastSyncUid: lowest UID processed (tracks backfill progress)
208
+ *
209
+ * Returns hasMore=true if there are more messages to sync. The caller
210
+ * should re-enqueue another sync event to continue processing.
211
+ *
212
+ * @param mailboxId - The database mailbox ID
213
+ * @param accountId - The account ID (scopes message/thread identity)
214
+ * @param accountConfigId - The account config ID (used for address linking)
215
+ * @param batchSize - Number of messages to process per batch
216
+ */
217
+ async syncMessages(mailboxId, accountId, accountConfigId, batchSize = 50) {
218
+ const mailbox = await this.mailboxService.get(accountId, mailboxId);
219
+ const mailboxPath = mailbox.fullPath;
220
+ // A mailbox whose cursor is already invalid (or a rebuild that crashed
221
+ // mid-way, #1272) never falls through to the normal watermark-based sync
222
+ // below — stored UIDs on that axis are not trustworthy. The rebuild is a
223
+ // variant of sync, not a special wipe path, so it runs here under the
224
+ // same mailbox lock the caller already holds.
225
+ if (isCursorRebuildNeeded(mailbox.cursorState)) {
226
+ return this.rebuildCursor(mailbox, accountId, accountConfigId, mailboxId);
227
+ }
228
+ const lastSyncUid = mailbox.lastSyncUid || 0;
229
+ const highWaterMarkUid = mailbox.highWaterMarkUid || 0;
230
+ const connection = this.connectionFactory.getConnection();
231
+ const box = await connection.openBox(mailboxPath);
232
+ const status = await connection.getMailboxStatus(mailboxPath);
233
+ // Detection: the served UIDVALIDITY may have changed since it was last
234
+ // stored, even though this mailbox was `normal` a moment ago. Trip the
235
+ // cursor and pause — every stored watermark, the mod-sequence included,
236
+ // is meaningless on the new axis, so nothing below may be acted on this
237
+ // round (epic #1281 invariants 3 and 5). The rebuild that follows is
238
+ // where the mod-sequence is reseeded from the new axis.
239
+ const cursorCheck = await guardMailboxCursor({ mailboxService: this.mailboxService }, accountId, mailbox, box.uidvalidity);
240
+ if (!cursorCheck.ok) {
241
+ this.log.warn({ mailboxId, mailboxPath, cursorState: cursorCheck.state }, "UIDVALIDITY changed; mailbox cursor tripped, pausing outbound sync this round");
242
+ return emptySyncResult();
243
+ }
244
+ if (await this.restoreMissingStructure(mailbox, accountId, accountConfigId, batchSize)) {
245
+ await connection.openBox(mailboxPath);
246
+ }
247
+ const cursor = parseChangeCursor(mailbox.highestModseq);
248
+ if (hasChangeCursor(cursor) && connection.supportsCondstore()) {
249
+ return this.syncChangedSince({
250
+ mailbox,
251
+ accountId,
252
+ accountConfigId,
253
+ cursor,
254
+ box,
255
+ status,
256
+ batchSize,
257
+ });
258
+ }
259
+ // One read per round, not per message (issue #72). The list is small by
260
+ // design — a growing one is a bug being reported, not a page to paginate.
261
+ const quarantined = await this.quarantineService?.load(accountConfigId);
262
+ const allUids = await connection.search(["ALL"]);
263
+ const uids = selectUidsToSync(allUids, lastSyncUid, highWaterMarkUid);
264
+ const unseenCount = status.unseen;
265
+ const deletedCount = status.deletedCount;
266
+ if (uids.length === 0) {
267
+ await this.settleHeaderWarnings(accountConfigId, mailbox, box.uidvalidity, {
268
+ failedUids: [],
269
+ syncedUids: [],
270
+ serverUids: { kind: "Listed", uids: allUids },
271
+ });
272
+ // Nothing left to enumerate: the folder is fully covered on this
273
+ // UIDVALIDITY axis, which is the one moment a mod-sequence watermark
274
+ // can be seeded without hiding unsynced history behind it. From the
275
+ // next round on, this mailbox takes the CHANGEDSINCE path above.
276
+ await this.mailboxService.update(accountId, mailboxId, {
277
+ lastMessageSyncAt: Date.now(),
278
+ uidValidity: box.uidvalidity,
279
+ messageCount: status.messages,
280
+ highestModseq: status.highestModseq,
281
+ unseenCount,
282
+ deletedCount,
283
+ });
284
+ this.log.info({
285
+ mailboxId,
286
+ mailboxPath,
287
+ total: 0,
288
+ messageCount: status.messages,
289
+ unseenCount,
290
+ highestModseq: status.highestModseq,
291
+ }, "No new messages to sync");
292
+ return emptySyncResult();
293
+ }
294
+ const totalBatches = Math.ceil(uids.length / batchSize);
295
+ this.log.info({ mailboxId, mailboxPath, total: uids.length, batches: totalBatches }, "Starting message sync batch (newest first)");
296
+ // Process only the first batch
297
+ const batchUids = uids.slice(0, batchSize);
298
+ // A quarantined UID is not fetched again, but it stays in `batchUids` so
299
+ // the watermark still advances over it. Filtering it out of the selection
300
+ // instead would hold the watermark below a message that is already
301
+ // durably resolved, which is the stall by another route.
302
+ const fetchUids = batchUids.filter((uid) => !quarantined?.has(mailboxId, box.uidvalidity, uid));
303
+ const messages = fetchUids.length > 0 ? await this.fetchMessageBatch(fetchUids) : [];
304
+ // A UID the round could not act on. Two shapes reach here and neither is
305
+ // the message's fault: the FETCH returned no row at all (the connection
306
+ // layer drops rows imapflow yields without a usable UID or INTERNALDATE,
307
+ // #408, and a message can be expunged between the SEARCH and the FETCH),
308
+ // or it returned a row carrying no ENVELOPE, which names no message and
309
+ // is the same client-side glitch one field further in.
310
+ //
311
+ // They join `failedUids` rather than merely being left out of the batch.
312
+ // Absence alone does not hold a watermark: `advanceUidWatermarks` takes
313
+ // the MAX of what was applied, so a gap in the middle of a batch is
314
+ // stepped straight over — [23, 22, 21] with 22 missing still advances to
315
+ // 23 and loses 22 for good. A failure is the one thing a watermark is
316
+ // built to stop below.
317
+ const applicable = messages.filter(hasEnvelope);
318
+ const unusableUids = batchUids.filter((uid) => !applicable.some((msg) => msg.uid === uid) &&
319
+ !quarantined?.has(mailboxId, box.uidvalidity, uid));
320
+ if (unusableUids.length > 0) {
321
+ this.log.warn({ mailboxId, mailboxPath, unusableUids }, "FETCH returned no usable row for some requested UIDs; holding the watermark below them");
322
+ }
323
+ // Process messages in parallel with concurrency limit. `stopOnError` stays
324
+ // at its default — but each message is saved through `trySaveMessage`,
325
+ // which catches its own error and reports a `failed` outcome instead of
326
+ // rejecting. So one bad message can no longer abort the whole batch (the
327
+ // poison pill that previously froze the mailbox, #817).
328
+ const roles = await this.folderRolesFor(accountId, accountConfigId);
329
+ const departures = await this.probeDepartures(mailbox, accountId, applicable);
330
+ const outcomes = await pMap(applicable, (msg) => this.trySaveMessage(mailbox, accountId, accountConfigId, msg, roles, departures), { concurrency: MESSAGE_SAVE_CONCURRENCY });
331
+ // Body-sync set: only rows created or owned by THIS mailbox. A residual
332
+ // cross-mailbox collision (same deterministic messageId already owned by a
333
+ // different mailbox) must not push a foreign-owned messageId into
334
+ // syncedMessageIds, or body-sync would FETCH against the wrong mailbox's UID.
335
+ const ownedResults = outcomes.flatMap((o) => o.kind === "saved" && o.result !== null && o.result.owned
336
+ ? [o.result]
337
+ : []);
338
+ const syncedMessages = ownedResults.map(({ messageId, uid }) => ({ messageId, uid }));
339
+ const syncedMessageIds = syncedMessages.map((m) => m.messageId);
340
+ // UIDs whose save threw. They must stay inside the next cycle's fetch
341
+ // window, so the watermark may not advance past them (no silent loss).
342
+ const saveFailedUids = outcomes.flatMap((o) => o.kind === "failed" ? [o.uid] : []);
343
+ if (saveFailedUids.length > 0) {
344
+ this.log.warn({ mailboxId, mailboxPath, failedUids: saveFailedUids }, "Some messages failed to save; holding watermark below them for retry");
345
+ }
346
+ const unresolvedUids = unresolvedSightingUids(outcomes);
347
+ if (unresolvedUids.length > 0) {
348
+ this.log.warn({ mailboxId, mailboxPath, unresolvedUids }, "Some sightings reached no verdict about the folder their row points at; holding the watermark below them");
349
+ }
350
+ const failedUids = new Set([
351
+ ...saveFailedUids,
352
+ ...unusableUids,
353
+ ...unresolvedUids,
354
+ ]);
355
+ // Watermarks advance over every SUCCESSFULLY-consumed UID in the batch,
356
+ // independent of ownership. `selectUidsToSync` reselects work purely by UID
357
+ // vs watermark (there is no per-UID processed set), so a foreign-owned UID
358
+ // that did not advance the watermark would be re-fetched every cycle
359
+ // forever. The same Message-ID legitimately appears in several of one
360
+ // account's mailboxes (Gmail All Mail + INBOX/labels), so cross-mailbox
361
+ // conflicts are routine; excluding them from body-sync is correct, stalling
362
+ // forward sync is not. Failures are what a watermark may never pass —
363
+ // see `advanceUidWatermarks`.
364
+ const { highWaterMarkUid: newHighWaterMark, lastSyncUid: newLastSyncUid } = advanceUidWatermarks({
365
+ batchUids,
366
+ failedUids,
367
+ lastSyncUid,
368
+ highWaterMarkUid,
369
+ });
370
+ const remainingCount = uids.length - batchUids.length;
371
+ const hasMore = remainingCount > 0;
372
+ // The mod-sequence watermark is seeded only by a round that leaves no
373
+ // enumeration work behind and lost no message to a failed save. Seeding
374
+ // it earlier would switch the mailbox to CHANGEDSINCE while UIDs it has
375
+ // never fetched still sit below the watermark, and those messages would
376
+ // never be discovered. A UID the FETCH returned nothing usable for is in
377
+ // `failedUids` too: the round did not finish with it either.
378
+ const enumerationComplete = !hasMore && failedUids.size === 0;
379
+ await this.settleHeaderWarnings(accountConfigId, mailbox, box.uidvalidity, {
380
+ failedUids: [...saveFailedUids, ...envelopeLessUidsOf(messages)],
381
+ syncedUids: syncedUidsOf(outcomes),
382
+ serverUids: { kind: "Listed", uids: allUids },
383
+ });
384
+ await this.mailboxService.update(accountId, mailboxId, {
385
+ lastSyncUid: newLastSyncUid,
386
+ highWaterMarkUid: newHighWaterMark,
387
+ lastMessageSyncAt: Date.now(),
388
+ uidValidity: box.uidvalidity,
389
+ messageCount: status.messages,
390
+ ...(enumerationComplete ? { highestModseq: status.highestModseq } : {}),
391
+ unseenCount,
392
+ deletedCount,
393
+ });
394
+ // Same stall condition as the CHANGEDSINCE round, on the UID axis: work
395
+ // selected, nothing moved, and the next round will select exactly the
396
+ // same work. No error surfaces on its own — every failure here is caught
397
+ // and held back — so this is the only signal that the mailbox is stuck.
398
+ const stalled = newHighWaterMark === highWaterMarkUid && newLastSyncUid === lastSyncUid;
399
+ if (stalled) {
400
+ this.log.error({
401
+ alert: "message_sync_cursor_stalled",
402
+ mailboxId,
403
+ mailboxPath,
404
+ accountId,
405
+ pendingUids: uids.length,
406
+ failedUids: [...failedUids],
407
+ }, "Message sync watermarks did not advance while UIDs were pending; this mailbox stops syncing until they do");
408
+ }
409
+ this.log.info({
410
+ batch: 1,
411
+ totalBatches,
412
+ batchSize: messages.length,
413
+ synced: syncedMessageIds.length,
414
+ total: uids.length,
415
+ remaining: remainingCount,
416
+ hasMore,
417
+ highWaterMarkUid: newHighWaterMark,
418
+ lastSyncUid: newLastSyncUid,
419
+ }, "Batch complete");
420
+ return {
421
+ syncedCount: syncedMessageIds.length,
422
+ syncedMessageIds,
423
+ syncedMessages,
424
+ hasMore,
425
+ remainingCount,
426
+ cursorStalled: stalled,
427
+ };
428
+ }
429
+ /**
430
+ * Re-key a mailbox's stored UIDs against a new UIDVALIDITY axis (#1272).
431
+ *
432
+ * One envelope-level pass (UID + Message-ID + INTERNALDATE, no body
433
+ * fetches — {@link matchCursorRebuild}) matched against the rows already
434
+ * stored for this mailbox:
435
+ * - Match → rewrite the row's UID mapping in place; bodies and threads are
436
+ * untouched.
437
+ * - Server message with no row → normal new-message sync (the same
438
+ * `trySaveMessage` pipeline the regular batch sync uses).
439
+ * - Row with no counterpart → expunged; removed through `deleteIfPlacement`
440
+ * on the uid the rebuild read (#1283 — the exact same "gone upstream"
441
+ * outcome as a body-sync retry exhaustion finding a stale row), so a row
442
+ * a move re-pointed in the meantime is left alone.
443
+ *
444
+ * Idempotent by construction: re-entering mid-rebuild (crash recovery —
445
+ * the mailbox was left `rebuilding`) simply redoes the same match/rewrite
446
+ * pass, which converges on the same result. `cursorState` is stamped
447
+ * `rebuilding` before any write and only cleared to `normal` after the
448
+ * watermarks are rebuilt, so a crash anywhere in between leaves the
449
+ * mailbox paused rather than falling back to the stale axis.
450
+ */
451
+ async rebuildCursor(mailbox, accountId, accountConfigId, mailboxId) {
452
+ const mailboxPath = mailbox.fullPath;
453
+ await this.mailboxService.update(accountId, mailboxId, {
454
+ cursorState: MailboxCursorState.rebuilding,
455
+ });
456
+ const connection = this.connectionFactory.getConnection();
457
+ const box = await connection.openBox(mailboxPath);
458
+ // Read before the pass, never after: a message arriving while the
459
+ // rebuild runs is absent from the snapshot below, and a HIGHESTMODSEQ
460
+ // read afterwards would already sit above that arrival's mod-sequence —
461
+ // seeding it would close the mailbox over a message it never stored.
462
+ const status = await connection.getMailboxStatus(mailboxPath);
463
+ const allUids = await connection.search(["ALL"]);
464
+ const snapshots = await connection.fetchEnvelopeSnapshots(allUids);
465
+ const serverSnapshots = snapshots.map((s) => ({
466
+ uid: s.uid,
467
+ messageId: s.messageId,
468
+ internalDate: s.internalDate.getTime(),
469
+ }));
470
+ const existingRows = await this.listExistingCursorRows(accountConfigId, mailboxId);
471
+ const { matched, newUids, staleMessageIds } = matchCursorRebuild(serverSnapshots, existingRows);
472
+ // Bounded concurrency (mirrors MESSAGE_SAVE_CONCURRENCY below) — a
473
+ // sequential loop over a large mailbox's full match set risked the
474
+ // Lambda timeout on its own, independent of the fetch-size question
475
+ // (#1272 review, non-blocking finding).
476
+ await pMap(matched, async ({ messageId, newUid, threadMessage }) => {
477
+ await this.messageService.updateUid(messageId, newUid, mailboxId);
478
+ // Rewrite the denormalized ThreadMessage.uid alongside Message.uid —
479
+ // a normal move keeps both in sync (see buildThreadMessageMoveUpdate
480
+ // in message-move.ts), and the rebuild must too, or a resumed
481
+ // rebuild re-emits a no-op rewrite forever (listExistingCursorRows
482
+ // reads uid from ThreadMessage) and any reader of the list
483
+ // projection sees a stale UID.
484
+ if (threadMessage) {
485
+ await this.threadMessageService.update(threadMessage.accountConfigId, threadMessage.threadMessageId, { uid: newUid }, {
486
+ composites: {
487
+ sentDate: threadMessage.sentDate,
488
+ mailboxId: threadMessage.mailboxId,
489
+ isRead: threadMessage.isRead,
490
+ isDeleted: threadMessage.isDeleted,
491
+ hasStars: threadMessage.hasStars,
492
+ hasAttachment: threadMessage.hasAttachment,
493
+ },
494
+ });
495
+ }
496
+ }, { concurrency: MESSAGE_SAVE_CONCURRENCY });
497
+ const stale = new Set(staleMessageIds);
498
+ await pMap(existingRows.filter((row) => stale.has(row.messageId)), (row) => this.messageService.deleteIfPlacement(row.messageId, {
499
+ mailboxId,
500
+ uid: row.uid,
501
+ }), { concurrency: MESSAGE_SAVE_CONCURRENCY });
502
+ const newMessages = newUids.length > 0 ? await this.fetchMessageBatch(newUids) : [];
503
+ const applicable = newMessages.filter(hasEnvelope);
504
+ const roles = await this.folderRolesFor(accountId, accountConfigId);
505
+ const departures = await this.probeDepartures(mailbox, accountId, applicable);
506
+ const outcomes = await pMap(applicable, (msg) => this.trySaveMessage(mailbox, accountId, accountConfigId, msg, roles, departures), { concurrency: MESSAGE_SAVE_CONCURRENCY });
507
+ const syncedMessages = outcomes.flatMap((o) => o.kind === "saved" && o.result !== null && o.result.owned
508
+ ? [{ messageId: o.result.messageId, uid: o.result.uid }]
509
+ : []);
510
+ const serverUids = serverSnapshots.map((s) => s.uid);
511
+ // A UID this pass could not account for, in any of the three ways it can
512
+ // go missing: the snapshot FETCH never returned a row for it, the
513
+ // message FETCH never returned one, or the row it returned carried no
514
+ // ENVELOPE. None of the three is the message's fault and none can be
515
+ // quarantined, so each has to keep the UID selectable.
516
+ //
517
+ // This matters more here than on the enumeration path, not less. The
518
+ // covered region is computed from `serverUids` rather than from what was
519
+ // applied, so a UID missing anywhere inside its span is silently inside
520
+ // it; and this round seeds the mod-sequence and returns the mailbox to
521
+ // `normal`, so the next round takes CHANGEDSINCE, which never
522
+ // enumerates. A UID lost here is lost for good.
523
+ const savedUids = new Set(applicable.map((msg) => msg.uid));
524
+ const snapshotUids = new Set(serverUids);
525
+ const unusableUids = [
526
+ ...allUids.filter((uid) => !snapshotUids.has(uid)),
527
+ ...newUids.filter((uid) => !savedUids.has(uid)),
528
+ ];
529
+ if (unusableUids.length > 0) {
530
+ this.log.warn({ mailboxId, mailboxPath, unusableUids }, "Cursor rebuild could not account for some UIDs; holding the watermark below them");
531
+ }
532
+ // A new message whose save threw must stay selectable, so the forward
533
+ // watermark stops below it and every UID above it is re-enumerated next
534
+ // round. The mod-sequence seed is withheld entirely in that case: the
535
+ // old value is meaningless on this axis (RFC 7162 requires discarding
536
+ // it on a UIDVALIDITY change) and the new one would sit above the
537
+ // message that failed, so the mailbox goes back to enumeration until a
538
+ // clean round seeds it.
539
+ const failedUids = new Set([
540
+ ...outcomes.flatMap((o) => (o.kind === "failed" ? [o.uid] : [])),
541
+ ...unusableUids,
542
+ ...unresolvedSightingUids(outcomes),
543
+ ]);
544
+ const lowestFailure = failedUids.size
545
+ ? Math.min(...failedUids)
546
+ : Number.POSITIVE_INFINITY;
547
+ const coveredUids = serverUids.filter((uid) => uid < lowestFailure);
548
+ await this.settleHeaderWarnings(accountConfigId, mailbox, box.uidvalidity, {
549
+ failedUids: [
550
+ ...saveFailedUidsOf(outcomes),
551
+ ...envelopeLessUidsOf(newMessages),
552
+ ],
553
+ syncedUids: syncedUidsOf(outcomes),
554
+ serverUids: { kind: "Listed", uids: allUids },
555
+ });
556
+ await this.mailboxService.update(accountId, mailboxId, {
557
+ cursorState: MailboxCursorState.normal,
558
+ uidValidity: box.uidvalidity,
559
+ highWaterMarkUid: coveredUids.length > 0 ? Math.max(...coveredUids) : 0,
560
+ lastSyncUid: serverUids.length > 0 ? Math.min(...serverUids) : 0,
561
+ highestModseq: failedUids.size === 0 ? status.highestModseq : "0",
562
+ lastMessageSyncAt: Date.now(),
563
+ messageCount: status.messages,
564
+ unseenCount: status.unseen,
565
+ deletedCount: status.deletedCount,
566
+ });
567
+ this.log.info({
568
+ mailboxId,
569
+ mailboxPath,
570
+ matched: matched.length,
571
+ newMessages: syncedMessages.length,
572
+ stale: staleMessageIds.length,
573
+ }, "Mailbox cursor rebuild complete; returned to normal");
574
+ return {
575
+ syncedCount: syncedMessages.length,
576
+ syncedMessageIds: syncedMessages.map((m) => m.messageId),
577
+ syncedMessages,
578
+ hasMore: false,
579
+ remainingCount: 0,
580
+ cursorStalled: false,
581
+ };
582
+ }
583
+ /**
584
+ * Page through every ThreadMessage row for this mailbox, projecting just
585
+ * the fields {@link matchCursorRebuild} needs. ThreadMessage (not
586
+ * Message) is the read source: it already denormalizes `messageIdHeader`
587
+ * and `internalDate` alongside `uid`, so this needs no per-row Envelope
588
+ * lookup.
589
+ */
590
+ async listExistingCursorRows(accountConfigId, mailboxId) {
591
+ const rows = [];
592
+ let continuationToken;
593
+ do {
594
+ const result = await this.threadMessageService.listByMailbox(accountConfigId, mailboxId, {
595
+ continuationToken,
596
+ attributes: [
597
+ "messageId",
598
+ "messageIdHeader",
599
+ "internalDate",
600
+ "uid",
601
+ "accountConfigId",
602
+ "threadMessageId",
603
+ "sentDate",
604
+ "mailboxId",
605
+ "isRead",
606
+ "isDeleted",
607
+ "hasStars",
608
+ "hasAttachment",
609
+ ],
610
+ });
611
+ for (const row of result.items) {
612
+ rows.push({
613
+ messageId: row.messageId,
614
+ messageIdHeader: row.messageIdHeader ?? "",
615
+ internalDate: row.internalDate,
616
+ uid: row.uid,
617
+ // Carried so a match can also rewrite ThreadMessage.uid (a normal
618
+ // move keeps both in sync — the rebuild must too, or #1271's
619
+ // push-time UID resolution can read a stale projection).
620
+ threadMessage: {
621
+ accountConfigId: row.accountConfigId,
622
+ threadMessageId: row.threadMessageId,
623
+ sentDate: row.sentDate,
624
+ mailboxId: row.mailboxId,
625
+ isRead: row.isRead,
626
+ isDeleted: row.isDeleted,
627
+ hasStars: row.hasStars,
628
+ hasAttachment: row.hasAttachment,
629
+ },
630
+ });
631
+ }
632
+ continuationToken = result.continuationToken;
633
+ } while (continuationToken);
634
+ return rows;
635
+ }
636
+ /**
637
+ * One CHANGEDSINCE round (issue #20).
638
+ *
639
+ * A single `FETCH ... (CHANGEDSINCE <modseq>)` over the whole UID space
640
+ * returns both the messages that arrived and the messages whose metadata
641
+ * changed since the stored watermark, so a flag flipped on another client
642
+ * is picked up without enumerating the folder. Rows already present in
643
+ * this folder take the metadata path — the envelope, body structure and
644
+ * addresses are immutable, so re-writing them for a read-state change would
645
+ * be pure waste; everything else, a row another client moved here from a
646
+ * folder it points at included, takes the normal save pipeline (#1386).
647
+ *
648
+ * Expunges are invisible here (CONDSTORE without QRESYNC never reports
649
+ * them, RFC 7162 Section 3.1.2.1) — they remain the reconcile path's job.
650
+ */
651
+ async syncChangedSince(params) {
652
+ const { mailbox, accountId, accountConfigId, cursor, box, status, batchSize, } = params;
653
+ const mailboxId = mailbox.mailboxId;
654
+ const mailboxPath = mailbox.fullPath;
655
+ const connection = this.connectionFactory.getConnection();
656
+ // Ask from the last COMPLETE mod-sequence, so a group left part-applied
657
+ // by an earlier round is served again in full; its applied members are
658
+ // then dropped by position, without a lookup.
659
+ const changed = await connection.fetchMessagesChangedSince(cursor.modseq);
660
+ const ordered = dropAppliedPrefix(orderByModseq(changed), cursor);
661
+ const batch = ordered.slice(0, batchSize);
662
+ // A quarantined UID stays in `batch`, so the cursor still advances over
663
+ // it; only the work of re-applying it is skipped.
664
+ const quarantined = await this.quarantineService?.load(accountConfigId);
665
+ const applicable = batch
666
+ .filter((msg) => !quarantined?.has(mailboxId, box.uidvalidity, msg.uid))
667
+ .filter(hasEnvelope);
668
+ // A change row carrying no ENVELOPE holds the cursor and is retried; it
669
+ // is never set aside. On this path the message is usually one already
670
+ // stored — it demonstrably had a sender, a date and a Message-ID when it
671
+ // was first saved — so an envelope-less row is the FETCH glitching
672
+ // (#408), not the message being defective. Quarantining it would filter
673
+ // that UID out of every later round, stopping its flag sync until a
674
+ // purge, and tell the user a message they can open and read "arrived
675
+ // without a sender". A transient glitch heals on the retry; a persistent
676
+ // one trips the stalled-cursor alert, which is what that alert is for.
677
+ const unusableUids = batch.flatMap((msg) => msg.envelope === undefined &&
678
+ !quarantined?.has(mailboxId, box.uidvalidity, msg.uid)
679
+ ? [msg.uid]
680
+ : []);
681
+ if (unusableUids.length > 0) {
682
+ this.log.warn({ mailboxId, mailboxPath, unusableUids }, "Change rows carried no ENVELOPE; holding the sync cursor below them");
683
+ }
684
+ const roles = await this.folderRolesFor(accountId, accountConfigId);
685
+ const departures = await this.probeDepartures(mailbox, accountId, applicable);
686
+ const outcomes = await pMap(applicable, (msg) => this.tryApplyChange(mailbox, accountId, accountConfigId, msg, roles, departures), { concurrency: MESSAGE_SAVE_CONCURRENCY });
687
+ const saveFailedUids = outcomes.flatMap((o) => o.kind === "failed" ? [o.uid] : []);
688
+ if (saveFailedUids.length > 0) {
689
+ this.log.warn({ mailboxId, mailboxPath, failedUids: saveFailedUids }, "Some changes failed to apply; holding the sync cursor below them for retry");
690
+ }
691
+ const unresolvedUids = unresolvedSightingUids(outcomes);
692
+ if (unresolvedUids.length > 0) {
693
+ this.log.warn({ mailboxId, mailboxPath, unresolvedUids }, "Some sightings reached no verdict about the folder their row points at; holding the sync cursor below them");
694
+ }
695
+ const failedUids = new Set([
696
+ ...saveFailedUids,
697
+ ...unusableUids,
698
+ ...unresolvedUids,
699
+ ]);
700
+ // Body sync only concerns messages this round created — a metadata
701
+ // change has no new body to fetch.
702
+ const syncedMessages = outcomes.flatMap((o) => o.kind === "saved" && o.result !== null && o.result.owned
703
+ ? [{ messageId: o.result.messageId, uid: o.result.uid }]
704
+ : []);
705
+ const syncedMessageIds = syncedMessages.map((m) => m.messageId);
706
+ const { cursor: nextCursor, hasMore } = advanceChangeCursor({
707
+ cursor,
708
+ serverModseq: parseModseq(status.highestModseq),
709
+ ordered,
710
+ batch,
711
+ failedUids,
712
+ });
713
+ const highestModseq = formatChangeCursor(nextCursor);
714
+ await this.settleHeaderWarnings(accountConfigId, mailbox, box.uidvalidity, {
715
+ failedUids: [...saveFailedUids, ...unusableUids],
716
+ syncedUids: syncedUidsOf(outcomes),
717
+ serverUids: {
718
+ kind: "OnRequest",
719
+ list: () => connection.search(["ALL"]),
720
+ },
721
+ });
722
+ // The UID watermark obeys the same clamp as the enumeration path even
723
+ // though the cursor governs retries here: a mailbox that later falls
724
+ // back to enumeration must not find a failed UID already behind its
725
+ // forward watermark.
726
+ const { highWaterMarkUid: newHighWaterMark } = advanceUidWatermarks({
727
+ batchUids: batch.map((msg) => msg.uid),
728
+ failedUids,
729
+ lastSyncUid: mailbox.lastSyncUid || 0,
730
+ highWaterMarkUid: mailbox.highWaterMarkUid || 0,
731
+ });
732
+ await this.mailboxService.update(accountId, mailboxId, {
733
+ highestModseq,
734
+ highWaterMarkUid: newHighWaterMark,
735
+ lastMessageSyncAt: Date.now(),
736
+ uidValidity: box.uidvalidity,
737
+ messageCount: status.messages,
738
+ unseenCount: status.unseen,
739
+ deletedCount: status.deletedCount,
740
+ });
741
+ // A round that had work to do and moved nothing is stalled: the same
742
+ // fetch will return the same set forever, and the set only grows as the
743
+ // mailbox keeps changing. Nothing above this call fails, so nothing else
744
+ // would ever notice — the queue message is acked either way.
745
+ const stalled = ordered.length > 0 && highestModseq === mailbox.highestModseq;
746
+ if (stalled) {
747
+ this.log.error({
748
+ alert: "message_sync_cursor_stalled",
749
+ mailboxId,
750
+ mailboxPath,
751
+ accountId,
752
+ cursor: highestModseq,
753
+ pendingChanges: ordered.length,
754
+ failedUids: [...failedUids],
755
+ }, "Message sync cursor did not advance while changes were pending; this mailbox stops seeing changes until it does");
756
+ }
757
+ this.log.info({
758
+ mailboxId,
759
+ mailboxPath,
760
+ changed: ordered.length,
761
+ applied: batch.length,
762
+ created: syncedMessageIds.length,
763
+ fromCursor: formatChangeCursor(cursor),
764
+ cursor: highestModseq,
765
+ hasMore,
766
+ }, "CHANGEDSINCE round complete");
767
+ return {
768
+ syncedCount: syncedMessageIds.length,
769
+ syncedMessageIds,
770
+ syncedMessages,
771
+ hasMore,
772
+ remainingCount: ordered.length - batch.length,
773
+ cursorStalled: stalled,
774
+ };
775
+ }
776
+ async settleHeaderWarnings(accountConfigId, mailbox, uidValidity, round) {
777
+ await this.syncWarningService.settle({
778
+ accountConfigId,
779
+ accountId: mailbox.accountId,
780
+ mailboxId: mailbox.mailboxId,
781
+ uidValidity,
782
+ }, SyncWarningStage.Header, round);
783
+ }
784
+ /**
785
+ * Apply one message from a CHANGEDSINCE result without ever rejecting —
786
+ * same contract as {@link trySaveMessage}, so a single unapplicable change
787
+ * holds the watermark back instead of failing the round.
788
+ */
789
+ async tryApplyChange(mailbox, accountId, accountConfigId, msg, roles, departures) {
790
+ const mailboxId = mailbox.mailboxId;
791
+ return this.applyChange(mailbox, accountId, accountConfigId, msg, roles, departures)
792
+ .then((result) => ({ kind: "saved", uid: msg.uid, result }))
793
+ .catch((error) => {
794
+ this.log.warn({
795
+ mailboxId,
796
+ uid: msg.uid,
797
+ messageId: msg.envelope?.messageId,
798
+ error: error instanceof Error ? error.message : String(error),
799
+ }, "Failed to apply message change; will retry on next sync");
800
+ return { kind: "failed", uid: msg.uid };
801
+ });
802
+ }
803
+ async applyChange(mailbox, accountId, accountConfigId, msg, roles, departures) {
804
+ if (!hasEnvelope(msg))
805
+ return null;
806
+ const messageId = messageIdForSighting(accountId, mailbox.mailboxId, msg);
807
+ const existing = await this.threadMessageService.findByMessageId(accountConfigId, messageId);
808
+ if (!existing) {
809
+ return this.saveMessage(mailbox, accountId, accountConfigId, msg, roles, departures);
810
+ }
811
+ await this.applyServerFlags(existing, msg.flags);
812
+ if (!departures.needsSave.has(messageId))
813
+ return null;
814
+ return this.saveMessage(mailbox, accountId, accountConfigId, msg, roles, departures);
815
+ }
816
+ /**
817
+ * Bring a stored row's read and star state, and the $Junk/$NotJunk
818
+ * keywords on the canonical MessageFlag record, in line with the
819
+ * server's flags.
820
+ *
821
+ * A field with a pending outbound push is left alone: the user flipped it
822
+ * locally, IMAP has not been told yet, and the server's answer is
823
+ * therefore known-stale for that field. Writing it back would revert the
824
+ * flip in front of the user and then push the reverted value.
825
+ */
826
+ async applyServerFlags(existing, flags) {
827
+ const isRead = flags.includes(MessageSystemFlag.Seen);
828
+ const hasStars = flags.includes(MessageSystemFlag.Flagged);
829
+ const updates = {};
830
+ if (existing.isRead !== isRead &&
831
+ !(await this.hasPendingPush(existing.messageId, MessageSystemFlag.Seen))) {
832
+ updates.isRead = isRead;
833
+ }
834
+ if (existing.hasStars !== hasStars &&
835
+ !(await this.hasPendingPush(existing.messageId, MessageSystemFlag.Flagged))) {
836
+ updates.hasStars = hasStars;
837
+ // `hasStars` is the boolean of record and `star` its presentation
838
+ // colour; the two may never disagree (#58). A star cleared upstream
839
+ // loses its colour; one set upstream takes the standard colour unless
840
+ // the row already carries a real one the user chose.
841
+ if (!hasStars) {
842
+ updates.star = StarColor.None;
843
+ }
844
+ else if (existing.star === undefined ||
845
+ existing.star === StarColor.None) {
846
+ updates.star = StarColor.Yellow;
847
+ }
848
+ }
849
+ await this.mirrorKeywordFlag(existing.messageId, MessageKeywordFlag.Junk, flags);
850
+ await this.mirrorKeywordFlag(existing.messageId, MessageKeywordFlag.NotJunk, flags);
851
+ if (Object.keys(updates).length === 0)
852
+ return;
853
+ // MessageFlag is the canonical flag record — `FlagQueueService` reads it
854
+ // to decide whether a user's flip is redundant, and the API answers
855
+ // read/starred from it. Writing only the denormalized row would leave
856
+ // the two disagreeing, and the next local flip would be dismissed as
857
+ // already-in-state: a click that does nothing.
858
+ //
859
+ // Canonical record first, projection second — the same order the local
860
+ // flip path uses. A crash between the two leaves the pair inconsistent
861
+ // exactly as a crashed local flip would, and the round's watermark has
862
+ // not moved, so the next round re-fetches the message and re-applies
863
+ // both writes (each is idempotent).
864
+ if (updates.isRead !== undefined) {
865
+ await this.setMessageFlag(existing.messageId, MessageSystemFlag.Seen, updates.isRead);
866
+ }
867
+ if (updates.hasStars !== undefined) {
868
+ await this.setMessageFlag(existing.messageId, MessageSystemFlag.Flagged, updates.hasStars);
869
+ }
870
+ await this.threadMessageService.update(existing.accountConfigId, existing.threadMessageId, updates, {
871
+ // The CURRENT values of every sort-key attribute: ElectroDB uses
872
+ // them for the conditional check on the existing row and to
873
+ // recompute the new keys. Passing the new value here would fail
874
+ // that check and silently drop the update (see FlagQueueService).
875
+ composites: {
876
+ sentDate: existing.sentDate,
877
+ mailboxId: existing.mailboxId,
878
+ isRead: existing.isRead,
879
+ isDeleted: existing.isDeleted,
880
+ hasStars: existing.hasStars,
881
+ hasAttachment: existing.hasAttachment,
882
+ },
883
+ });
884
+ this.log.info({
885
+ messageId: existing.messageId,
886
+ threadMessageId: existing.threadMessageId,
887
+ ...updates,
888
+ }, "Applied server flag state from CHANGEDSINCE");
889
+ }
890
+ /**
891
+ * Set or clear one flag on the canonical record. Both repository calls are
892
+ * idempotent, so a re-applied change is a no-op rather than a conflict.
893
+ */
894
+ async setMessageFlag(messageId, flagName, present) {
895
+ if (!this.messageFlagService)
896
+ return;
897
+ if (present) {
898
+ await this.messageFlagService.addFlag(messageId, flagName);
899
+ return;
900
+ }
901
+ await this.messageFlagService.removeFlag(messageId, flagName);
902
+ }
903
+ async hasPendingPush(messageId, flagName) {
904
+ if (!this.flagPushMarkerService)
905
+ return false;
906
+ const marker = await this.flagPushMarkerService.find(messageId, flagName);
907
+ return marker !== null;
908
+ }
909
+ /**
910
+ * Mirror one RFC 5788 keyword ($Junk / $NotJunk) from the server into the
911
+ * canonical MessageFlag record, guarded the same way as the system flags:
912
+ * a pending local push wins over the server's known-stale answer.
913
+ *
914
+ * This mailbox is never remit-only — Apple Mail or another IMAP client is
915
+ * routinely connected too — so the keyword is message state, nothing
916
+ * more. It never reaches the Address model: reading another client's
917
+ * keyword as a standing rule about the sender is the exact fight this
918
+ * mirror must not pick.
919
+ */
920
+ async mirrorKeywordFlag(messageId, flagName, serverFlags) {
921
+ if (!this.messageFlagService)
922
+ return;
923
+ if (await this.hasPendingPush(messageId, flagName))
924
+ return;
925
+ const present = serverFlags.includes(flagName);
926
+ const hasFlag = await this.messageFlagService.hasFlag(messageId, flagName);
927
+ if (hasFlag === present)
928
+ return;
929
+ await this.setMessageFlag(messageId, flagName, present);
930
+ }
931
+ /**
932
+ * Messages the forwarded-message repair queued — their part rows dropped,
933
+ * the lost row only on the mail server — are fetched again by UID and saved
934
+ * through the same path as any other sighting, which writes their parts. No
935
+ * watermark moves: these UIDs are already behind them. Each leaves the queue
936
+ * once saved or once the server returns no usable row for it, whatever
937
+ * parts came back; only a failed save keeps it for the next round. Resolves
938
+ * `true` when it touched the connection, which the probe of departures may
939
+ * have left on another folder.
940
+ */
941
+ async restoreMissingStructure(mailbox, accountId, accountConfigId, batchSize) {
942
+ const queued = await this.envelopeService.listQueuedStructureRefetches(mailbox.mailboxId, batchSize);
943
+ if (queued.length === 0)
944
+ return false;
945
+ const applicable = (await this.fetchMessageBatch(queued.map(({ uid }) => uid))).filter(hasEnvelope);
946
+ const roles = await this.folderRolesFor(accountId, accountConfigId);
947
+ const departures = await this.probeDepartures(mailbox, accountId, applicable);
948
+ const outcomes = await pMap(applicable, (msg) => this.trySaveMessage(mailbox, accountId, accountConfigId, msg, roles, departures), { concurrency: MESSAGE_SAVE_CONCURRENCY });
949
+ const failedUids = new Set(outcomes.flatMap((outcome) => outcome.kind === "failed" ? [outcome.uid] : []));
950
+ await this.envelopeService.clearStructureRefetches(queued
951
+ .filter(({ uid }) => !failedUids.has(uid))
952
+ .map(({ messageId }) => messageId));
953
+ this.log.info({
954
+ mailboxId: mailbox.mailboxId,
955
+ requested: queued.length,
956
+ saved: outcomes.length - failedUids.size,
957
+ failed: failedUids.size,
958
+ }, "Re-read the structure of messages the forwarded-message repair queued");
959
+ return true;
960
+ }
961
+ /**
962
+ * Fetch a batch of messages using the managed connection.
963
+ * Assumes mailbox is already open from fetchUidsToSync.
964
+ */
965
+ async fetchMessageBatch(uids) {
966
+ const connection = this.connectionFactory.getConnection();
967
+ return await connection.fetchMessages(uids);
968
+ }
969
+ /**
970
+ * Save a single message without ever rejecting. Any error is caught and
971
+ * reported as a `failed` outcome so it cannot abort the surrounding `pMap`
972
+ * batch — the failed UID is held back from the watermark and retried next
973
+ * cycle. This is the guardrail that stops a single unsaveable message from
974
+ * permanently freezing the mailbox (#817).
975
+ */
976
+ /**
977
+ * Which folders hold Junk and Trash for this account, resolved once per
978
+ * batch rather than per message: the lookup reads an appointment row and
979
+ * the account’s mailbox list, and every message in a batch shares both.
980
+ */
981
+ async folderRolesFor(accountId, accountConfigId) {
982
+ const [junk, trash, configJunkRoles] = await Promise.all([
983
+ this.mailboxSpecialUseService.findJunkMailbox(accountId),
984
+ this.mailboxSpecialUseService.findTrashMailbox(accountId),
985
+ this.mailboxSpecialUseService.resolveJunkRolesForConfig(accountConfigId),
986
+ ]);
987
+ return {
988
+ junkMailboxId: junk?.mailboxId ?? null,
989
+ trashMailboxId: trash?.mailboxId ?? null,
990
+ configJunkRoles,
991
+ };
992
+ }
993
+ /**
994
+ * Ask the folders this batch's rows point at whether they still hold them
995
+ * (#1146).
996
+ *
997
+ * Between the batch FETCH and the save pass, in all three rounds, and in that
998
+ * position on purpose. It SELECTs another folder on the one connection this
999
+ * sync has, so it may not run while this mailbox's own fetches are still
1000
+ * outstanding; and the save pass has to have the answer already, because the
1001
+ * transaction that re-points a row is where the answer is spent. Nothing
1002
+ * after the save pass touches IMAP, so leaving another folder selected costs
1003
+ * the round nothing.
1004
+ */
1005
+ async probeDepartures(mailbox, accountId, applicable) {
1006
+ return confirmDepartures({
1007
+ connection: this.connectionFactory.getConnection(),
1008
+ mailboxService: this.mailboxService,
1009
+ messageService: this.messageService,
1010
+ log: this.log,
1011
+ }, accountId, mailbox, applicable.map((msg) => messageIdForSighting(accountId, mailbox.mailboxId, msg)));
1012
+ }
1013
+ async trySaveMessage(mailbox, accountId, accountConfigId, msg, roles, departures) {
1014
+ const mailboxId = mailbox.mailboxId;
1015
+ return this.saveMessage(mailbox, accountId, accountConfigId, msg, roles, departures)
1016
+ .then((result) => ({ kind: "saved", uid: msg.uid, result }))
1017
+ .catch((error) => {
1018
+ this.log.warn({
1019
+ mailboxId,
1020
+ uid: msg.uid,
1021
+ messageId: msg.envelope?.messageId,
1022
+ error: error instanceof Error ? error.message : String(error),
1023
+ }, "Failed to save message; will retry on next sync");
1024
+ return { kind: "failed", uid: msg.uid };
1025
+ });
1026
+ }
1027
+ async saveMessage(mailbox, accountId, accountConfigId, msg, roles, departures) {
1028
+ if (!hasEnvelope(msg))
1029
+ return null;
1030
+ const mailboxId = mailbox.mailboxId;
1031
+ const sighting = addressSightingIn(mailboxId, roles);
1032
+ // Store envelope to preserve narrowing in closures
1033
+ const envelope = msg.envelope;
1034
+ const messageId = messageIdForSighting(accountId, mailboxId, msg);
1035
+ const envelopeId = deriveEnvelopeId(messageId);
1036
+ const rootBodyPartId = deriveBodyPartId(messageId, ROOT_PART_PATH);
1037
+ const internalDateMs = msg.internalDate.getTime();
1038
+ const { value: sentDate, usedFallback: dateFellBack } = parseHeaderDate(envelope.date, internalDateMs);
1039
+ if (dateFellBack) {
1040
+ this.log.warn({
1041
+ mailboxId,
1042
+ messageId,
1043
+ uid: msg.uid,
1044
+ dateRaw: envelope.date,
1045
+ }, "Unparseable Date header; fell back to IMAP internalDate");
1046
+ }
1047
+ // Prepare all address save operations
1048
+ const addressOps = [
1049
+ { addresses: envelope.from, role: AddressRole.From },
1050
+ { addresses: envelope.sender, role: AddressRole.Sender },
1051
+ { addresses: envelope.replyTo, role: AddressRole.ReplyTo },
1052
+ { addresses: envelope.to, role: AddressRole.To },
1053
+ { addresses: envelope.cc, role: AddressRole.Cc },
1054
+ { addresses: envelope.bcc, role: AddressRole.Bcc },
1055
+ ];
1056
+ const bodyParts = buildBodyPartUpserts(msg.bodyStructure);
1057
+ const hasAttachment = bodyParts.some((p) => !p.isMultipart && p.disposition === "attachment");
1058
+ // Ownership of this messageId by the current mailbox: the row was created
1059
+ // by this call, or an existing row already belongs to this mailbox. A
1060
+ // conflict whose stored row points at a different mailbox is foreign-owned
1061
+ // and must not feed this mailbox's watermark / body-sync (#634).
1062
+ let owned = false;
1063
+ let unresolvedSighting = false;
1064
+ // Every write below is an idempotent upsert keyed on a derived id, so a
1065
+ // save that fails part-way is repaired by the next round repeating it: the
1066
+ // UID's watermark only moves once the save returns. The order is the
1067
+ // guarantee a reader gets in between: the Envelope lands before the
1068
+ // Message (#1072), and the ThreadMessage is written last so the list path
1069
+ // never anchors on a ThreadMessage whose Message does not yet exist
1070
+ // (#1209).
1071
+ await this.envelopeService.upsertEnvelope({
1072
+ envelopeId,
1073
+ messageId,
1074
+ dateValue: sentDate,
1075
+ dateRaw: envelope.date,
1076
+ subject: envelope.subject,
1077
+ messageIdValue: envelope.messageId,
1078
+ });
1079
+ const messageReferences = messageReferencesOf(envelope, msg.references);
1080
+ if (messageReferences.length > 0) {
1081
+ await this.envelopeService.replaceMessageReferences(messageId, messageReferences);
1082
+ }
1083
+ for (const { addresses, role } of addressOps) {
1084
+ await this.saveAddresses(this.addressService, messageId, accountConfigId, addresses, role, sighting);
1085
+ }
1086
+ // IMAP returns BODYSTRUCTURE in the same FETCH that returns the
1087
+ // envelope, so persisting BodyParts here is "free" — no extra round-trip.
1088
+ if (bodyParts.length > 0) {
1089
+ await this.envelopeService.upsertBodyParts(messageId, bodyParts);
1090
+ }
1091
+ const { item, created } = await this.messageService.upsertWithStatus({
1092
+ messageId,
1093
+ mailboxId,
1094
+ uid: msg.uid,
1095
+ sequenceNumber: msg.seq,
1096
+ rfc822Size: msg.size ?? 0,
1097
+ internalDate: msg.internalDate.getTime(),
1098
+ envelopeId,
1099
+ rootBodyPartId,
1100
+ });
1101
+ // The server has just named this folder and this UID for a message the
1102
+ // database holds under another folder. `upsertWithStatus` left the row
1103
+ // alone, so the pointer is repaired here — mailbox and UID together,
1104
+ // because a UID only means anything inside the folder that issued it.
1105
+ //
1106
+ // Only when the message has really left the folder the row points at
1107
+ // (#1146). A sighting alone does not say that: a Gmail label, a Sieve
1108
+ // `fileinto` beside a `keep` and an echoed Sent copy all put the same
1109
+ // message in a second real folder while the first still holds it, and
1110
+ // following those took the mail out of the Inbox. `confirmDepartures`
1111
+ // asked the source before this batch was saved, and its verdict names
1112
+ // the placement it was reached for — so a row that has moved since
1113
+ // matches nothing, and `unresolvedSighting` holds this UID back for
1114
+ // the next round rather than repairing the row against a stale answer.
1115
+ const contested = !created && sightingContestsPlacement(mailbox, item);
1116
+ const verdict = placementKey(messageId, item.mailboxId, item.uid);
1117
+ unresolvedSighting = contested && !departures.settled.has(verdict);
1118
+ const repointable = contested &&
1119
+ departures.departed.has(verdict) &&
1120
+ !(await this.holdsCopyOf(this.messageService, messageId, mailboxId));
1121
+ // A user move landing between the read above and this write wins: the
1122
+ // re-point names the placement it read, writes nothing when the row has
1123
+ // moved since, and the UID waits for the next round.
1124
+ const repointed = repointable &&
1125
+ (await this.messageService.repointIfPlacement(messageId, { status: item.status, mailboxId: item.mailboxId, uid: item.uid }, { mailboxId, uid: msg.uid, isDeleted: sighting === "discarded" }));
1126
+ if (repointable && !repointed)
1127
+ unresolvedSighting = true;
1128
+ owned = created || item.mailboxId === mailboxId || repointed;
1129
+ // Withholding reads the pointer, so it is re-derived whenever the
1130
+ // pointer could have changed the answer: a save into Junk, and a
1131
+ // re-point in either direction. The reconcile both applies and lifts,
1132
+ // so a sender whose only mail was moved out of Junk is restored by the
1133
+ // same call that would have withheld them (#859).
1134
+ if (sighting === "junk" || repointed) {
1135
+ await this.addressService.reconcileJunkOnlyForMessage(messageId, roles.configJunkRoles);
1136
+ }
1137
+ await this.createThreadForMessage(this.threadMessageService, messageId, mailboxId, accountId, accountConfigId, msg.uid, msg.internalDate.getTime(), sentDate, envelope, msg.flags,
1138
+ // The category of the Message this same save just wrote. A new
1139
+ // message is `uncategorized` until body-sync classifies it; a row
1140
+ // created for a Message that is ALREADY classified takes the decided
1141
+ // value rather than sitting stale forever behind a body-sync pass that
1142
+ // will skip it. That happens on thread-root drift — the same message
1143
+ // re-saved under different `References` mints a second threadId and so
1144
+ // a second row — not on a second mailbox, which resolves to the row
1145
+ // that already exists (issue #320).
1146
+ item.category, msg.references, hasAttachment);
1147
+ return { messageId, uid: msg.uid, owned, unresolvedSighting };
1148
+ }
1149
+ /**
1150
+ * Whether this folder already holds a copy the user made of this message
1151
+ * (#75). A copy is a second row under its own derived id, so the sighting is
1152
+ * that copy standing where it was put — the original has not moved, and
1153
+ * following it would take the mail out of the folder the user copied it from.
1154
+ */
1155
+ async holdsCopyOf(messageService, messageId, mailboxId) {
1156
+ const rows = await messageService.get([
1157
+ deriveCopyMessageId(messageId, mailboxId),
1158
+ ]);
1159
+ return rows.length > 0;
1160
+ }
1161
+ async saveAddresses(addressService, messageId, accountConfigId, addresses, role, sighting) {
1162
+ if (!addresses)
1163
+ return;
1164
+ // Pre-compute address data with order indices, filtering valid addresses
1165
+ const addressData = [];
1166
+ // An unusable address is dropped and the message is written anyway, and
1167
+ // that stays deliberate under the quarantine rules (issue #72). What is
1168
+ // lost is one envelope address, not the message: it is stored, listed and
1169
+ // readable, and its body is untouched. Setting the whole message aside
1170
+ // over a malformed From would take readable mail out of the mailbox to
1171
+ // protect a display name.
1172
+ for (let i = 0; i < addresses.length; i++) {
1173
+ const addr = addresses[i];
1174
+ if (!isParseableEmailAddress(addr))
1175
+ continue;
1176
+ addressData.push({
1177
+ localPart: addr.mailbox,
1178
+ domain: addr.host,
1179
+ displayName: harvestedDisplayName(addr),
1180
+ order: i,
1181
+ });
1182
+ }
1183
+ for (const { localPart, domain, displayName, order } of addressData) {
1184
+ const normalizedEmail = `${localPart}@${domain}`.toLowerCase();
1185
+ const normalizedCompound = `${displayName.toLowerCase()} ${normalizedEmail}`.trim();
1186
+ const addressId = deriveAddressId(accountConfigId, normalizedEmail);
1187
+ const addressInput = {
1188
+ addressId,
1189
+ accountConfigId,
1190
+ localPart,
1191
+ domain,
1192
+ normalizedEmail,
1193
+ normalizedCompound,
1194
+ displayName,
1195
+ };
1196
+ if (sighting === "junk") {
1197
+ await addressService.upsertJunkAddress(addressInput);
1198
+ }
1199
+ else if (sighting === "discarded") {
1200
+ await addressService.upsertAddress(addressInput);
1201
+ }
1202
+ else {
1203
+ await addressService.upsertCorrespondentAddress(addressInput);
1204
+ }
1205
+ await addressService.upsertEnvelopeAddress({
1206
+ messageId,
1207
+ addressId,
1208
+ displayName,
1209
+ normalizedEmail,
1210
+ addressRole: role,
1211
+ addressOrder: order,
1212
+ });
1213
+ }
1214
+ }
1215
+ /**
1216
+ * The thread a synced message files under. A message keeps the thread it is
1217
+ * already stored in; a reply joins the thread its parent is stored in;
1218
+ * otherwise the thread is derived from the root `threadRootOf` names.
1219
+ */
1220
+ async threadFor(threadMessageService, messageId, accountId, accountConfigId, envelope, references) {
1221
+ const own = await threadMessageService.findByMessageId(accountConfigId, messageId);
1222
+ if (own)
1223
+ return own.threadId;
1224
+ const parent = parentOf(envelope, references);
1225
+ const parentRow = parent
1226
+ ? await threadMessageService.findByMessageId(accountConfigId, deriveMessageId(accountId, parent))
1227
+ : null;
1228
+ return (parentRow?.threadId ??
1229
+ deriveThreadId(accountConfigId, threadRootOf(envelope, references, messageId)));
1230
+ }
1231
+ /**
1232
+ * Joins the rows written on either side of a parent and its reply, after
1233
+ * this message's own row exists. Replies that arrived before this message,
1234
+ * and so were keyed on its own id, follow it into its thread; and a reply
1235
+ * whose parent was saved while it was being saved follows the parent, but
1236
+ * only from the thread it derived for itself — a thread it joined or was
1237
+ * already stored in is left as it is.
1238
+ *
1239
+ * Both checks run after the create, so of a parent and a reply saved
1240
+ * concurrently at least one sees the other's row: neither is left in a
1241
+ * thread of its own.
1242
+ */
1243
+ async joinThreads(threadMessageService, messageId, accountId, accountConfigId, envelope, references, threadId) {
1244
+ if (isValidMessageId(envelope.messageId)) {
1245
+ await threadMessageService.rekeyThread(accountConfigId, deriveThreadId(accountConfigId, wireMessageId(envelope.messageId)), threadId);
1246
+ }
1247
+ const parent = parentOf(envelope, references);
1248
+ if (!parent)
1249
+ return;
1250
+ const derived = deriveThreadId(accountConfigId, threadRootOf(envelope, references, messageId));
1251
+ if (threadId !== derived)
1252
+ return;
1253
+ const parentRow = await threadMessageService.findByMessageId(accountConfigId, deriveMessageId(accountId, parent));
1254
+ if (!parentRow || parentRow.threadId === threadId)
1255
+ return;
1256
+ await threadMessageService.rekeyThread(accountConfigId, threadId, parentRow.threadId);
1257
+ }
1258
+ /**
1259
+ * Create the ThreadMessage for a synced message, then join it to its
1260
+ * parent and replies.
1261
+ *
1262
+ * `category` is the value the caller already holds from the Message it just
1263
+ * wrote, so the row starts out agreeing with it rather than defaulting to
1264
+ * `uncategorized` behind an already-classified message.
1265
+ */
1266
+ async createThreadForMessage(threadMessageService, messageId, mailboxId, accountId, accountConfigId, uid, internalDate, sentDate, envelope, flags, category, references, hasAttachment = false) {
1267
+ const threadId = await this.threadFor(threadMessageService, messageId, accountId, accountConfigId, envelope, references);
1268
+ const isRead = flags.includes(MessageSystemFlag.Seen);
1269
+ // The server's \Flagged keyword is the star. Mail flagged in another
1270
+ // client must arrive starred, so carry it through on create rather than
1271
+ // defaulting every row to unstarred. Both comparisons go through the
1272
+ // generated members, which carry the wire spelling (reader#65) and are
1273
+ // the same values the flag-push markers are keyed by — one source of
1274
+ // truth for the wire flag and the record of it.
1275
+ const hasStars = flags.includes(MessageSystemFlag.Flagged);
1276
+ // Extract sender info. When the server could not parse the From address,
1277
+ // omit fromEmail rather than persist a fabricated string — a display name
1278
+ // may still be present and useful, so keep it. This is the sender label the
1279
+ // message list shows and the search index tokenizes, so it takes the same
1280
+ // guard as the address book (issue #826).
1281
+ const fromAddr = envelope.from?.[0];
1282
+ const fromEmail = isParseableEmailAddress(fromAddr)
1283
+ ? `${fromAddr?.mailbox}@${fromAddr?.host}`.toLowerCase()
1284
+ : undefined;
1285
+ const fromName = harvestedDisplayName(fromAddr) || undefined;
1286
+ // Calculate reference order (position in the thread chain)
1287
+ // references.length gives the position since References = [root, parent1, parent2, ...]
1288
+ const referenceOrder = references?.length ?? (envelope.inReplyTo ? 1 : 0);
1289
+ // Create ThreadMessage linking message to thread
1290
+ await threadMessageService
1291
+ .create({
1292
+ threadId,
1293
+ messageId,
1294
+ accountConfigId,
1295
+ mailboxId,
1296
+ uid,
1297
+ messageIdHeader: envelope.messageId,
1298
+ inReplyTo: envelope.inReplyTo,
1299
+ referenceOrder,
1300
+ fromEmail,
1301
+ fromName,
1302
+ subject: envelope.subject,
1303
+ internalDate,
1304
+ sentDate,
1305
+ isRead,
1306
+ isDeleted: false,
1307
+ hasAttachment,
1308
+ hasStars,
1309
+ star: hasStars ? StarColor.Yellow : StarColor.None,
1310
+ category,
1311
+ })
1312
+ .catch((error) => {
1313
+ // Ignore conflict errors (idempotent create)
1314
+ if (error?.name === "CreateFailedConflictError") {
1315
+ return;
1316
+ }
1317
+ throw error;
1318
+ });
1319
+ await this.joinThreads(threadMessageService, messageId, accountId, accountConfigId, envelope, references, threadId);
1320
+ }
1321
+ }
1322
+ /**
1323
+ * Translate the IMAP BODYSTRUCTURE for a single message into a list of
1324
+ * `BodyPartUpsertInput`s ready for `EnvelopeService.upsertBodyParts`.
1325
+ * Returns an empty list when the server didn't return BODYSTRUCTURE
1326
+ * (some unusual messages, or older test fixtures).
1327
+ */
1328
+ const buildBodyPartUpserts = (bodyStructure) => {
1329
+ if (!bodyStructure)
1330
+ return [];
1331
+ return walkMimeStructure(bodyStructure).map((part) => ({
1332
+ partPath: part.partPath,
1333
+ parentPartPath: part.parentPartPath,
1334
+ mediaType: part.mediaType,
1335
+ mediaSubtype: part.mediaSubtype,
1336
+ transferEncoding: part.transferEncoding,
1337
+ sizeOctets: part.sizeOctets,
1338
+ isMultipart: part.isMultipart,
1339
+ parameters: part.parameters,
1340
+ ...(part.contentId !== undefined ? { contentId: part.contentId } : {}),
1341
+ ...(part.contentDescription !== undefined
1342
+ ? { contentDescription: part.contentDescription }
1343
+ : {}),
1344
+ ...(part.lineCount !== undefined ? { lineCount: part.lineCount } : {}),
1345
+ ...(part.md5Hash !== undefined ? { md5Hash: part.md5Hash } : {}),
1346
+ ...(part.disposition !== undefined
1347
+ ? { disposition: part.disposition }
1348
+ : {}),
1349
+ ...(part.dispositionFilename !== undefined
1350
+ ? { dispositionFilename: part.dispositionFilename }
1351
+ : {}),
1352
+ ...(part.language !== undefined ? { language: part.language } : {}),
1353
+ ...(part.location !== undefined ? { location: part.location } : {}),
1354
+ ...(part.multipartSubtype !== undefined
1355
+ ? { multipartSubtype: part.multipartSubtype }
1356
+ : {}),
1357
+ }));
1358
+ };