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