@remit/mailbox-service 0.0.111 → 0.0.113

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 (298) 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} +19 -77
  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} +39 -63
  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 +126 -0
  78. package/dist/mailbox-queue.js +214 -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 +284 -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} +19 -50
  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 -101
  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 -73
  150. package/src/account-credentials.ts +0 -210
  151. package/src/adversarial-mime.e2e.test.ts +0 -307
  152. package/src/attribute-mapper.test.ts +0 -104
  153. package/src/attribute-mapper.ts +0 -148
  154. package/src/body-parse.test.ts +0 -55
  155. package/src/body-part-mapper.property.test.ts +0 -354
  156. package/src/body-part-mapper.ts +0 -360
  157. package/src/body-sync-auto-read-writeonce.test.ts +0 -219
  158. package/src/body-sync-calendar-suggestion.test.ts +0 -433
  159. package/src/body-sync-category-denormalize.test.ts +0 -352
  160. package/src/body-sync-category-override.test.ts +0 -501
  161. package/src/body-sync-category-writeonce.test.ts +0 -234
  162. package/src/body-sync-filter.test.ts +0 -237
  163. package/src/body-sync-message-gone.test.ts +0 -139
  164. package/src/body-sync-placement-flags.test.ts +0 -347
  165. package/src/body-sync-placement-writeonce.test.ts +0 -337
  166. package/src/body-sync-quarantine.test.ts +0 -276
  167. package/src/body-sync-queue.test.ts +0 -124
  168. package/src/body-sync-queue.ts +0 -131
  169. package/src/body-sync-resync.test.ts +0 -605
  170. package/src/body-sync-terminal.test.ts +0 -310
  171. package/src/body-sync-terminal.ts +0 -176
  172. package/src/body-sync-unclassifiable-once.test.ts +0 -260
  173. package/src/body-sync-unsubscribed-auto-read.test.ts +0 -176
  174. package/src/body-sync.ts +0 -1970
  175. package/src/calendar-parts.ts +0 -41
  176. package/src/classification-backfill.test.ts +0 -615
  177. package/src/classification-backfill.ts +0 -363
  178. package/src/classify-message.test.ts +0 -205
  179. package/src/classify-message.ts +0 -116
  180. package/src/connection-factory.ts +0 -102
  181. package/src/connection-test.ts +0 -208
  182. package/src/credentials.test.ts +0 -181
  183. package/src/external-move.test.ts +0 -369
  184. package/src/external-move.ts +0 -272
  185. package/src/filters/anchor-drift.ts +0 -55
  186. package/src/filters/filter-config.test.ts +0 -229
  187. package/src/filters/filter-config.ts +0 -69
  188. package/src/filters/list-id.test.ts +0 -50
  189. package/src/filters/match.test.ts +0 -429
  190. package/src/filters/match.ts +0 -274
  191. package/src/filters/message-embedder.test.ts +0 -39
  192. package/src/filters/message-embedder.ts +0 -33
  193. package/src/filters/pipeline.test.ts +0 -330
  194. package/src/filters/pipeline.ts +0 -284
  195. package/src/flag-push-terminal.test.ts +0 -209
  196. package/src/flag-push.test.ts +0 -408
  197. package/src/flag-push.ts +0 -198
  198. package/src/flag-queue-stars.test.ts +0 -171
  199. package/src/flag-queue.ts +0 -509
  200. package/src/folder-placement.ts +0 -56
  201. package/src/heuristics/authenticationResults.test.ts +0 -189
  202. package/src/heuristics/authenticationResults.ts +0 -212
  203. package/src/heuristics/classifyByHeaders.test.ts +0 -889
  204. package/src/heuristics/classifyByHeaders.ts +0 -467
  205. package/src/heuristics/classifyPlacement.test.ts +0 -366
  206. package/src/heuristics/classifyPlacement.ts +0 -211
  207. package/src/heuristics/classifyRealMail.test.ts +0 -242
  208. package/src/heuristics/machineSenders.test.ts +0 -101
  209. package/src/heuristics/senderMismatch.test.ts +0 -346
  210. package/src/heuristics/senderMismatch.ts +0 -259
  211. package/src/imapflow-connect-retry.test.ts +0 -134
  212. package/src/imapflow-connection.e2e.test.ts +0 -335
  213. package/src/imapflow-connection.integ.test.ts +0 -316
  214. package/src/imapflow-connection.test.ts +0 -469
  215. package/src/imapflow-connection.ts +0 -1475
  216. package/src/index.ts +0 -350
  217. package/src/list-id-backfill.test.ts +0 -573
  218. package/src/list-id-backfill.ts +0 -274
  219. package/src/mailbox-cursor-rebuild.test.ts +0 -171
  220. package/src/mailbox-cursor-rebuild.ts +0 -146
  221. package/src/mailbox-cursor.test.ts +0 -249
  222. package/src/mailbox-delete-intent.test.ts +0 -246
  223. package/src/mailbox-management.test.ts +0 -356
  224. package/src/mailbox-management.ts +0 -839
  225. package/src/mailbox-presence.test.ts +0 -72
  226. package/src/mailbox-queue.ts +0 -415
  227. package/src/mailbox-rename-intent.test.ts +0 -389
  228. package/src/mailbox-rename-subtree.test.ts +0 -664
  229. package/src/mailbox-sync.test.ts +0 -848
  230. package/src/mailbox-sync.ts +0 -724
  231. package/src/message-delete-junk-reconcile.test.ts +0 -117
  232. package/src/message-delete-no-trash.test.ts +0 -245
  233. package/src/message-delete-unsettled-placement.test.ts +0 -396
  234. package/src/message-move-copy.test.ts +0 -562
  235. package/src/message-move-empty-trash.test.ts +0 -326
  236. package/src/message-move-same-mailbox.test.ts +0 -139
  237. package/src/message-move-unsettled-placement.test.ts +0 -378
  238. package/src/message-move.ts +0 -1348
  239. package/src/message-parser.ts +0 -26
  240. package/src/message-presence.test.ts +0 -50
  241. package/src/message-sync-address-spoof.test.ts +0 -293
  242. package/src/message-sync-category.test.ts +0 -142
  243. package/src/message-sync-changedsince.test.ts +0 -840
  244. package/src/message-sync-flags.test.ts +0 -158
  245. package/src/message-sync-junk-harvest.test.ts +0 -244
  246. package/src/message-sync-quarantine.test.ts +0 -416
  247. package/src/message-sync-repoint.test.ts +0 -419
  248. package/src/message-sync.ts +0 -1963
  249. package/src/mime-walker.test.ts +0 -311
  250. package/src/mime-walker.ts +0 -305
  251. package/src/outbox-attachment-content.test.ts +0 -153
  252. package/src/outbox-attachment-content.ts +0 -104
  253. package/src/outbox-attachment-filename.test.ts +0 -132
  254. package/src/outbox-attachment-filename.ts +0 -76
  255. package/src/outbox-attachment.test.ts +0 -738
  256. package/src/outbox-attachment.ts +0 -536
  257. package/src/outbox-failed-recovery.test.ts +0 -370
  258. package/src/outbox-queue-settled-row.test.ts +0 -140
  259. package/src/outbox-queue.test.ts +0 -291
  260. package/src/outbox-queue.ts +0 -508
  261. package/src/pass-through-unit-of-work.ts +0 -17
  262. package/src/placement-move-terminal.test.ts +0 -300
  263. package/src/placement-move-terminal.ts +0 -148
  264. package/src/placement-move-unsettled.test.ts +0 -275
  265. package/src/placement-move.ts +0 -336
  266. package/src/placement-settled.test.ts +0 -147
  267. package/src/placement-transition-race.test.ts +0 -482
  268. package/src/quarantine.test.ts +0 -191
  269. package/src/quarantine.ts +0 -228
  270. package/src/snippet.test.ts +0 -213
  271. package/src/snippet.ts +0 -152
  272. package/src/spam-report.test.ts +0 -801
  273. package/src/spam-report.ts +0 -357
  274. package/src/stale-message-reconcile.test.ts +0 -82
  275. package/src/stale-message-reconcile.ts +0 -55
  276. package/src/sync-watermarks.test.ts +0 -409
  277. package/src/sync-watermarks.ts +0 -319
  278. package/src/system-flag-wire-format.test.ts +0 -77
  279. package/src/test-helpers/folder-roles.ts +0 -66
  280. package/src/test-helpers/isolated-mailbox.ts +0 -44
  281. package/src/test-helpers/mailfuzz-connection.ts +0 -27
  282. package/src/test-helpers/mime-tree-generator.ts +0 -461
  283. package/src/text/normalizer.test.ts +0 -223
  284. package/src/text/normalizer.ts +0 -140
  285. package/src/thread-message-rows.ts +0 -77
  286. package/src/types/mailparser-augment.d.ts +0 -15
  287. package/src/types/natural-porter-stemmer.d.ts +0 -50
  288. package/src/types/stopword.d.ts +0 -69
  289. package/src/types.ts +0 -371
  290. package/test/fixtures/mime-adversarial-e2e/01-octet-stream-pdf.eml +0 -21
  291. package/test/fixtures/mime-adversarial-e2e/02-nested-related-cid-image.eml +0 -31
  292. package/test/fixtures/mime-adversarial-e2e/03-calendar-invite.eml +0 -33
  293. package/test/fixtures/mime-adversarial-e2e/04-forwarded-rfc822-with-attachments.eml +0 -42
  294. package/test/fixtures/mime-adversarial-e2e/05-tnef-winmail.eml +0 -25
  295. package/test/fixtures/mime-adversarial-e2e/06-8bit-non-ascii.eml +0 -15
  296. package/test/fixtures/mime-adversarial-e2e/07-quoted-printable-soft-breaks.eml +0 -17
  297. package/test/fixtures/mime-adversarial-e2e/08-alternative-empty-text.eml +0 -18
  298. package/tsconfig.json +0 -8
@@ -1,1963 +0,0 @@
1
- import type {
2
- BodyPartUpsertInput,
3
- IAddressRepository,
4
- IEnvelopeRepository,
5
- IMailboxRepository,
6
- IMailboxSpecialUseRepository,
7
- IMessageFlagPushRepository,
8
- IMessageFlagRepository,
9
- IMessageRepository,
10
- IThreadMessageRepository,
11
- IUnitOfWork,
12
- MailboxItem,
13
- ThreadMessageItem,
14
- } from "@remit/data-ports";
15
- import { storedDisplayName } from "@remit/data-ports/display-name";
16
- import type { JunkRoleMailboxes } from "@remit/data-ports/folder-role";
17
- import {
18
- deriveAddressId,
19
- deriveBodyPartId,
20
- deriveCopyMessageId,
21
- deriveEnvelopeId,
22
- deriveMessageIdFromSource,
23
- deriveThreadId,
24
- isValidMessageId,
25
- } from "@remit/data-ports/id";
26
- import {
27
- AddressRole,
28
- MailboxCursorState,
29
- MessageKeywordFlag,
30
- MessageSystemFlag,
31
- StarColor,
32
- } from "@remit/domain-enums";
33
- import pMap from "p-map";
34
- import type { ManagedConnectionFactory } from "./connection-factory.js";
35
- import {
36
- confirmDepartures,
37
- type DepartureVerdicts,
38
- placementKey,
39
- sightingContestsPlacement,
40
- } from "./external-move.js";
41
- import { guardMailboxCursor, isCursorRebuildNeeded } from "./mailbox-cursor.js";
42
- import {
43
- type CursorRebuildRow,
44
- type CursorRebuildSnapshot,
45
- matchCursorRebuild,
46
- } from "./mailbox-cursor-rebuild.js";
47
- import { ROOT_PART_PATH, walkMimeStructure } from "./mime-walker.js";
48
- import { PassThroughUnitOfWork } from "./pass-through-unit-of-work.js";
49
- import type { QuarantineService } from "./quarantine.js";
50
- import { reconcileStaleMessage } from "./stale-message-reconcile.js";
51
- import {
52
- advanceChangeCursor,
53
- advanceUidWatermarks,
54
- type ChangeCursor,
55
- dropAppliedPrefix,
56
- formatChangeCursor,
57
- hasChangeCursor,
58
- orderByModseq,
59
- parseChangeCursor,
60
- parseModseq,
61
- } from "./sync-watermarks.js";
62
- import type {
63
- ImapAddress,
64
- ImapBodyStructure,
65
- ImapEnvelope,
66
- ImapMailboxStatus,
67
- ImapMessage,
68
- } from "./types.js";
69
-
70
- const MESSAGE_SAVE_CONCURRENCY = 10;
71
-
72
- // Some IMAP servers (e.g. Hostnet) emit these literal placeholders in the
73
- // ENVELOPE when they cannot parse a From header, instead of leaving the
74
- // address parts empty.
75
- const HOSTNET_MISSING_MAILBOX = "missing_mailbox";
76
- const HOSTNET_MISSING_DOMAIN = "missing_domain";
77
-
78
- /**
79
- * A From address is only usable when it looks like a real mailbox: both parts
80
- * present, neither is a known "could not parse" sentinel, and the host carries
81
- * an actual domain (at least one dot). Detect this structurally so a fabricated
82
- * string like `missing_mailbox@missing_domain` is never persisted as a sender.
83
- */
84
- export const isParseableEmailAddress = (
85
- address: ImapAddress | undefined,
86
- ): boolean => {
87
- if (!address) return false;
88
- const mailbox = address.mailbox?.trim();
89
- const host = address.host?.trim();
90
- if (!mailbox || !host) return false;
91
- if (mailbox === HOSTNET_MISSING_MAILBOX || host === HOSTNET_MISSING_DOMAIN) {
92
- return false;
93
- }
94
- return host.includes(".");
95
- };
96
-
97
- export type AddressSighting = "junk" | "discarded" | "correspondent";
98
-
99
- /** The account's Junk and Trash mailboxes, resolved once per batch. */
100
- export interface AccountFolderRoles {
101
- readonly junkMailboxId: string | null;
102
- readonly trashMailboxId: string | null;
103
- /**
104
- * The same two roles across every account under the config, for the
105
- * reconcile that follows a save into Junk. The address book is keyed by
106
- * config, so a predicate over an address weighs sightings from all of them —
107
- * resolving only this account withholds a sender on one and restores it on
108
- * the next.
109
- */
110
- readonly configJunkRoles: JunkRoleMailboxes;
111
- }
112
-
113
- /**
114
- * What a sighting of an address in this mailbox is worth. Junk withholds the
115
- * sender from autocomplete (#822), Trash decides nothing either way, and
116
- * anywhere else is a correspondent.
117
- *
118
- * Decided by mailbox IDENTITY against the account's resolved roles, not by the
119
- * folder's flag and name: a role belongs to exactly one folder (RFC 032), and
120
- * the folder it belongs to is whichever one the user appointed. Reading the
121
- * name here meant a second folder called `Spam` also withheld its senders,
122
- * while the folder the user actually appointed harvested every spammer in it as
123
- * a correspondent (#837).
124
- */
125
- export const addressSightingIn = (
126
- mailboxId: string,
127
- roles: AccountFolderRoles,
128
- ): AddressSighting => {
129
- if (roles.junkMailboxId !== null && mailboxId === roles.junkMailboxId) {
130
- return "junk";
131
- }
132
- if (roles.trashMailboxId !== null && mailboxId === roles.trashMailboxId) {
133
- return "discarded";
134
- }
135
- return "correspondent";
136
- };
137
-
138
- /**
139
- * The name an envelope carries for an address, as it should be stored: any
140
- * claim to be some other address removed (issue #826), the rest of the name
141
- * kept. Refused wherever a name lands — the address book, the envelope the
142
- * message header renders, and the sender label on the thread row.
143
- */
144
- export const harvestedDisplayName = (
145
- address: ImapAddress | undefined,
146
- ): string => {
147
- if (!address?.name) return "";
148
- return storedDisplayName(address.name, `${address.mailbox}@${address.host}`);
149
- };
150
-
151
- /**
152
- * Parse an external `Date:` header into an epoch-millisecond integer.
153
- *
154
- * The IMAP envelope `date` is a raw RFC 2822 header copied verbatim from the
155
- * message. It can be missing, malformed, or in a format `Date` cannot parse —
156
- * `new Date(raw).getTime()` then yields `NaN`. `NaN` is not a valid integer
157
- * and ElectroDB rejects it, which previously threw on the envelope upsert and
158
- * (because the batch aborted on the first rejection) stalled the whole mailbox.
159
- *
160
- * When the header is unparseable we fall back to `fallbackMs` — the IMAP
161
- * server's own INTERNALDATE receive time, always a valid integer. The raw
162
- * header is preserved separately in `dateRaw`, so nothing is lost.
163
- */
164
- export const parseHeaderDate = (
165
- raw: string | undefined,
166
- fallbackMs: number,
167
- ): { value: number; usedFallback: boolean } => {
168
- if (raw !== undefined && raw !== "") {
169
- const parsed = new Date(raw).getTime();
170
- if (Number.isFinite(parsed)) {
171
- return { value: parsed, usedFallback: false };
172
- }
173
- }
174
- return { value: fallbackMs, usedFallback: true };
175
- };
176
-
177
- /**
178
- * @deprecated Use ManagedConnectionFactory instead
179
- */
180
- export type ImapConnectionFactory = () => {
181
- connect(): Promise<void>;
182
- disconnect(): Promise<void>;
183
- };
184
-
185
- export interface SyncLogger {
186
- info(obj: Record<string, unknown>, msg: string): void;
187
- warn(obj: Record<string, unknown>, msg: string): void;
188
- error(obj: Record<string, unknown>, msg: string): void;
189
- }
190
-
191
- const noopLogger: SyncLogger = {
192
- info: () => {},
193
- warn: () => {},
194
- error: () => {},
195
- };
196
-
197
- export interface SyncedMessage {
198
- messageId: string;
199
- uid: number;
200
- }
201
-
202
- /**
203
- * Per-message save outcome. `owned` is true when the row was created by this
204
- * sync, already belongs to the current mailbox, or was re-pointed at it by this
205
- * sighting; false for a collision this sync declined to follow — a virtual-copy
206
- * folder, or a row whose own mutation has not settled.
207
- */
208
- interface SaveMessageResult extends SyncedMessage {
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;
219
- }
220
-
221
- /**
222
- * Wrapper outcome for a single message in the batch. A `failed` outcome means
223
- * the save threw (and was caught) — its UID must NOT advance the watermark, so
224
- * the message is re-fetched and retried on the next cycle. A `saved` outcome
225
- * with a `null` result is a UID this round finished with but created no row
226
- * for: a cross-mailbox collision, a change applied to an existing row, or a
227
- * message quarantined instead of applied (issue #72). Its watermark advances
228
- * either way, because there is nothing left to retry.
229
- */
230
- type BatchOutcome =
231
- | { kind: "saved"; uid: number; result: SaveMessageResult | null }
232
- | { kind: "failed"; uid: number };
233
-
234
- export interface SyncMessagesResult {
235
- syncedCount: number;
236
- syncedMessageIds: string[];
237
- syncedMessages: SyncedMessage[];
238
- hasMore: boolean;
239
- remainingCount: number;
240
- /**
241
- * The round had work to do and moved no cursor. Nothing throws on this
242
- * path — a message that cannot be applied is caught and held back — so
243
- * without this flag a mailbox that stops syncing looks exactly like one
244
- * with nothing to sync. Callers must surface it.
245
- */
246
- cursorStalled: boolean;
247
- }
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
-
265
- const emptySyncResult = (): SyncMessagesResult => ({
266
- syncedCount: 0,
267
- syncedMessageIds: [],
268
- syncedMessages: [],
269
- hasMore: false,
270
- remainingCount: 0,
271
- cursorStalled: false,
272
- });
273
-
274
- /**
275
- * Pick the UIDs a full-enumeration round should sync, newest first.
276
- *
277
- * 1. New messages: UIDs above the forward watermark.
278
- * 2. Backfill: UIDs below the lowest one synced so far.
279
- * 3. A mailbox with no watermarks at all syncs everything.
280
- */
281
- export const selectUidsToSync = (
282
- allUids: number[],
283
- lastSyncUid: number,
284
- highWaterMarkUid: number,
285
- ): number[] => {
286
- const newUids = allUids.filter((uid) => uid > highWaterMarkUid);
287
- const backfillUids =
288
- lastSyncUid > 1 ? allUids.filter((uid) => uid < lastSyncUid) : [];
289
-
290
- const isFreshSync = highWaterMarkUid === 0 && lastSyncUid === 0;
291
- const uidsToSync = isFreshSync ? [...allUids] : [...newUids, ...backfillUids];
292
-
293
- return uidsToSync.sort((a, b) => b - a);
294
- };
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
-
322
- export class MessageSyncService {
323
- private log: SyncLogger;
324
- private unitOfWork: IUnitOfWork;
325
-
326
- constructor(
327
- private connectionFactory: ManagedConnectionFactory,
328
- private mailboxService: IMailboxRepository,
329
- /**
330
- * The account’s folder-role map. Required, not optional: which folder
331
- * holds the Junk role decides whether a sighting withholds its sender
332
- * from autocomplete, and a construction without it would quietly harvest
333
- * spammers as correspondents.
334
- */
335
- private mailboxSpecialUseService: IMailboxSpecialUseRepository,
336
- private messageService: IMessageRepository,
337
- envelopeService: IEnvelopeRepository,
338
- addressService: IAddressRepository,
339
- private threadMessageService: IThreadMessageRepository,
340
- logger?: SyncLogger,
341
- unitOfWork?: IUnitOfWork,
342
- /**
343
- * Pending outbound flag-push markers (#1273). Supplied, an inbound
344
- * metadata change never overwrites a local flip that IMAP has not been
345
- * told about yet; omitted, the server always wins.
346
- */
347
- private flagPushMarkerService?: IMessageFlagPushRepository,
348
- /**
349
- * The canonical flag record. Supplied, an inbound metadata change lands
350
- * on the same record the outbound flip path reads, so a user's next
351
- * flip is never dismissed as redundant.
352
- */
353
- private messageFlagService?: IMessageFlagRepository,
354
- /**
355
- * The set of messages already set aside (issue #72). Message sync never
356
- * writes a record — only the body path can attribute a failure to the
357
- * message — but it reads the set so a uid the body path quarantined is
358
- * not re-fetched on every round.
359
- */
360
- private quarantineService?: QuarantineService,
361
- ) {
362
- this.log = logger ?? noopLogger;
363
- this.unitOfWork =
364
- unitOfWork ??
365
- new PassThroughUnitOfWork({
366
- message: messageService,
367
- envelope: envelopeService,
368
- address: addressService,
369
- threadMessage: threadMessageService,
370
- });
371
- }
372
-
373
- /**
374
- * Sync ONE batch of messages for a mailbox using newest-first strategy.
375
- *
376
- * Uses dual-watermark tracking:
377
- * - highWaterMarkUid: highest UID ever seen (detects new messages)
378
- * - lastSyncUid: lowest UID processed (tracks backfill progress)
379
- *
380
- * Returns hasMore=true if there are more messages to sync. The caller
381
- * should re-enqueue another sync event to continue processing.
382
- *
383
- * @param mailboxId - The database mailbox ID
384
- * @param accountId - The account ID (scopes message/thread identity)
385
- * @param accountConfigId - The account config ID (used for address linking)
386
- * @param batchSize - Number of messages to process per batch
387
- */
388
- async syncMessages(
389
- mailboxId: string,
390
- accountId: string,
391
- accountConfigId: string,
392
- batchSize = 50,
393
- ): Promise<SyncMessagesResult> {
394
- const mailbox = await this.mailboxService.get(accountId, mailboxId);
395
- const mailboxPath = mailbox.fullPath;
396
-
397
- // A mailbox whose cursor is already invalid (or a rebuild that crashed
398
- // mid-way, #1272) never falls through to the normal watermark-based sync
399
- // below — stored UIDs on that axis are not trustworthy. The rebuild is a
400
- // variant of sync, not a special wipe path, so it runs here under the
401
- // same mailbox lock the caller already holds.
402
- if (isCursorRebuildNeeded(mailbox.cursorState)) {
403
- return this.rebuildCursor(mailbox, accountId, accountConfigId, mailboxId);
404
- }
405
-
406
- const lastSyncUid = mailbox.lastSyncUid || 0;
407
- const highWaterMarkUid = mailbox.highWaterMarkUid || 0;
408
-
409
- const connection = this.connectionFactory.getConnection();
410
- const box = await connection.openBox(mailboxPath);
411
- const status = await connection.getMailboxStatus(mailboxPath);
412
-
413
- // Detection: the served UIDVALIDITY may have changed since it was last
414
- // stored, even though this mailbox was `normal` a moment ago. Trip the
415
- // cursor and pause — every stored watermark, the mod-sequence included,
416
- // is meaningless on the new axis, so nothing below may be acted on this
417
- // round (epic #1281 invariants 3 and 5). The rebuild that follows is
418
- // where the mod-sequence is reseeded from the new axis.
419
- const cursorCheck = await guardMailboxCursor(
420
- { mailboxService: this.mailboxService },
421
- accountId,
422
- mailbox,
423
- box.uidvalidity,
424
- );
425
- if (!cursorCheck.ok) {
426
- this.log.warn(
427
- { mailboxId, mailboxPath, cursorState: cursorCheck.state },
428
- "UIDVALIDITY changed; mailbox cursor tripped, pausing outbound sync this round",
429
- );
430
- return emptySyncResult();
431
- }
432
-
433
- const cursor = parseChangeCursor(mailbox.highestModseq);
434
- if (hasChangeCursor(cursor) && connection.supportsCondstore()) {
435
- return this.syncChangedSince({
436
- mailbox,
437
- accountId,
438
- accountConfigId,
439
- cursor,
440
- box,
441
- status,
442
- batchSize,
443
- });
444
- }
445
-
446
- // One read per round, not per message (issue #72). The list is small by
447
- // design — a growing one is a bug being reported, not a page to paginate.
448
- const quarantined = await this.quarantineService?.load(accountConfigId);
449
-
450
- const allUids = await connection.search(["ALL"]);
451
- const uids = selectUidsToSync(allUids, lastSyncUid, highWaterMarkUid);
452
- const unseenCount = status.unseen;
453
- const deletedCount = status.deletedCount;
454
-
455
- if (uids.length === 0) {
456
- // Nothing left to enumerate: the folder is fully covered on this
457
- // UIDVALIDITY axis, which is the one moment a mod-sequence watermark
458
- // can be seeded without hiding unsynced history behind it. From the
459
- // next round on, this mailbox takes the CHANGEDSINCE path above.
460
- await this.mailboxService.update(accountId, mailboxId, {
461
- lastMessageSyncAt: Date.now(),
462
- uidValidity: box.uidvalidity,
463
- messageCount: status.messages,
464
- highestModseq: status.highestModseq,
465
- unseenCount,
466
- deletedCount,
467
- });
468
-
469
- this.log.info(
470
- {
471
- mailboxId,
472
- mailboxPath,
473
- total: 0,
474
- messageCount: status.messages,
475
- unseenCount,
476
- highestModseq: status.highestModseq,
477
- },
478
- "No new messages to sync",
479
- );
480
- return emptySyncResult();
481
- }
482
-
483
- const totalBatches = Math.ceil(uids.length / batchSize);
484
- this.log.info(
485
- { mailboxId, mailboxPath, total: uids.length, batches: totalBatches },
486
- "Starting message sync batch (newest first)",
487
- );
488
-
489
- // Process only the first batch
490
- const batchUids = uids.slice(0, batchSize);
491
-
492
- // A quarantined UID is not fetched again, but it stays in `batchUids` so
493
- // the watermark still advances over it. Filtering it out of the selection
494
- // instead would hold the watermark below a message that is already
495
- // durably resolved, which is the stall by another route.
496
- const fetchUids = batchUids.filter(
497
- (uid) => !quarantined?.has(mailboxId, box.uidvalidity, uid),
498
- );
499
- const messages =
500
- fetchUids.length > 0 ? await this.fetchMessageBatch(fetchUids) : [];
501
-
502
- // A UID the round could not act on. Two shapes reach here and neither is
503
- // the message's fault: the FETCH returned no row at all (the connection
504
- // layer drops rows imapflow yields without a usable UID or INTERNALDATE,
505
- // #408, and a message can be expunged between the SEARCH and the FETCH),
506
- // or it returned a row carrying no ENVELOPE, which names no message and
507
- // is the same client-side glitch one field further in.
508
- //
509
- // They join `failedUids` rather than merely being left out of the batch.
510
- // Absence alone does not hold a watermark: `advanceUidWatermarks` takes
511
- // the MAX of what was applied, so a gap in the middle of a batch is
512
- // stepped straight over — [23, 22, 21] with 22 missing still advances to
513
- // 23 and loses 22 for good. A failure is the one thing a watermark is
514
- // built to stop below.
515
- const applicable = messages.filter(hasEnvelope);
516
- const unusableUids = batchUids.filter(
517
- (uid) =>
518
- !applicable.some((msg) => msg.uid === uid) &&
519
- !quarantined?.has(mailboxId, box.uidvalidity, uid),
520
- );
521
- if (unusableUids.length > 0) {
522
- this.log.warn(
523
- { mailboxId, mailboxPath, unusableUids },
524
- "FETCH returned no usable row for some requested UIDs; holding the watermark below them",
525
- );
526
- }
527
-
528
- // Process messages in parallel with concurrency limit. `stopOnError` stays
529
- // at its default — but each message is saved through `trySaveMessage`,
530
- // which catches its own error and reports a `failed` outcome instead of
531
- // rejecting. So one bad message can no longer abort the whole batch (the
532
- // poison pill that previously froze the mailbox, #817).
533
- const roles = await this.folderRolesFor(accountId, accountConfigId);
534
- const departures = await this.probeDepartures(
535
- mailbox,
536
- accountId,
537
- applicable,
538
- );
539
- const outcomes = await pMap(
540
- applicable,
541
- (msg) =>
542
- this.trySaveMessage(
543
- mailbox,
544
- accountId,
545
- accountConfigId,
546
- msg,
547
- roles,
548
- departures,
549
- ),
550
- { concurrency: MESSAGE_SAVE_CONCURRENCY },
551
- );
552
-
553
- // Body-sync set: only rows created or owned by THIS mailbox. A residual
554
- // cross-mailbox collision (same deterministic messageId already owned by a
555
- // different mailbox) must not push a foreign-owned messageId into
556
- // syncedMessageIds, or body-sync would FETCH against the wrong mailbox's UID.
557
- const ownedResults = outcomes.flatMap((o) =>
558
- o.kind === "saved" && o.result !== null && o.result.owned
559
- ? [o.result]
560
- : [],
561
- );
562
- const syncedMessages: SyncedMessage[] = ownedResults.map(
563
- ({ messageId, uid }) => ({ messageId, uid }),
564
- );
565
- const syncedMessageIds = syncedMessages.map((m) => m.messageId);
566
-
567
- // UIDs whose save threw. They must stay inside the next cycle's fetch
568
- // window, so the watermark may not advance past them (no silent loss).
569
- const saveFailedUids = outcomes.flatMap((o) =>
570
- o.kind === "failed" ? [o.uid] : [],
571
- );
572
- if (saveFailedUids.length > 0) {
573
- this.log.warn(
574
- { mailboxId, mailboxPath, failedUids: saveFailedUids },
575
- "Some messages failed to save; holding watermark below them for retry",
576
- );
577
- }
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
- ]);
590
-
591
- // Watermarks advance over every SUCCESSFULLY-consumed UID in the batch,
592
- // independent of ownership. `selectUidsToSync` reselects work purely by UID
593
- // vs watermark (there is no per-UID processed set), so a foreign-owned UID
594
- // that did not advance the watermark would be re-fetched every cycle
595
- // forever. The same Message-ID legitimately appears in several of one
596
- // account's mailboxes (Gmail All Mail + INBOX/labels), so cross-mailbox
597
- // conflicts are routine; excluding them from body-sync is correct, stalling
598
- // forward sync is not. Failures are what a watermark may never pass —
599
- // see `advanceUidWatermarks`.
600
- const { highWaterMarkUid: newHighWaterMark, lastSyncUid: newLastSyncUid } =
601
- advanceUidWatermarks({
602
- batchUids,
603
- failedUids,
604
- lastSyncUid,
605
- highWaterMarkUid,
606
- });
607
-
608
- const remainingCount = uids.length - batchUids.length;
609
- const hasMore = remainingCount > 0;
610
-
611
- // The mod-sequence watermark is seeded only by a round that leaves no
612
- // enumeration work behind and lost no message to a failed save. Seeding
613
- // it earlier would switch the mailbox to CHANGEDSINCE while UIDs it has
614
- // never fetched still sit below the watermark, and those messages would
615
- // never be discovered. A UID the FETCH returned nothing usable for is in
616
- // `failedUids` too: the round did not finish with it either.
617
- const enumerationComplete = !hasMore && failedUids.size === 0;
618
-
619
- await this.mailboxService.update(accountId, mailboxId, {
620
- lastSyncUid: newLastSyncUid,
621
- highWaterMarkUid: newHighWaterMark,
622
- lastMessageSyncAt: Date.now(),
623
- uidValidity: box.uidvalidity,
624
- messageCount: status.messages,
625
- ...(enumerationComplete ? { highestModseq: status.highestModseq } : {}),
626
- unseenCount,
627
- deletedCount,
628
- });
629
-
630
- // Same stall condition as the CHANGEDSINCE round, on the UID axis: work
631
- // selected, nothing moved, and the next round will select exactly the
632
- // same work. No error surfaces on its own — every failure here is caught
633
- // and held back — so this is the only signal that the mailbox is stuck.
634
- const stalled =
635
- newHighWaterMark === highWaterMarkUid && newLastSyncUid === lastSyncUid;
636
- if (stalled) {
637
- this.log.error(
638
- {
639
- alert: "message_sync_cursor_stalled",
640
- mailboxId,
641
- mailboxPath,
642
- accountId,
643
- pendingUids: uids.length,
644
- failedUids: [...failedUids],
645
- },
646
- "Message sync watermarks did not advance while UIDs were pending; this mailbox stops syncing until they do",
647
- );
648
- }
649
-
650
- this.log.info(
651
- {
652
- batch: 1,
653
- totalBatches,
654
- batchSize: messages.length,
655
- synced: syncedMessageIds.length,
656
- total: uids.length,
657
- remaining: remainingCount,
658
- hasMore,
659
- highWaterMarkUid: newHighWaterMark,
660
- lastSyncUid: newLastSyncUid,
661
- },
662
- "Batch complete",
663
- );
664
-
665
- return {
666
- syncedCount: syncedMessageIds.length,
667
- syncedMessageIds,
668
- syncedMessages,
669
- hasMore,
670
- remainingCount,
671
- cursorStalled: stalled,
672
- };
673
- }
674
-
675
- /**
676
- * Re-key a mailbox's stored UIDs against a new UIDVALIDITY axis (#1272).
677
- *
678
- * One envelope-level pass (UID + Message-ID + INTERNALDATE, no body
679
- * fetches — {@link matchCursorRebuild}) matched against the rows already
680
- * stored for this mailbox:
681
- * - Match → rewrite the row's UID mapping in place; bodies and threads are
682
- * untouched.
683
- * - Server message with no row → normal new-message sync (the same
684
- * `trySaveMessage` pipeline the regular batch sync uses).
685
- * - Row with no counterpart → expunged; reconcile via {@link
686
- * reconcileStaleMessage} (#1283 — the exact same "gone upstream" outcome
687
- * as a body-sync retry exhaustion finding a stale row).
688
- *
689
- * Idempotent by construction: re-entering mid-rebuild (crash recovery —
690
- * the mailbox was left `rebuilding`) simply redoes the same match/rewrite
691
- * pass, which converges on the same result. `cursorState` is stamped
692
- * `rebuilding` before any write and only cleared to `normal` after the
693
- * watermarks are rebuilt, so a crash anywhere in between leaves the
694
- * mailbox paused rather than falling back to the stale axis.
695
- */
696
- private async rebuildCursor(
697
- mailbox: MailboxItem,
698
- accountId: string,
699
- accountConfigId: string,
700
- mailboxId: string,
701
- ): Promise<SyncMessagesResult> {
702
- const mailboxPath = mailbox.fullPath;
703
-
704
- await this.mailboxService.update(accountId, mailboxId, {
705
- cursorState: MailboxCursorState.rebuilding,
706
- });
707
-
708
- const connection = this.connectionFactory.getConnection();
709
- const box = await connection.openBox(mailboxPath);
710
- // Read before the pass, never after: a message arriving while the
711
- // rebuild runs is absent from the snapshot below, and a HIGHESTMODSEQ
712
- // read afterwards would already sit above that arrival's mod-sequence —
713
- // seeding it would close the mailbox over a message it never stored.
714
- const status = await connection.getMailboxStatus(mailboxPath);
715
- const allUids = await connection.search(["ALL"]);
716
- const snapshots = await connection.fetchEnvelopeSnapshots(allUids);
717
- const serverSnapshots: CursorRebuildSnapshot[] = snapshots.map((s) => ({
718
- uid: s.uid,
719
- messageId: s.messageId,
720
- internalDate: s.internalDate.getTime(),
721
- }));
722
-
723
- const existingRows = await this.listExistingCursorRows(
724
- accountConfigId,
725
- mailboxId,
726
- );
727
-
728
- const { matched, newUids, staleMessageIds } = matchCursorRebuild(
729
- serverSnapshots,
730
- existingRows,
731
- );
732
-
733
- // Bounded concurrency (mirrors MESSAGE_SAVE_CONCURRENCY below) — a
734
- // sequential loop over a large mailbox's full match set risked the
735
- // Lambda timeout on its own, independent of the fetch-size question
736
- // (#1272 review, non-blocking finding).
737
- await pMap(
738
- matched,
739
- async ({ messageId, newUid, threadMessage }) => {
740
- await this.unitOfWork.transaction((repos) =>
741
- repos.message.updateUid(messageId, newUid, mailboxId),
742
- );
743
- // Rewrite the denormalized ThreadMessage.uid alongside Message.uid —
744
- // a normal move keeps both in sync (see buildThreadMessageMoveUpdate
745
- // in message-move.ts), and the rebuild must too, or a resumed
746
- // rebuild re-emits a no-op rewrite forever (listExistingCursorRows
747
- // reads uid from ThreadMessage) and any reader of the list
748
- // projection sees a stale UID.
749
- if (threadMessage) {
750
- await this.unitOfWork.transaction((repos) =>
751
- repos.threadMessage.update(
752
- threadMessage.accountConfigId,
753
- threadMessage.threadMessageId,
754
- { uid: newUid },
755
- {
756
- composites: {
757
- sentDate: threadMessage.sentDate,
758
- mailboxId: threadMessage.mailboxId,
759
- isRead: threadMessage.isRead,
760
- isDeleted: threadMessage.isDeleted,
761
- hasStars: threadMessage.hasStars,
762
- hasAttachment: threadMessage.hasAttachment,
763
- },
764
- },
765
- ),
766
- );
767
- }
768
- },
769
- { concurrency: MESSAGE_SAVE_CONCURRENCY },
770
- );
771
-
772
- await pMap(
773
- staleMessageIds,
774
- (messageId) =>
775
- this.unitOfWork.transaction((repos) =>
776
- reconcileStaleMessage(
777
- {
778
- messageService: repos.message,
779
- threadMessageService: repos.threadMessage,
780
- },
781
- accountConfigId,
782
- messageId,
783
- ),
784
- ),
785
- { concurrency: MESSAGE_SAVE_CONCURRENCY },
786
- );
787
-
788
- const newMessages =
789
- newUids.length > 0 ? await this.fetchMessageBatch(newUids) : [];
790
- const applicable = newMessages.filter(hasEnvelope);
791
- const roles = await this.folderRolesFor(accountId, accountConfigId);
792
- const departures = await this.probeDepartures(
793
- mailbox,
794
- accountId,
795
- applicable,
796
- );
797
- const outcomes = await pMap(
798
- applicable,
799
- (msg) =>
800
- this.trySaveMessage(
801
- mailbox,
802
- accountId,
803
- accountConfigId,
804
- msg,
805
- roles,
806
- departures,
807
- ),
808
- { concurrency: MESSAGE_SAVE_CONCURRENCY },
809
- );
810
- const syncedMessages: SyncedMessage[] = outcomes.flatMap((o) =>
811
- o.kind === "saved" && o.result !== null && o.result.owned
812
- ? [{ messageId: o.result.messageId, uid: o.result.uid }]
813
- : [],
814
- );
815
-
816
- const serverUids = serverSnapshots.map((s) => s.uid);
817
-
818
- // A UID this pass could not account for, in any of the three ways it can
819
- // go missing: the snapshot FETCH never returned a row for it, the
820
- // message FETCH never returned one, or the row it returned carried no
821
- // ENVELOPE. None of the three is the message's fault and none can be
822
- // quarantined, so each has to keep the UID selectable.
823
- //
824
- // This matters more here than on the enumeration path, not less. The
825
- // covered region is computed from `serverUids` rather than from what was
826
- // applied, so a UID missing anywhere inside its span is silently inside
827
- // it; and this round seeds the mod-sequence and returns the mailbox to
828
- // `normal`, so the next round takes CHANGEDSINCE, which never
829
- // enumerates. A UID lost here is lost for good.
830
- const savedUids = new Set(applicable.map((msg) => msg.uid));
831
- const snapshotUids = new Set(serverUids);
832
- const unusableUids = [
833
- ...allUids.filter((uid) => !snapshotUids.has(uid)),
834
- ...newUids.filter((uid) => !savedUids.has(uid)),
835
- ];
836
- if (unusableUids.length > 0) {
837
- this.log.warn(
838
- { mailboxId, mailboxPath, unusableUids },
839
- "Cursor rebuild could not account for some UIDs; holding the watermark below them",
840
- );
841
- }
842
-
843
- // A new message whose save threw must stay selectable, so the forward
844
- // watermark stops below it and every UID above it is re-enumerated next
845
- // round. The mod-sequence seed is withheld entirely in that case: the
846
- // old value is meaningless on this axis (RFC 7162 requires discarding
847
- // it on a UIDVALIDITY change) and the new one would sit above the
848
- // message that failed, so the mailbox goes back to enumeration until a
849
- // clean round seeds it.
850
- const failedUids = new Set([
851
- ...outcomes.flatMap((o) => (o.kind === "failed" ? [o.uid] : [])),
852
- ...unusableUids,
853
- ...unresolvedSightingUids(outcomes),
854
- ]);
855
- const lowestFailure = failedUids.size
856
- ? Math.min(...failedUids)
857
- : Number.POSITIVE_INFINITY;
858
- const coveredUids = serverUids.filter((uid) => uid < lowestFailure);
859
-
860
- await this.mailboxService.update(accountId, mailboxId, {
861
- cursorState: MailboxCursorState.normal,
862
- uidValidity: box.uidvalidity,
863
- highWaterMarkUid: coveredUids.length > 0 ? Math.max(...coveredUids) : 0,
864
- lastSyncUid: serverUids.length > 0 ? Math.min(...serverUids) : 0,
865
- highestModseq: failedUids.size === 0 ? status.highestModseq : "0",
866
- lastMessageSyncAt: Date.now(),
867
- messageCount: status.messages,
868
- unseenCount: status.unseen,
869
- deletedCount: status.deletedCount,
870
- });
871
-
872
- this.log.info(
873
- {
874
- mailboxId,
875
- mailboxPath,
876
- matched: matched.length,
877
- newMessages: syncedMessages.length,
878
- stale: staleMessageIds.length,
879
- },
880
- "Mailbox cursor rebuild complete; returned to normal",
881
- );
882
-
883
- return {
884
- syncedCount: syncedMessages.length,
885
- syncedMessageIds: syncedMessages.map((m) => m.messageId),
886
- syncedMessages,
887
- hasMore: false,
888
- remainingCount: 0,
889
- cursorStalled: false,
890
- };
891
- }
892
-
893
- /**
894
- * Page through every ThreadMessage row for this mailbox, projecting just
895
- * the fields {@link matchCursorRebuild} needs. ThreadMessage (not
896
- * Message) is the read source: it already denormalizes `messageIdHeader`
897
- * and `internalDate` alongside `uid`, so this needs no per-row Envelope
898
- * lookup.
899
- */
900
- private async listExistingCursorRows(
901
- accountConfigId: string,
902
- mailboxId: string,
903
- ): Promise<CursorRebuildRow[]> {
904
- const rows: CursorRebuildRow[] = [];
905
- let continuationToken: string | undefined;
906
-
907
- do {
908
- const result = await this.threadMessageService.listByMailbox(
909
- accountConfigId,
910
- mailboxId,
911
- {
912
- continuationToken,
913
- attributes: [
914
- "messageId",
915
- "messageIdHeader",
916
- "internalDate",
917
- "uid",
918
- "accountConfigId",
919
- "threadMessageId",
920
- "sentDate",
921
- "mailboxId",
922
- "isRead",
923
- "isDeleted",
924
- "hasStars",
925
- "hasAttachment",
926
- ],
927
- },
928
- );
929
- for (const row of result.items) {
930
- rows.push({
931
- messageId: row.messageId,
932
- messageIdHeader: row.messageIdHeader ?? "",
933
- internalDate: row.internalDate,
934
- uid: row.uid,
935
- // Carried so a match can also rewrite ThreadMessage.uid (a normal
936
- // move keeps both in sync — the rebuild must too, or #1271's
937
- // push-time UID resolution can read a stale projection).
938
- threadMessage: {
939
- accountConfigId: row.accountConfigId,
940
- threadMessageId: row.threadMessageId,
941
- sentDate: row.sentDate,
942
- mailboxId: row.mailboxId,
943
- isRead: row.isRead,
944
- isDeleted: row.isDeleted,
945
- hasStars: row.hasStars,
946
- hasAttachment: row.hasAttachment,
947
- },
948
- });
949
- }
950
- continuationToken = result.continuationToken;
951
- } while (continuationToken);
952
-
953
- return rows;
954
- }
955
-
956
- /**
957
- * One CHANGEDSINCE round (issue #20).
958
- *
959
- * A single `FETCH ... (CHANGEDSINCE <modseq>)` over the whole UID space
960
- * returns both the messages that arrived and the messages whose metadata
961
- * changed since the stored watermark, so a flag flipped on another client
962
- * is picked up without enumerating the folder. Rows already present take
963
- * the metadata path — the envelope, body structure and addresses are
964
- * immutable, so re-writing them for a read-state change would be pure
965
- * waste; everything else is a new message and takes the normal save
966
- * pipeline.
967
- *
968
- * Expunges are invisible here (CONDSTORE without QRESYNC never reports
969
- * them, RFC 7162 Section 3.1.2.1) — they remain the reconcile path's job.
970
- */
971
- private async syncChangedSince(params: {
972
- mailbox: MailboxItem;
973
- accountId: string;
974
- accountConfigId: string;
975
- cursor: ChangeCursor;
976
- box: { uidvalidity: number };
977
- status: ImapMailboxStatus;
978
- batchSize: number;
979
- }): Promise<SyncMessagesResult> {
980
- const {
981
- mailbox,
982
- accountId,
983
- accountConfigId,
984
- cursor,
985
- box,
986
- status,
987
- batchSize,
988
- } = params;
989
- const mailboxId = mailbox.mailboxId;
990
- const mailboxPath = mailbox.fullPath;
991
-
992
- const connection = this.connectionFactory.getConnection();
993
- // Ask from the last COMPLETE mod-sequence, so a group left part-applied
994
- // by an earlier round is served again in full; its applied members are
995
- // then dropped by position, without a lookup.
996
- const changed = await connection.fetchMessagesChangedSince(cursor.modseq);
997
-
998
- const ordered = dropAppliedPrefix(orderByModseq(changed), cursor);
999
- const batch = ordered.slice(0, batchSize);
1000
-
1001
- // A quarantined UID stays in `batch`, so the cursor still advances over
1002
- // it; only the work of re-applying it is skipped.
1003
- const quarantined = await this.quarantineService?.load(accountConfigId);
1004
- const applicable = batch
1005
- .filter((msg) => !quarantined?.has(mailboxId, box.uidvalidity, msg.uid))
1006
- .filter(hasEnvelope);
1007
-
1008
- // A change row carrying no ENVELOPE holds the cursor and is retried; it
1009
- // is never set aside. On this path the message is usually one already
1010
- // stored — it demonstrably had a sender, a date and a Message-ID when it
1011
- // was first saved — so an envelope-less row is the FETCH glitching
1012
- // (#408), not the message being defective. Quarantining it would filter
1013
- // that UID out of every later round, stopping its flag sync until a
1014
- // purge, and tell the user a message they can open and read "arrived
1015
- // without a sender". A transient glitch heals on the retry; a persistent
1016
- // one trips the stalled-cursor alert, which is what that alert is for.
1017
- const unusableUids = batch.flatMap((msg) =>
1018
- msg.envelope === undefined &&
1019
- !quarantined?.has(mailboxId, box.uidvalidity, msg.uid)
1020
- ? [msg.uid]
1021
- : [],
1022
- );
1023
- if (unusableUids.length > 0) {
1024
- this.log.warn(
1025
- { mailboxId, mailboxPath, unusableUids },
1026
- "Change rows carried no ENVELOPE; holding the sync cursor below them",
1027
- );
1028
- }
1029
-
1030
- const roles = await this.folderRolesFor(accountId, accountConfigId);
1031
- const departures = await this.probeDepartures(
1032
- mailbox,
1033
- accountId,
1034
- applicable,
1035
- );
1036
- const outcomes = await pMap(
1037
- applicable,
1038
- (msg) =>
1039
- this.tryApplyChange(
1040
- mailbox,
1041
- accountId,
1042
- accountConfigId,
1043
- msg,
1044
- roles,
1045
- departures,
1046
- ),
1047
- { concurrency: MESSAGE_SAVE_CONCURRENCY },
1048
- );
1049
-
1050
- const saveFailedUids = outcomes.flatMap((o) =>
1051
- o.kind === "failed" ? [o.uid] : [],
1052
- );
1053
- if (saveFailedUids.length > 0) {
1054
- this.log.warn(
1055
- { mailboxId, mailboxPath, failedUids: saveFailedUids },
1056
- "Some changes failed to apply; holding the sync cursor below them for retry",
1057
- );
1058
- }
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
- ]);
1071
-
1072
- // Body sync only concerns messages this round created — a metadata
1073
- // change has no new body to fetch.
1074
- const syncedMessages: SyncedMessage[] = outcomes.flatMap((o) =>
1075
- o.kind === "saved" && o.result !== null && o.result.owned
1076
- ? [{ messageId: o.result.messageId, uid: o.result.uid }]
1077
- : [],
1078
- );
1079
- const syncedMessageIds = syncedMessages.map((m) => m.messageId);
1080
-
1081
- const { cursor: nextCursor, hasMore } = advanceChangeCursor({
1082
- cursor,
1083
- serverModseq: parseModseq(status.highestModseq),
1084
- ordered,
1085
- batch,
1086
- failedUids,
1087
- });
1088
- const highestModseq = formatChangeCursor(nextCursor);
1089
-
1090
- // The UID watermark obeys the same clamp as the enumeration path even
1091
- // though the cursor governs retries here: a mailbox that later falls
1092
- // back to enumeration must not find a failed UID already behind its
1093
- // forward watermark.
1094
- const { highWaterMarkUid: newHighWaterMark } = advanceUidWatermarks({
1095
- batchUids: batch.map((msg) => msg.uid),
1096
- failedUids,
1097
- lastSyncUid: mailbox.lastSyncUid || 0,
1098
- highWaterMarkUid: mailbox.highWaterMarkUid || 0,
1099
- });
1100
-
1101
- await this.mailboxService.update(accountId, mailboxId, {
1102
- highestModseq,
1103
- highWaterMarkUid: newHighWaterMark,
1104
- lastMessageSyncAt: Date.now(),
1105
- uidValidity: box.uidvalidity,
1106
- messageCount: status.messages,
1107
- unseenCount: status.unseen,
1108
- deletedCount: status.deletedCount,
1109
- });
1110
-
1111
- // A round that had work to do and moved nothing is stalled: the same
1112
- // fetch will return the same set forever, and the set only grows as the
1113
- // mailbox keeps changing. Nothing above this call fails, so nothing else
1114
- // would ever notice — the queue message is acked either way.
1115
- const stalled =
1116
- ordered.length > 0 && highestModseq === mailbox.highestModseq;
1117
- if (stalled) {
1118
- this.log.error(
1119
- {
1120
- alert: "message_sync_cursor_stalled",
1121
- mailboxId,
1122
- mailboxPath,
1123
- accountId,
1124
- cursor: highestModseq,
1125
- pendingChanges: ordered.length,
1126
- failedUids: [...failedUids],
1127
- },
1128
- "Message sync cursor did not advance while changes were pending; this mailbox stops seeing changes until it does",
1129
- );
1130
- }
1131
-
1132
- this.log.info(
1133
- {
1134
- mailboxId,
1135
- mailboxPath,
1136
- changed: ordered.length,
1137
- applied: batch.length,
1138
- created: syncedMessageIds.length,
1139
- fromCursor: formatChangeCursor(cursor),
1140
- cursor: highestModseq,
1141
- hasMore,
1142
- },
1143
- "CHANGEDSINCE round complete",
1144
- );
1145
-
1146
- return {
1147
- syncedCount: syncedMessageIds.length,
1148
- syncedMessageIds,
1149
- syncedMessages,
1150
- hasMore,
1151
- remainingCount: ordered.length - batch.length,
1152
- cursorStalled: stalled,
1153
- };
1154
- }
1155
-
1156
- /**
1157
- * Apply one message from a CHANGEDSINCE result without ever rejecting —
1158
- * same contract as {@link trySaveMessage}, so a single unapplicable change
1159
- * holds the watermark back instead of failing the round.
1160
- */
1161
- private async tryApplyChange(
1162
- mailbox: MailboxItem,
1163
- accountId: string,
1164
- accountConfigId: string,
1165
- msg: ImapMessage,
1166
- roles: AccountFolderRoles,
1167
- departures: DepartureVerdicts,
1168
- ): Promise<BatchOutcome> {
1169
- const mailboxId = mailbox.mailboxId;
1170
- return this.applyChange(
1171
- mailbox,
1172
- accountId,
1173
- accountConfigId,
1174
- msg,
1175
- roles,
1176
- departures,
1177
- )
1178
- .then((result): BatchOutcome => ({ kind: "saved", uid: msg.uid, result }))
1179
- .catch((error): BatchOutcome => {
1180
- this.log.warn(
1181
- {
1182
- mailboxId,
1183
- uid: msg.uid,
1184
- messageId: msg.envelope?.messageId,
1185
- error: error instanceof Error ? error.message : String(error),
1186
- },
1187
- "Failed to apply message change; will retry on next sync",
1188
- );
1189
- return { kind: "failed", uid: msg.uid };
1190
- });
1191
- }
1192
-
1193
- private async applyChange(
1194
- mailbox: MailboxItem,
1195
- accountId: string,
1196
- accountConfigId: string,
1197
- msg: ImapMessage,
1198
- roles: AccountFolderRoles,
1199
- departures: DepartureVerdicts,
1200
- ): Promise<SaveMessageResult | null> {
1201
- if (!hasEnvelope(msg)) return null;
1202
-
1203
- const messageId = messageIdForSighting(accountId, mailbox.mailboxId, msg);
1204
-
1205
- const existing = await this.threadMessageService.findByMessageId(
1206
- accountConfigId,
1207
- messageId,
1208
- );
1209
- if (!existing) {
1210
- return this.saveMessage(
1211
- mailbox,
1212
- accountId,
1213
- accountConfigId,
1214
- msg,
1215
- roles,
1216
- departures,
1217
- );
1218
- }
1219
-
1220
- await this.applyServerFlags(existing, msg.flags);
1221
- return null;
1222
- }
1223
-
1224
- /**
1225
- * Bring a stored row's read and star state, and the $Junk/$NotJunk
1226
- * keywords on the canonical MessageFlag record, in line with the
1227
- * server's flags.
1228
- *
1229
- * A field with a pending outbound push is left alone: the user flipped it
1230
- * locally, IMAP has not been told yet, and the server's answer is
1231
- * therefore known-stale for that field. Writing it back would revert the
1232
- * flip in front of the user and then push the reverted value.
1233
- */
1234
- private async applyServerFlags(
1235
- existing: ThreadMessageItem,
1236
- flags: string[],
1237
- ): Promise<void> {
1238
- const isRead = flags.includes(MessageSystemFlag.Seen);
1239
- const hasStars = flags.includes(MessageSystemFlag.Flagged);
1240
-
1241
- const updates: {
1242
- isRead?: boolean;
1243
- hasStars?: boolean;
1244
- star?: (typeof StarColor)[keyof typeof StarColor];
1245
- } = {};
1246
- if (
1247
- existing.isRead !== isRead &&
1248
- !(await this.hasPendingPush(existing.messageId, MessageSystemFlag.Seen))
1249
- ) {
1250
- updates.isRead = isRead;
1251
- }
1252
- if (
1253
- existing.hasStars !== hasStars &&
1254
- !(await this.hasPendingPush(
1255
- existing.messageId,
1256
- MessageSystemFlag.Flagged,
1257
- ))
1258
- ) {
1259
- updates.hasStars = hasStars;
1260
- // `hasStars` is the boolean of record and `star` its presentation
1261
- // colour; the two may never disagree (#58). A star cleared upstream
1262
- // loses its colour; one set upstream takes the standard colour unless
1263
- // the row already carries a real one the user chose.
1264
- if (!hasStars) {
1265
- updates.star = StarColor.None;
1266
- } else if (
1267
- existing.star === undefined ||
1268
- existing.star === StarColor.None
1269
- ) {
1270
- updates.star = StarColor.Yellow;
1271
- }
1272
- }
1273
-
1274
- await this.mirrorKeywordFlag(
1275
- existing.messageId,
1276
- MessageKeywordFlag.Junk,
1277
- flags,
1278
- );
1279
- await this.mirrorKeywordFlag(
1280
- existing.messageId,
1281
- MessageKeywordFlag.NotJunk,
1282
- flags,
1283
- );
1284
-
1285
- if (Object.keys(updates).length === 0) return;
1286
-
1287
- // MessageFlag is the canonical flag record — `FlagQueueService` reads it
1288
- // to decide whether a user's flip is redundant, and the API answers
1289
- // read/starred from it. Writing only the denormalized row would leave
1290
- // the two disagreeing, and the next local flip would be dismissed as
1291
- // already-in-state: a click that does nothing.
1292
- //
1293
- // Canonical record first, projection second — the same order the local
1294
- // flip path uses. A crash between the two leaves the pair inconsistent
1295
- // exactly as a crashed local flip would, and the round's watermark has
1296
- // not moved, so the next round re-fetches the message and re-applies
1297
- // both writes (each is idempotent).
1298
- if (updates.isRead !== undefined) {
1299
- await this.setMessageFlag(
1300
- existing.messageId,
1301
- MessageSystemFlag.Seen,
1302
- updates.isRead,
1303
- );
1304
- }
1305
- if (updates.hasStars !== undefined) {
1306
- await this.setMessageFlag(
1307
- existing.messageId,
1308
- MessageSystemFlag.Flagged,
1309
- updates.hasStars,
1310
- );
1311
- }
1312
-
1313
- await this.threadMessageService.update(
1314
- existing.accountConfigId,
1315
- existing.threadMessageId,
1316
- updates,
1317
- {
1318
- // The CURRENT values of every sort-key attribute: ElectroDB uses
1319
- // them for the conditional check on the existing row and to
1320
- // recompute the new keys. Passing the new value here would fail
1321
- // that check and silently drop the update (see FlagQueueService).
1322
- composites: {
1323
- sentDate: existing.sentDate,
1324
- mailboxId: existing.mailboxId,
1325
- isRead: existing.isRead,
1326
- isDeleted: existing.isDeleted,
1327
- hasStars: existing.hasStars,
1328
- hasAttachment: existing.hasAttachment,
1329
- },
1330
- },
1331
- );
1332
-
1333
- this.log.info(
1334
- {
1335
- messageId: existing.messageId,
1336
- threadMessageId: existing.threadMessageId,
1337
- ...updates,
1338
- },
1339
- "Applied server flag state from CHANGEDSINCE",
1340
- );
1341
- }
1342
-
1343
- /**
1344
- * Set or clear one flag on the canonical record. Both repository calls are
1345
- * idempotent, so a re-applied change is a no-op rather than a conflict.
1346
- */
1347
- private async setMessageFlag(
1348
- messageId: string,
1349
- flagName: string,
1350
- present: boolean,
1351
- ): Promise<void> {
1352
- if (!this.messageFlagService) return;
1353
- if (present) {
1354
- await this.messageFlagService.addFlag(messageId, flagName);
1355
- return;
1356
- }
1357
- await this.messageFlagService.removeFlag(messageId, flagName);
1358
- }
1359
-
1360
- private async hasPendingPush(
1361
- messageId: string,
1362
- flagName: string,
1363
- ): Promise<boolean> {
1364
- if (!this.flagPushMarkerService) return false;
1365
- const marker = await this.flagPushMarkerService.find(messageId, flagName);
1366
- return marker !== null;
1367
- }
1368
-
1369
- /**
1370
- * Mirror one RFC 5788 keyword ($Junk / $NotJunk) from the server into the
1371
- * canonical MessageFlag record, guarded the same way as the system flags:
1372
- * a pending local push wins over the server's known-stale answer.
1373
- *
1374
- * This mailbox is never remit-only — Apple Mail or another IMAP client is
1375
- * routinely connected too — so the keyword is message state, nothing
1376
- * more. It never reaches the Address model: reading another client's
1377
- * keyword as a standing rule about the sender is the exact fight this
1378
- * mirror must not pick.
1379
- */
1380
- private async mirrorKeywordFlag(
1381
- messageId: string,
1382
- flagName: string,
1383
- serverFlags: string[],
1384
- ): Promise<void> {
1385
- if (!this.messageFlagService) return;
1386
- if (await this.hasPendingPush(messageId, flagName)) return;
1387
-
1388
- const present = serverFlags.includes(flagName);
1389
- const hasFlag = await this.messageFlagService.hasFlag(messageId, flagName);
1390
- if (hasFlag === present) return;
1391
-
1392
- await this.setMessageFlag(messageId, flagName, present);
1393
- }
1394
-
1395
- /**
1396
- * Fetch a batch of messages using the managed connection.
1397
- * Assumes mailbox is already open from fetchUidsToSync.
1398
- */
1399
- private async fetchMessageBatch(uids: number[]): Promise<ImapMessage[]> {
1400
- const connection = this.connectionFactory.getConnection();
1401
- return await connection.fetchMessages(uids);
1402
- }
1403
-
1404
- /**
1405
- * Save a single message without ever rejecting. Any error is caught and
1406
- * reported as a `failed` outcome so it cannot abort the surrounding `pMap`
1407
- * batch — the failed UID is held back from the watermark and retried next
1408
- * cycle. This is the guardrail that stops a single unsaveable message from
1409
- * permanently freezing the mailbox (#817).
1410
- */
1411
- /**
1412
- * Which folders hold Junk and Trash for this account, resolved once per
1413
- * batch rather than per message: the lookup reads an appointment row and
1414
- * the account’s mailbox list, and every message in a batch shares both.
1415
- */
1416
- private async folderRolesFor(
1417
- accountId: string,
1418
- accountConfigId: string,
1419
- ): Promise<AccountFolderRoles> {
1420
- const [junk, trash, configJunkRoles] = await Promise.all([
1421
- this.mailboxSpecialUseService.findJunkMailbox(accountId),
1422
- this.mailboxSpecialUseService.findTrashMailbox(accountId),
1423
- this.mailboxSpecialUseService.resolveJunkRolesForConfig(accountConfigId),
1424
- ]);
1425
- return {
1426
- junkMailboxId: junk?.mailboxId ?? null,
1427
- trashMailboxId: trash?.mailboxId ?? null,
1428
- configJunkRoles,
1429
- };
1430
- }
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
-
1464
- private async trySaveMessage(
1465
- mailbox: MailboxItem,
1466
- accountId: string,
1467
- accountConfigId: string,
1468
- msg: ImapMessage,
1469
- roles: AccountFolderRoles,
1470
- departures: DepartureVerdicts,
1471
- ): Promise<BatchOutcome> {
1472
- const mailboxId = mailbox.mailboxId;
1473
- return this.saveMessage(
1474
- mailbox,
1475
- accountId,
1476
- accountConfigId,
1477
- msg,
1478
- roles,
1479
- departures,
1480
- )
1481
- .then((result): BatchOutcome => ({ kind: "saved", uid: msg.uid, result }))
1482
- .catch((error): BatchOutcome => {
1483
- this.log.warn(
1484
- {
1485
- mailboxId,
1486
- uid: msg.uid,
1487
- messageId: msg.envelope?.messageId,
1488
- error: error instanceof Error ? error.message : String(error),
1489
- },
1490
- "Failed to save message; will retry on next sync",
1491
- );
1492
- return { kind: "failed", uid: msg.uid };
1493
- });
1494
- }
1495
-
1496
- private async saveMessage(
1497
- mailbox: MailboxItem,
1498
- accountId: string,
1499
- accountConfigId: string,
1500
- msg: ImapMessage,
1501
- roles: AccountFolderRoles,
1502
- departures: DepartureVerdicts,
1503
- ): Promise<SaveMessageResult | null> {
1504
- if (!hasEnvelope(msg)) return null;
1505
-
1506
- const mailboxId = mailbox.mailboxId;
1507
- const sighting = addressSightingIn(mailboxId, roles);
1508
-
1509
- // Store envelope to preserve narrowing in closures
1510
- const envelope = msg.envelope;
1511
-
1512
- const messageId = messageIdForSighting(accountId, mailboxId, msg);
1513
- const envelopeId = deriveEnvelopeId(messageId);
1514
- const rootBodyPartId = deriveBodyPartId(messageId, ROOT_PART_PATH);
1515
-
1516
- const internalDateMs = msg.internalDate.getTime();
1517
- const { value: sentDate, usedFallback: dateFellBack } = parseHeaderDate(
1518
- envelope.date,
1519
- internalDateMs,
1520
- );
1521
- if (dateFellBack) {
1522
- this.log.warn(
1523
- {
1524
- mailboxId,
1525
- messageId,
1526
- uid: msg.uid,
1527
- dateRaw: envelope.date,
1528
- },
1529
- "Unparseable Date header; fell back to IMAP internalDate",
1530
- );
1531
- }
1532
-
1533
- // Prepare all address save operations
1534
- const addressOps: Array<{
1535
- addresses: ImapAddress[] | undefined;
1536
- role: (typeof AddressRole)[keyof typeof AddressRole];
1537
- }> = [
1538
- { addresses: envelope.from, role: AddressRole.From },
1539
- { addresses: envelope.sender, role: AddressRole.Sender },
1540
- { addresses: envelope.replyTo, role: AddressRole.ReplyTo },
1541
- { addresses: envelope.to, role: AddressRole.To },
1542
- { addresses: envelope.cc, role: AddressRole.Cc },
1543
- { addresses: envelope.bcc, role: AddressRole.Bcc },
1544
- ];
1545
-
1546
- const bodyParts = buildBodyPartUpserts(msg.bodyStructure);
1547
- const hasAttachment = bodyParts.some(
1548
- (p) => !p.isMultipart && p.disposition === "attachment",
1549
- );
1550
-
1551
- // Ownership of this messageId by the current mailbox: the row was created
1552
- // by this call, or an existing row already belongs to this mailbox. A
1553
- // conflict whose stored row points at a different mailbox is foreign-owned
1554
- // and must not feed this mailbox's watermark / body-sync (#634).
1555
- let owned = false;
1556
- let unresolvedSighting = false;
1557
-
1558
- // One unit of work for the whole message: on Postgres these repos are
1559
- // transaction-bound, so the Envelope, addresses, Message, BodyParts and
1560
- // ThreadMessage — and the transactional-outbox rows the Message write
1561
- // appends — all commit together. A throw anywhere rolls the whole set
1562
- // back, so a failed save never strands a Message without its Envelope
1563
- // (#1072). Writes run in sequence: a single transaction serialises on one
1564
- // connection, and it lets the Envelope land before the Message, with the
1565
- // ThreadMessage written last so the list path never anchors on a
1566
- // ThreadMessage whose Message does not yet exist (#1209).
1567
- await this.unitOfWork.transaction(async (repos) => {
1568
- await repos.envelope.upsertEnvelope({
1569
- envelopeId,
1570
- messageId,
1571
- dateValue: sentDate,
1572
- dateRaw: envelope.date,
1573
- subject: envelope.subject,
1574
- messageIdValue: envelope.messageId,
1575
- });
1576
-
1577
- for (const { addresses, role } of addressOps) {
1578
- await this.saveAddresses(
1579
- repos.address,
1580
- messageId,
1581
- accountConfigId,
1582
- addresses,
1583
- role,
1584
- sighting,
1585
- );
1586
- }
1587
-
1588
- // IMAP returns BODYSTRUCTURE in the same FETCH that returns the
1589
- // envelope, so persisting BodyParts here is "free" — no extra round-trip.
1590
- if (bodyParts.length > 0) {
1591
- await repos.envelope.upsertBodyParts(messageId, bodyParts);
1592
- }
1593
-
1594
- const { item, created } = await repos.message.upsertWithStatus({
1595
- messageId,
1596
- mailboxId,
1597
- uid: msg.uid,
1598
- sequenceNumber: msg.seq,
1599
- rfc822Size: msg.size ?? 0,
1600
- internalDate: msg.internalDate.getTime(),
1601
- envelopeId,
1602
- rootBodyPartId,
1603
- });
1604
-
1605
- // The server has just named this folder and this UID for a message the
1606
- // database holds under another folder. `upsertWithStatus` left the row
1607
- // alone, so the pointer is repaired here — mailbox and UID together,
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);
1622
- const repointed =
1623
- contested &&
1624
- departures.departed.has(verdict) &&
1625
- !(await this.holdsCopyOf(repos.message, messageId, mailboxId));
1626
- if (repointed) {
1627
- await repos.message.updateUid(messageId, msg.uid, mailboxId);
1628
- await this.repointThreadMessage(
1629
- repos.threadMessage,
1630
- accountConfigId,
1631
- messageId,
1632
- mailboxId,
1633
- msg.uid,
1634
- sighting === "discarded",
1635
- );
1636
- }
1637
-
1638
- owned = created || item.mailboxId === mailboxId || repointed;
1639
-
1640
- // Withholding reads the pointer, so it is re-derived whenever the
1641
- // pointer could have changed the answer: a save into Junk, and a
1642
- // re-point in either direction. The reconcile both applies and lifts,
1643
- // so a sender whose only mail was moved out of Junk is restored by the
1644
- // same call that would have withheld them (#859).
1645
- if (sighting === "junk" || repointed) {
1646
- await repos.address.reconcileJunkOnlyForMessage(
1647
- messageId,
1648
- roles.configJunkRoles,
1649
- );
1650
- }
1651
-
1652
- await this.createThreadForMessage(
1653
- repos.threadMessage,
1654
- messageId,
1655
- mailboxId,
1656
- accountConfigId,
1657
- msg.uid,
1658
- msg.internalDate.getTime(),
1659
- sentDate,
1660
- envelope,
1661
- msg.flags,
1662
- // The category of the Message this same save just wrote. A new
1663
- // message is `uncategorized` until body-sync classifies it; a row
1664
- // created for a Message that is ALREADY classified takes the decided
1665
- // value rather than sitting stale forever behind a body-sync pass that
1666
- // will skip it. That happens on thread-root drift — the same message
1667
- // re-saved under different `References` mints a second threadId and so
1668
- // a second row — not on a second mailbox, which resolves to the row
1669
- // that already exists (issue #320).
1670
- item.category,
1671
- msg.references,
1672
- hasAttachment,
1673
- );
1674
- });
1675
-
1676
- return { messageId, uid: msg.uid, owned, unresolvedSighting };
1677
- }
1678
-
1679
- /**
1680
- * Whether this folder already holds a copy the user made of this message
1681
- * (#75). A copy is a second row under its own derived id, so the sighting is
1682
- * that copy standing where it was put — the original has not moved, and
1683
- * following it would take the mail out of the folder the user copied it from.
1684
- */
1685
- private async holdsCopyOf(
1686
- messageService: IMessageRepository,
1687
- messageId: string,
1688
- mailboxId: string,
1689
- ): Promise<boolean> {
1690
- const rows = await messageService.get([
1691
- deriveCopyMessageId(messageId, mailboxId),
1692
- ]);
1693
- return rows.length > 0;
1694
- }
1695
-
1696
- /**
1697
- * Follow a re-pointed Message with the ThreadMessage row that renders it.
1698
- * The listing and every folder count read that row, so leaving it behind
1699
- * would show the message in the folder it just left and hide it from the one
1700
- * it landed in. `isDeleted` follows the same rule the move path uses — true
1701
- * in Trash, false anywhere else — so a message another client rescues out of
1702
- * Trash stops being filtered out of listings.
1703
- */
1704
- private async repointThreadMessage(
1705
- threadMessageService: IThreadMessageRepository,
1706
- accountConfigId: string,
1707
- messageId: string,
1708
- mailboxId: string,
1709
- uid: number,
1710
- isDeleted: boolean,
1711
- ): Promise<void> {
1712
- const row = await threadMessageService.findByMessageId(
1713
- accountConfigId,
1714
- messageId,
1715
- );
1716
- if (row === null) return;
1717
- if (
1718
- row.mailboxId === mailboxId &&
1719
- row.uid === uid &&
1720
- row.isDeleted === isDeleted
1721
- ) {
1722
- return;
1723
- }
1724
-
1725
- await threadMessageService.update(
1726
- row.accountConfigId,
1727
- row.threadMessageId,
1728
- { mailboxId, uid, isDeleted },
1729
- {
1730
- composites: {
1731
- mailboxId: row.mailboxId,
1732
- sentDate: row.sentDate,
1733
- isRead: row.isRead,
1734
- isDeleted: row.isDeleted,
1735
- hasStars: row.hasStars,
1736
- hasAttachment: row.hasAttachment,
1737
- },
1738
- },
1739
- );
1740
- }
1741
-
1742
- private async saveAddresses(
1743
- addressService: IAddressRepository,
1744
- messageId: string,
1745
- accountConfigId: string,
1746
- addresses: ImapAddress[] | undefined,
1747
- role: (typeof AddressRole)[keyof typeof AddressRole],
1748
- sighting: AddressSighting,
1749
- ) {
1750
- if (!addresses) return;
1751
-
1752
- // Pre-compute address data with order indices, filtering valid addresses
1753
- const addressData: Array<{
1754
- localPart: string;
1755
- domain: string;
1756
- displayName: string;
1757
- order: number;
1758
- }> = [];
1759
-
1760
- // An unusable address is dropped and the message is written anyway, and
1761
- // that stays deliberate under the quarantine rules (issue #72). What is
1762
- // lost is one envelope address, not the message: it is stored, listed and
1763
- // readable, and its body is untouched. Setting the whole message aside
1764
- // over a malformed From would take readable mail out of the mailbox to
1765
- // protect a display name.
1766
- for (let i = 0; i < addresses.length; i++) {
1767
- const addr = addresses[i];
1768
- if (!isParseableEmailAddress(addr)) continue;
1769
- addressData.push({
1770
- localPart: addr.mailbox,
1771
- domain: addr.host,
1772
- displayName: harvestedDisplayName(addr),
1773
- order: i,
1774
- });
1775
- }
1776
-
1777
- for (const { localPart, domain, displayName, order } of addressData) {
1778
- const normalizedEmail = `${localPart}@${domain}`.toLowerCase();
1779
- const normalizedCompound =
1780
- `${displayName.toLowerCase()} ${normalizedEmail}`.trim();
1781
-
1782
- const addressId = deriveAddressId(accountConfigId, normalizedEmail);
1783
-
1784
- const addressInput = {
1785
- addressId,
1786
- accountConfigId,
1787
- localPart,
1788
- domain,
1789
- normalizedEmail,
1790
- normalizedCompound,
1791
- displayName,
1792
- };
1793
- if (sighting === "junk") {
1794
- await addressService.upsertJunkAddress(addressInput);
1795
- } else if (sighting === "discarded") {
1796
- await addressService.upsertAddress(addressInput);
1797
- } else {
1798
- await addressService.upsertCorrespondentAddress(addressInput);
1799
- }
1800
-
1801
- await addressService.upsertEnvelopeAddress({
1802
- messageId,
1803
- addressId,
1804
- displayName,
1805
- normalizedEmail,
1806
- addressRole: role,
1807
- addressOrder: order,
1808
- });
1809
- }
1810
- }
1811
-
1812
- /**
1813
- * Create or update Thread and ThreadMessage for a synced message.
1814
- *
1815
- * Thread ID derivation (RFC 2822 compliant):
1816
- * 1. If References header exists, use the FIRST entry as thread root
1817
- * (References format: <root> <parent1> ... <direct-parent>)
1818
- * 2. Fall back to In-Reply-To if no References
1819
- * 3. Fall back to Message-ID (this message is a thread root)
1820
- *
1821
- * This ensures proper threading even when messages arrive out of order.
1822
- *
1823
- * `category` is the value the caller already holds from the Message it just
1824
- * wrote, so the row starts out agreeing with it rather than defaulting to
1825
- * `uncategorized` behind an already-classified message.
1826
- */
1827
- private async createThreadForMessage(
1828
- threadMessageService: IThreadMessageRepository,
1829
- messageId: string,
1830
- mailboxId: string,
1831
- accountConfigId: string,
1832
- uid: number,
1833
- internalDate: number,
1834
- sentDate: number,
1835
- envelope: ImapEnvelope,
1836
- flags: string[],
1837
- category: ThreadMessageItem["category"],
1838
- references?: string[],
1839
- hasAttachment = false,
1840
- ): Promise<void> {
1841
- // Determine the thread root Message-ID
1842
- let rootMessageIdHeader: string;
1843
-
1844
- if (references && references.length > 0) {
1845
- // References header exists - first entry is the thread root (RFC 2822)
1846
- rootMessageIdHeader = references[0];
1847
- } else if (envelope.inReplyTo) {
1848
- // No References, but has In-Reply-To - use as thread root
1849
- // (This is a reply to a single message, which becomes the root)
1850
- rootMessageIdHeader = envelope.inReplyTo;
1851
- } else if (isValidMessageId(envelope.messageId)) {
1852
- // No References, no In-Reply-To - this message is a thread root
1853
- rootMessageIdHeader = envelope.messageId;
1854
- } else {
1855
- // No usable header (missing, empty, or a "<>" delivery-failure
1856
- // placeholder). Fall back to the always-present internal messageId so
1857
- // this message becomes a standalone thread-of-one. Distinct headerless
1858
- // messages keep distinct ids, so they never collide into one bogus
1859
- // thread, and every persisted Message gets exactly one ThreadMessage.
1860
- rootMessageIdHeader = messageId;
1861
- }
1862
-
1863
- // Derive threadId from the root Message-ID (deterministic)
1864
- const threadId = deriveThreadId(accountConfigId, rootMessageIdHeader);
1865
-
1866
- const isRead = flags.includes(MessageSystemFlag.Seen);
1867
-
1868
- // The server's \Flagged keyword is the star. Mail flagged in another
1869
- // client must arrive starred, so carry it through on create rather than
1870
- // defaulting every row to unstarred. Both comparisons go through the
1871
- // generated members, which carry the wire spelling (reader#65) and are
1872
- // the same values the flag-push markers are keyed by — one source of
1873
- // truth for the wire flag and the record of it.
1874
- const hasStars = flags.includes(MessageSystemFlag.Flagged);
1875
-
1876
- // Extract sender info. When the server could not parse the From address,
1877
- // omit fromEmail rather than persist a fabricated string — a display name
1878
- // may still be present and useful, so keep it. This is the sender label the
1879
- // message list shows and the search index tokenizes, so it takes the same
1880
- // guard as the address book (issue #826).
1881
- const fromAddr = envelope.from?.[0];
1882
- const fromEmail = isParseableEmailAddress(fromAddr)
1883
- ? `${fromAddr?.mailbox}@${fromAddr?.host}`.toLowerCase()
1884
- : undefined;
1885
- const fromName = harvestedDisplayName(fromAddr) || undefined;
1886
-
1887
- // Calculate reference order (position in the thread chain)
1888
- // references.length gives the position since References = [root, parent1, parent2, ...]
1889
- const referenceOrder = references?.length ?? (envelope.inReplyTo ? 1 : 0);
1890
-
1891
- // Create ThreadMessage linking message to thread
1892
- await threadMessageService
1893
- .create({
1894
- threadId,
1895
- messageId,
1896
- accountConfigId,
1897
- mailboxId,
1898
- uid,
1899
- messageIdHeader: envelope.messageId,
1900
- inReplyTo: envelope.inReplyTo,
1901
- referenceOrder,
1902
- fromEmail,
1903
- fromName,
1904
- subject: envelope.subject,
1905
- internalDate,
1906
- sentDate,
1907
- isRead,
1908
- isDeleted: false,
1909
- hasAttachment,
1910
- hasStars,
1911
- star: hasStars ? StarColor.Yellow : StarColor.None,
1912
- category,
1913
- })
1914
- .catch((error: unknown) => {
1915
- // Ignore conflict errors (idempotent create)
1916
- if (
1917
- (error as { name?: string })?.name === "CreateFailedConflictError"
1918
- ) {
1919
- return;
1920
- }
1921
- throw error;
1922
- });
1923
- }
1924
- }
1925
-
1926
- /**
1927
- * Translate the IMAP BODYSTRUCTURE for a single message into a list of
1928
- * `BodyPartUpsertInput`s ready for `EnvelopeService.upsertBodyParts`.
1929
- * Returns an empty list when the server didn't return BODYSTRUCTURE
1930
- * (some unusual messages, or older test fixtures).
1931
- */
1932
- const buildBodyPartUpserts = (
1933
- bodyStructure: ImapBodyStructure | undefined,
1934
- ): BodyPartUpsertInput[] => {
1935
- if (!bodyStructure) return [];
1936
- return walkMimeStructure(bodyStructure).map((part) => ({
1937
- partPath: part.partPath,
1938
- parentPartPath: part.parentPartPath,
1939
- mediaType: part.mediaType,
1940
- mediaSubtype: part.mediaSubtype,
1941
- transferEncoding: part.transferEncoding,
1942
- sizeOctets: part.sizeOctets,
1943
- isMultipart: part.isMultipart,
1944
- parameters: part.parameters,
1945
- ...(part.contentId !== undefined ? { contentId: part.contentId } : {}),
1946
- ...(part.contentDescription !== undefined
1947
- ? { contentDescription: part.contentDescription }
1948
- : {}),
1949
- ...(part.lineCount !== undefined ? { lineCount: part.lineCount } : {}),
1950
- ...(part.md5Hash !== undefined ? { md5Hash: part.md5Hash } : {}),
1951
- ...(part.disposition !== undefined
1952
- ? { disposition: part.disposition }
1953
- : {}),
1954
- ...(part.dispositionFilename !== undefined
1955
- ? { dispositionFilename: part.dispositionFilename }
1956
- : {}),
1957
- ...(part.language !== undefined ? { language: part.language } : {}),
1958
- ...(part.location !== undefined ? { location: part.location } : {}),
1959
- ...(part.multipartSubtype !== undefined
1960
- ? { multipartSubtype: part.multipartSubtype }
1961
- : {}),
1962
- }));
1963
- };