@rapidmx/activesync-plugin 1.0.0-beta.2 → 1.0.0-beta.3

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 (201) hide show
  1. package/README.md +4 -4
  2. package/dist/lib/BaseDeviceSyncStateRoute.js +32 -0
  3. package/dist/lib/BaseDeviceSyncStateRoute.js.map +1 -1
  4. package/dist/lib/BaseEasRoute.js +91 -15
  5. package/dist/lib/BaseEasRoute.js.map +1 -1
  6. package/dist/lib/EasAuditLog.js +56 -0
  7. package/dist/lib/EasAuditLog.js.map +1 -0
  8. package/dist/lib/EasCollectionLease.js +199 -0
  9. package/dist/lib/EasCollectionLease.js.map +1 -0
  10. package/dist/lib/EasCollectionStore.js +102 -0
  11. package/dist/lib/EasCollectionStore.js.map +1 -0
  12. package/dist/lib/EasCollectionSync.js +294 -0
  13. package/dist/lib/EasCollectionSync.js.map +1 -0
  14. package/dist/lib/EasSyncKeyUtils.js +125 -62
  15. package/dist/lib/EasSyncKeyUtils.js.map +1 -1
  16. package/dist/lib/MessageMoveRules.js +100 -0
  17. package/dist/lib/MessageMoveRules.js.map +1 -0
  18. package/dist/lib/MimeHeaderUtils.js +395 -0
  19. package/dist/lib/MimeHeaderUtils.js.map +1 -0
  20. package/dist/lib/RegexPatternUtils.js +30 -0
  21. package/dist/lib/RegexPatternUtils.js.map +1 -0
  22. package/dist/lib/RestapiCompat.js +41 -0
  23. package/dist/lib/RestapiCompat.js.map +1 -0
  24. package/dist/lib/adapters/CalendarSyncAdapter.js +142 -33
  25. package/dist/lib/adapters/CalendarSyncAdapter.js.map +1 -1
  26. package/dist/lib/adapters/EmailSyncAdapter.js +114 -27
  27. package/dist/lib/adapters/EmailSyncAdapter.js.map +1 -1
  28. package/dist/lib/adapters/mongo/EmailSyncAdapterMongo.js +2 -1
  29. package/dist/lib/adapters/mongo/EmailSyncAdapterMongo.js.map +1 -1
  30. package/dist/lib/adapters/sql/EmailSyncAdapterSQL.js +2 -1
  31. package/dist/lib/adapters/sql/EmailSyncAdapterSQL.js.map +1 -1
  32. package/dist/lib/codec/WbxmlDecoder.js +86 -34
  33. package/dist/lib/codec/WbxmlDecoder.js.map +1 -1
  34. package/dist/lib/codec/WbxmlEncoder.js +103 -22
  35. package/dist/lib/codec/WbxmlEncoder.js.map +1 -1
  36. package/dist/lib/commands/ComposeMailCommand.js +105 -31
  37. package/dist/lib/commands/ComposeMailCommand.js.map +1 -1
  38. package/dist/lib/commands/FolderSyncCommand.js +67 -27
  39. package/dist/lib/commands/FolderSyncCommand.js.map +1 -1
  40. package/dist/lib/commands/GetItemEstimateCommand.js +80 -59
  41. package/dist/lib/commands/GetItemEstimateCommand.js.map +1 -1
  42. package/dist/lib/commands/ItemOperationsCommand.js +219 -56
  43. package/dist/lib/commands/ItemOperationsCommand.js.map +1 -1
  44. package/dist/lib/commands/MeetingResponseCommand.js +212 -53
  45. package/dist/lib/commands/MeetingResponseCommand.js.map +1 -1
  46. package/dist/lib/commands/MoveItemsCommand.js +54 -21
  47. package/dist/lib/commands/MoveItemsCommand.js.map +1 -1
  48. package/dist/lib/commands/PingCommand.js +288 -56
  49. package/dist/lib/commands/PingCommand.js.map +1 -1
  50. package/dist/lib/commands/ProvisionCommand.js +34 -17
  51. package/dist/lib/commands/ProvisionCommand.js.map +1 -1
  52. package/dist/lib/commands/ResolveRecipientsCommand.js +43 -5
  53. package/dist/lib/commands/ResolveRecipientsCommand.js.map +1 -1
  54. package/dist/lib/commands/SearchCommand.js +92 -17
  55. package/dist/lib/commands/SearchCommand.js.map +1 -1
  56. package/dist/lib/commands/SettingsCommand.js +2 -1
  57. package/dist/lib/commands/SettingsCommand.js.map +1 -1
  58. package/dist/lib/commands/SmartForwardCommand.js +2 -1
  59. package/dist/lib/commands/SmartForwardCommand.js.map +1 -1
  60. package/dist/lib/commands/SmartReplyCommand.js +2 -1
  61. package/dist/lib/commands/SmartReplyCommand.js.map +1 -1
  62. package/dist/lib/commands/SyncCommand.js +481 -244
  63. package/dist/lib/commands/SyncCommand.js.map +1 -1
  64. package/dist/lib/commands/mongo/GetItemEstimateCommandMongo.js +6 -1
  65. package/dist/lib/commands/mongo/GetItemEstimateCommandMongo.js.map +1 -1
  66. package/dist/lib/commands/mongo/ItemOperationsCommandMongo.js +3 -1
  67. package/dist/lib/commands/mongo/ItemOperationsCommandMongo.js.map +1 -1
  68. package/dist/lib/commands/mongo/MeetingResponseCommandMongo.js +2 -1
  69. package/dist/lib/commands/mongo/MeetingResponseCommandMongo.js.map +1 -1
  70. package/dist/lib/commands/mongo/PingCommandMongo.js +23 -0
  71. package/dist/lib/commands/mongo/PingCommandMongo.js.map +1 -0
  72. package/dist/lib/commands/mongo/SearchCommandMongo.js +3 -1
  73. package/dist/lib/commands/mongo/SearchCommandMongo.js.map +1 -1
  74. package/dist/lib/commands/mongo/SendMailCommandMongo.js +2 -1
  75. package/dist/lib/commands/mongo/SendMailCommandMongo.js.map +1 -1
  76. package/dist/lib/commands/mongo/SmartForwardCommandMongo.js +2 -1
  77. package/dist/lib/commands/mongo/SmartForwardCommandMongo.js.map +1 -1
  78. package/dist/lib/commands/mongo/SmartReplyCommandMongo.js +2 -1
  79. package/dist/lib/commands/mongo/SmartReplyCommandMongo.js.map +1 -1
  80. package/dist/lib/commands/mongo/SyncCommandMongo.js +7 -1
  81. package/dist/lib/commands/mongo/SyncCommandMongo.js.map +1 -1
  82. package/dist/lib/commands/mongo/index.js +1 -0
  83. package/dist/lib/commands/mongo/index.js.map +1 -1
  84. package/dist/lib/commands/sql/GetItemEstimateCommandSQL.js +6 -1
  85. package/dist/lib/commands/sql/GetItemEstimateCommandSQL.js.map +1 -1
  86. package/dist/lib/commands/sql/ItemOperationsCommandSQL.js +3 -1
  87. package/dist/lib/commands/sql/ItemOperationsCommandSQL.js.map +1 -1
  88. package/dist/lib/commands/sql/MeetingResponseCommandSQL.js +2 -1
  89. package/dist/lib/commands/sql/MeetingResponseCommandSQL.js.map +1 -1
  90. package/dist/lib/commands/sql/PingCommandSQL.js +23 -0
  91. package/dist/lib/commands/sql/PingCommandSQL.js.map +1 -0
  92. package/dist/lib/commands/sql/SearchCommandSQL.js +3 -1
  93. package/dist/lib/commands/sql/SearchCommandSQL.js.map +1 -1
  94. package/dist/lib/commands/sql/SendMailCommandSQL.js +2 -1
  95. package/dist/lib/commands/sql/SendMailCommandSQL.js.map +1 -1
  96. package/dist/lib/commands/sql/SmartForwardCommandSQL.js +2 -1
  97. package/dist/lib/commands/sql/SmartForwardCommandSQL.js.map +1 -1
  98. package/dist/lib/commands/sql/SmartReplyCommandSQL.js +2 -1
  99. package/dist/lib/commands/sql/SmartReplyCommandSQL.js.map +1 -1
  100. package/dist/lib/commands/sql/SyncCommandSQL.js +7 -1
  101. package/dist/lib/commands/sql/SyncCommandSQL.js.map +1 -1
  102. package/dist/lib/commands/sql/index.js +1 -0
  103. package/dist/lib/commands/sql/index.js.map +1 -1
  104. package/dist/lib/index.js +5 -0
  105. package/dist/lib/index.js.map +1 -1
  106. package/dist/lib/jobs/EasDeviceStateCleanupJob.js +44 -2
  107. package/dist/lib/jobs/EasDeviceStateCleanupJob.js.map +1 -1
  108. package/dist/lib/jobs/mongo/EasDeviceStateCleanupJobMongo.js +4 -0
  109. package/dist/lib/jobs/mongo/EasDeviceStateCleanupJobMongo.js.map +1 -1
  110. package/dist/lib/jobs/sql/EasDeviceStateCleanupJobSQL.js +9 -0
  111. package/dist/lib/jobs/sql/EasDeviceStateCleanupJobSQL.js.map +1 -1
  112. package/dist/lib/models/EasCollectionChunk.js +2 -0
  113. package/dist/lib/models/EasCollectionChunk.js.map +1 -0
  114. package/dist/lib/models/EasCollectionState.js +2 -0
  115. package/dist/lib/models/EasCollectionState.js.map +1 -0
  116. package/dist/lib/models/mongo/DeviceSyncStateMongo.js +7 -0
  117. package/dist/lib/models/mongo/DeviceSyncStateMongo.js.map +1 -1
  118. package/dist/lib/models/mongo/EasCollectionChunkMongo.js +83 -0
  119. package/dist/lib/models/mongo/EasCollectionChunkMongo.js.map +1 -0
  120. package/dist/lib/models/mongo/EasCollectionStateMongo.js +162 -0
  121. package/dist/lib/models/mongo/EasCollectionStateMongo.js.map +1 -0
  122. package/dist/lib/models/sql/DeviceSyncStateSQL.js +7 -0
  123. package/dist/lib/models/sql/DeviceSyncStateSQL.js.map +1 -1
  124. package/dist/lib/models/sql/EasCollectionChunkSQL.js +83 -0
  125. package/dist/lib/models/sql/EasCollectionChunkSQL.js.map +1 -0
  126. package/dist/lib/models/sql/EasCollectionStateSQL.js +162 -0
  127. package/dist/lib/models/sql/EasCollectionStateSQL.js.map +1 -0
  128. package/dist/lib/mongo/EasRouteMongo.js +2 -2
  129. package/dist/lib/mongo/EasRouteMongo.js.map +1 -1
  130. package/dist/lib/mongo.js +2 -0
  131. package/dist/lib/mongo.js.map +1 -1
  132. package/dist/lib/sql/EasRouteSQL.js +2 -2
  133. package/dist/lib/sql/EasRouteSQL.js.map +1 -1
  134. package/dist/lib/sql.js +2 -0
  135. package/dist/lib/sql.js.map +1 -1
  136. package/dist/types/BaseDeviceSyncStateRoute.d.ts +10 -0
  137. package/dist/types/BaseEasRoute.d.ts +15 -4
  138. package/dist/types/EasAuditLog.d.ts +47 -0
  139. package/dist/types/EasCollectionLease.d.ts +60 -0
  140. package/dist/types/EasCollectionStore.d.ts +51 -0
  141. package/dist/types/EasCollectionSync.d.ts +96 -0
  142. package/dist/types/EasCommandHandler.d.ts +7 -4
  143. package/dist/types/EasSyncKeyUtils.d.ts +73 -42
  144. package/dist/types/MessageMoveRules.d.ts +45 -0
  145. package/dist/types/MimeHeaderUtils.d.ts +73 -0
  146. package/dist/types/RegexPatternUtils.d.ts +14 -0
  147. package/dist/types/RestapiCompat.d.ts +24 -0
  148. package/dist/types/adapters/CalendarSyncAdapter.d.ts +47 -18
  149. package/dist/types/adapters/EasCollectionSyncAdapter.d.ts +18 -2
  150. package/dist/types/adapters/EmailSyncAdapter.d.ts +24 -7
  151. package/dist/types/adapters/mongo/EmailSyncAdapterMongo.d.ts +1 -0
  152. package/dist/types/adapters/sql/EmailSyncAdapterSQL.d.ts +1 -0
  153. package/dist/types/codec/WbxmlDecoder.d.ts +39 -0
  154. package/dist/types/codec/WbxmlEncoder.d.ts +36 -1
  155. package/dist/types/commands/ComposeMailCommand.d.ts +33 -15
  156. package/dist/types/commands/FolderSyncCommand.d.ts +4 -1
  157. package/dist/types/commands/GetItemEstimateCommand.d.ts +20 -15
  158. package/dist/types/commands/ItemOperationsCommand.d.ts +29 -4
  159. package/dist/types/commands/MeetingResponseCommand.d.ts +52 -13
  160. package/dist/types/commands/MoveItemsCommand.d.ts +13 -3
  161. package/dist/types/commands/PingCommand.d.ts +72 -14
  162. package/dist/types/commands/ProvisionCommand.d.ts +16 -11
  163. package/dist/types/commands/ResolveRecipientsCommand.d.ts +4 -0
  164. package/dist/types/commands/SearchCommand.d.ts +13 -1
  165. package/dist/types/commands/SyncCommand.d.ts +102 -62
  166. package/dist/types/commands/mongo/GetItemEstimateCommandMongo.d.ts +3 -0
  167. package/dist/types/commands/mongo/ItemOperationsCommandMongo.d.ts +2 -0
  168. package/dist/types/commands/mongo/MeetingResponseCommandMongo.d.ts +1 -0
  169. package/dist/types/commands/mongo/PingCommandMongo.d.ts +8 -0
  170. package/dist/types/commands/mongo/SearchCommandMongo.d.ts +2 -0
  171. package/dist/types/commands/mongo/SendMailCommandMongo.d.ts +1 -0
  172. package/dist/types/commands/mongo/SmartForwardCommandMongo.d.ts +1 -0
  173. package/dist/types/commands/mongo/SmartReplyCommandMongo.d.ts +1 -0
  174. package/dist/types/commands/mongo/SyncCommandMongo.d.ts +4 -0
  175. package/dist/types/commands/mongo/index.d.ts +1 -0
  176. package/dist/types/commands/sql/GetItemEstimateCommandSQL.d.ts +3 -0
  177. package/dist/types/commands/sql/ItemOperationsCommandSQL.d.ts +2 -0
  178. package/dist/types/commands/sql/MeetingResponseCommandSQL.d.ts +1 -0
  179. package/dist/types/commands/sql/PingCommandSQL.d.ts +8 -0
  180. package/dist/types/commands/sql/SearchCommandSQL.d.ts +2 -0
  181. package/dist/types/commands/sql/SendMailCommandSQL.d.ts +1 -0
  182. package/dist/types/commands/sql/SmartForwardCommandSQL.d.ts +1 -0
  183. package/dist/types/commands/sql/SmartReplyCommandSQL.d.ts +1 -0
  184. package/dist/types/commands/sql/SyncCommandSQL.d.ts +4 -0
  185. package/dist/types/commands/sql/index.d.ts +1 -0
  186. package/dist/types/index.d.ts +5 -0
  187. package/dist/types/jobs/EasDeviceStateCleanupJob.d.ts +15 -0
  188. package/dist/types/jobs/mongo/EasDeviceStateCleanupJobMongo.d.ts +2 -0
  189. package/dist/types/jobs/sql/EasDeviceStateCleanupJobSQL.d.ts +4 -0
  190. package/dist/types/models/DeviceSyncState.d.ts +13 -2
  191. package/dist/types/models/EasCollectionChunk.d.ts +20 -0
  192. package/dist/types/models/EasCollectionState.d.ts +79 -0
  193. package/dist/types/models/mongo/DeviceSyncStateMongo.d.ts +1 -0
  194. package/dist/types/models/mongo/EasCollectionChunkMongo.d.ts +16 -0
  195. package/dist/types/models/mongo/EasCollectionStateMongo.d.ts +27 -0
  196. package/dist/types/models/sql/DeviceSyncStateSQL.d.ts +1 -0
  197. package/dist/types/models/sql/EasCollectionChunkSQL.d.ts +16 -0
  198. package/dist/types/models/sql/EasCollectionStateSQL.d.ts +27 -0
  199. package/dist/types/mongo.d.ts +2 -0
  200. package/dist/types/sql.d.ts +2 -0
  201. package/package.json +267 -238
@@ -13,59 +13,99 @@ var __metadata = (this && this.__metadata) || function (k, v) {
13
13
  ///////////////////////////////////////////////////////////////////////////////
14
14
  import { ApiError, ObjectDecorators } from "@rapidrest/core";
15
15
  import { ACLAction, ACLUtils, ApiErrorMessages, ApiErrors, RepoUtils, } from "@rapidrest/service-core";
16
- import { RecoverableRepoUtils } from "@rapidmx/restapi";
16
+ import { AuditAction, findOrCreateWellKnownFolder, FolderType, RecoverableRepoUtils } from "@rapidmx/restapi";
17
17
  import { WbxmlCodePage } from "../codec/WbxmlCodePages.js";
18
18
  import { childText, element, findChild, findChildren, textElement } from "../codec/WbxmlElement.js";
19
- import { computeChanges, formatSyncKey, persistDeviceSyncState, resolveSyncKey } from "../EasSyncKeyUtils.js";
20
- const { Config, Init, Inject } = ObjectDecorators;
21
- /** Caps how many item changes are enumerated per `Sync` round - a real device-visible "MoreAvailable" trigger
22
- * for a busy folder, not a real-world binding constraint (unlike `FolderSyncCommand`'s much smaller folder
23
- * hierarchy, an Inbox can easily exceed this in one round). */
19
+ import { formatSyncKey } from "../EasSyncKeyUtils.js";
20
+ import { classForFolderType, cloneWorkingState, enumerateCollection, filterPredicate, roundRecord, workingStateFromRound, workingStateFromRow, } from "../EasCollectionSync.js";
21
+ import { clearHeldSet, INLINE_HELD_LIMIT, loadHeldSet, saveHeldSet } from "../EasCollectionStore.js";
22
+ import { hasLiveSendLease, planMessageMove } from "../MessageMoveRules.js";
23
+ import { asEntity } from "../RestapiCompat.js";
24
+ import { EasCollectionLease } from "../EasCollectionLease.js";
25
+ import { EasAuditLog } from "../EasAuditLog.js";
26
+ const { Config, Init, Inject, Logger } = ObjectDecorators;
27
+ /** Default most item changes reported per collection per `Sync` round. */
24
28
  const DEFAULT_WINDOW_SIZE = 100;
29
+ /** [MS-ASCMD]'s own ceiling for `WindowSize`. */
30
+ const MAX_WINDOW_SIZE = 512;
31
+ /** Most `<Collection>`s one `Sync` request may carry - more is answered with a top-level Status 4. */
32
+ export const MAX_SYNC_COLLECTIONS = 300;
33
+ /** Most client `Add`/`Change`/`Delete` commands one collection of a `Sync` request may carry - more is answered
34
+ * with that collection's Status 4, without applying any of them. */
35
+ export const MAX_SYNC_COMMANDS_PER_COLLECTION = 512;
36
+ /** Rows read per round from the stream of items outside a collection's folder (see `enumerateCollection`). */
37
+ const DEFAULT_MOVE_SCAN_LIMIT = 1000;
38
+ /** Held ids checked against the store per caught-up round (see `enumerateCollection`'s reconcile). */
39
+ const DEFAULT_RECONCILE_LIMIT = 100;
40
+ /** Slack subtracted from "now" when a collection is (re)started, for the out-of-folder cursor. */
41
+ const MOVE_CURSOR_SLACK_MS = 60000;
42
+ /** How long a `Sync` waits for another request's lease on the same collection before answering Status 16. */
43
+ const LEASE_WAIT_MS = 15000;
44
+ /** How long a collection lease lives in Redis if its holder dies without releasing it. */
45
+ const LEASE_TTL_MS = 120000;
46
+ /** [MS-ASCMD] `Sync` Status values this command reports beyond success. */
47
+ const STATUS_INVALID_SYNC_KEY = "3";
48
+ const STATUS_SERVER_ERROR = "5";
49
+ const STATUS_RETRY = "16";
25
50
  /**
26
- * Handles EAS `Sync`: enumerates `Add`/`Change`/`Delete`s for a single folder's contents since the device's last
27
- * `Sync` of that folder, using the same watermark-based cursor mechanism `FolderSyncCommand` uses (via
28
- * `EasSyncKeyUtils`), scoped by `folderUid` instead of `mailboxUid`, and keyed per-folder in
29
- * `DeviceSyncState.folderSyncKeys` (the `CollectionId` a client sends *is* the `folderUid` - this library never
30
- * invents a separate collection identifier).
51
+ * Handles EAS `Sync` for `Email`/`Contacts`/`Calendar`/`Tasks` folders.
31
52
  *
32
- * **Multi-collection requests**: every `<Collection>` in a request's `<Collections>` is processed and gets its
33
- * own `<Collection>` entry in the response, each with its own independent `SyncKey`/`Status` - a client
34
- * syncing several folders in one round trip (the common case once the initial per-folder backlog is done)
35
- * gets one response covering all of them. All per-collection `SyncKey`/remembered-`Class` writes for the whole
36
- * request are batched into a single `persistDeviceSyncState` call after every collection has been processed
37
- * (never one call per collection) - see `EasSyncKeyUtils.persistDeviceSyncState`'s own doc comment for why a
38
- * second write to the same `DeviceSyncState` within one request must never be built off a stale copy.
53
+ * **Per-collection state** lives in its own `EasCollectionState` row per (mailbox, device, folder) - see that
54
+ * model - rather than in `DeviceSyncState`, so concurrent `Sync`s of different folders never contend for one row.
55
+ * Besides the issued `SyncKey`, the row records exactly which items the device holds (inline in `serverIds` while
56
+ * small, in `EasCollectionChunk` rows once large - see `EasCollectionStore`). That is what makes the reported
57
+ * commands correct rather than guessed: an item the device doesn't hold is always an `Add` (including on the first
58
+ * round after `SyncKey 0`), an item it holds is a `Change`, and an item it holds that has been deleted *or moved to
59
+ * another folder* is a `Delete` - see `EasCollectionSync.enumerateCollection`.
39
60
  *
40
- * **`Class` is only required on a collection's first (`SyncKey "0"`) request**, per `[MS-ASCMD]` - once seen,
41
- * it's remembered in `DeviceSyncState.folderCollectionClasses` (keyed by `folderUid`) so a later request may
42
- * omit it; omitting it for a folder never previously synced still gets `Status 4` (nothing to fall back to).
61
+ * **One round per collection at a time**: each collection's round runs under a lease on (mailbox, device, folder)
62
+ * (`EasCollectionLease`: in-process, plus Redis `datastores:cache` across server copies when configured), and its
63
+ * state is read only once the lease is held - a second `Sync` of the same collection waits for the first to finish
64
+ * and then sees its result (typically answering the now-previous `SyncKey` as a retry), instead of both computing a
65
+ * round from the same state. A lease not acquired within `LEASE_WAIT_MS` is answered with Status 16 (retry).
43
66
  *
44
- * **Every `CollectionId` is ACL-checked against the caller before it's touched**: `processCollection()` requires
45
- * `ACLAction.READ` on the folder before enumerating or accepting any Commands for it at all (a folder the caller
46
- * can't read is reported the same as an unrecognized collection - Status `4` - rather than leaking whether it
47
- * exists); `applyAdd`/`applyChange`/`applyDelete` additionally require `CREATE`/`UPDATE`/`DELETE` respectively,
48
- * and `applyChange`/`applyDelete` re-verify the resolved item's own `folderUid` actually matches the collection
49
- * being synced (treating a mismatch identically to "not found" - Status `8` - never revealing that the
50
- * `ServerId` resolves to something real elsewhere). Without this, a client could supply any other mailbox's
51
- * folder/item uid as its own `CollectionId`/`ServerId` and read or mutate that mailbox's data directly - the
52
- * same ownership-verification-after-an-`ignoreACL`-lookup pattern `ItemOperationsCommand`/`MoveItemsCommand`
53
- * already use, applied consistently here too.
67
+ * **Round order**: the client's own `Commands` are applied first, then server changes are enumerated. The device's
68
+ * own writes are not echoed back: each successful `Add`/`Change` records the resulting `dateModified` in the row's
69
+ * `echoes`, and a changed row still carrying exactly that timestamp is skipped. The cursor itself only ever advances
70
+ * past rows actually enumerated, never past a pending server change.
54
71
  *
55
- * **Pragmatic subset, deliberately not the full MS-ASCMD `Sync` surface**:
56
- * - **Client-originated `Add`/`Change`/`Delete` commands are accepted for every collection type**, including
57
- * `Email` (a device creating/editing a Draft, or deleting a message locally - see `applyAdd`/`applyChange`/
58
- * `applyDelete`). `[MS-ASCMD]` itself disallows `Add`/`Change` for any *non-draft* `Email` item - this library
59
- * doesn't verify a Sync `Email` Add/Change actually targets the caller's own Drafts folder *specifically*
60
- * (only that it's a folder the caller actually owns/can write to - see above), matching how Contacts/Calendar/
61
- * Tasks folder targeting is equally unchecked beyond ownership elsewhere. A collection whose adapter has no
62
- * `fromApplicationData` at all would get Status `6` for `Add`/`Change` instead, but every adapter today
63
- * implements it. Per `[MS-ASCMD]`'s own "Add (Sync)"/"Status (Sync)" pages: `Add` always gets a `Responses`
64
- * entry (it must report the assigned `ServerId`); `Change`/`Delete` only get one on **failure** - a silent
65
- * success means "assume it worked."
66
- * - Only a body preview is returned per item (see `EmailSyncAdapter`'s own doc comment) - full body content is
67
- * fetched separately via `ItemOperationsCommand`. A Draft's `Email` Add/Change is plain-text-only, with no
68
- * attachment support (mirrors `ComposeMailCommand`'s own already-documented attachment gap).
72
+ * **Lost state**: a round's state that can't be saved is answered with the collection's Status 3 (and a failed
73
+ * `SyncKey 0` restart with Status 5), never with a `SyncKey` the server doesn't have - the device re-syncs from
74
+ * scratch instead of continuing from a key whose held set was never recorded.
75
+ *
76
+ * **Retries**: a client that never received a response re-sends the `SyncKey` it still holds. The row keeps the
77
+ * previous round's key and delta (`previous`), so that key is accepted and the round is recomputed from the state
78
+ * before it; an `Add` re-sent with the same `ClientId` is answered with the item created the first time, and a
79
+ * `Delete` of an item that round already removed succeeds silently.
80
+ *
81
+ * **Options honoured**: `WindowSize` (capped by `mail:eas:sync_window_size` and 512), `FilterType` (age window for
82
+ * `Email`/`Calendar`, incomplete-only for `Tasks`; applied to items the device doesn't hold yet). A collection
83
+ * started without `Options` has no filter recorded, and the first `FilterType` sent while the device still holds
84
+ * nothing is adopted (clients commonly send `Options` only from the second request on); a `FilterType` differing
85
+ * from the recorded one otherwise gets Status 3 so the client re-syncs from 0. `DeletesAsMoves` (default `true`: an
86
+ * `Email` delete moves the message to Deleted Items; a delete inside Deleted Items, or with `DeletesAsMoves` `0`,
87
+ * deletes it) and `GetChanges` `0` (no server changes this round).
88
+ *
89
+ * **Meetings**: a device deleting or editing an attendee's copy of someone else's meeting never makes restapi's
90
+ * `MeetingSchedulingJob` send cancellations/invitations as the organizer - see `CalendarSyncAdapter.beforeDelete`
91
+ * and its `fromApplicationData`.
92
+ *
93
+ * **Access**: every `CollectionId` needs `READ` on the folder (otherwise Status 4, indistinguishable from an unknown
94
+ * collection); `Add`/`Change`/`Delete` additionally need `CREATE`/`UPDATE`/`DELETE`, and a `ServerId` that resolves
95
+ * to an item outside the synced folder is reported as not found (Status 8). An `Email` `Add` is only accepted in
96
+ * a Drafts folder ([MS-ASCMD]: no non-draft email may be added by a client), and a new item's `mailboxUid` is the
97
+ * folder's own mailbox, so an item added to a shared folder belongs to that folder's mailbox. An `Email` body can only
98
+ * be changed on a genuine draft (`EmailSyncAdapter`), and a delete-as-move out of Outbox cancels the scheduled send
99
+ * (`MessageMoveRules.planMessageMove`; Status 6 once the message was relayed), and no delete at all happens while a send of
100
+ * the message is in flight (`hasLiveSendLease`, Status 6). Every update is version-checked on both
101
+ * backends (`asEntity`).
102
+ *
103
+ * **Audit** (`EasAuditLog`), only in a mailbox the caller doesn't own (restapi's `isNonOwnerAccess()` - an administrator
104
+ * or a delegate syncing a shared folder), and only for `Email`: a round that sends `Add`/`Change` items records one
105
+ * `MESSAGE_CONTENT_ACCESSED` entry for the collection listing the sent message uids (at most a window's worth), rather
106
+ * than one per row; each successful client `Delete` records one `MESSAGE_DELETE` entry, as restapi's REST delete does.
107
+ *
108
+ * Per `[MS-ASCMD]`, `Add` always gets a `Responses` entry; `Change`/`Delete` only on failure.
69
109
  *
70
110
  * @author Jean-Philippe Steinmetz
71
111
  */
@@ -73,6 +113,9 @@ export class SyncCommand {
73
113
  constructor() {
74
114
  this.command = "Sync";
75
115
  this.windowSize = DEFAULT_WINDOW_SIZE;
116
+ this.moveScanLimit = DEFAULT_MOVE_SCAN_LIMIT;
117
+ this.reconcileLimit = DEFAULT_RECONCILE_LIMIT;
118
+ this.leaseWaitMs = LEASE_WAIT_MS;
76
119
  this.repos = new Map();
77
120
  this.adapters = new Map();
78
121
  }
@@ -81,11 +124,21 @@ export class SyncCommand {
81
124
  name: this.mailboxClass.name,
82
125
  args: [this.mailboxClass],
83
126
  });
127
+ this.folderRepo = await this._objectFactory.newInstance(RecoverableRepoUtils, {
128
+ name: this.folderClass.name,
129
+ args: [this.folderClass],
130
+ });
131
+ this.collectionStateRepo = await this._objectFactory.newInstance(RepoUtils, {
132
+ name: this.collectionStateClass.name,
133
+ args: [this.collectionStateClass],
134
+ });
135
+ this.collectionChunkRepo = await this._objectFactory.newInstance(RepoUtils, {
136
+ name: this.collectionChunkClass.name,
137
+ args: [this.collectionChunkClass],
138
+ });
84
139
  for (const [collectionClass, binding] of Object.entries(this.collectionBindings)) {
85
- // RecoverableRepoUtils, not plain RepoUtils: SyncCommand now originates its own deletes
86
- // (`applyDelete`) - without it, a soft-delete here wouldn't bump `dateModified`/`version`,
87
- // breaking this exact class's own watermark-based deletion detection for anything deleted via
88
- // Sync instead of the REST API. The same fix `BaseMapiEmsmdbRoute.ts` already needed for MAPI.
140
+ // RecoverableRepoUtils: a soft-delete must bump `dateModified`/`version`, or the change stream this
141
+ // class enumerates would never see it.
89
142
  this.repos.set(collectionClass, await this._objectFactory.newInstance(RecoverableRepoUtils, {
90
143
  name: binding.entityClass.name,
91
144
  args: [binding.entityClass],
@@ -93,22 +146,24 @@ export class SyncCommand {
93
146
  this.adapters.set(collectionClass, await this._objectFactory.newInstance(binding.adapterClass));
94
147
  }
95
148
  }
96
- /** Resolves the caller's own `Mailbox` at most once per request, and only if actually needed - most `Sync`
97
- * requests contain no client-originated `Add` at all, so most requests never pay this extra round trip. */
98
- mailboxLoader(ctx) {
149
+ get chunkStore() {
150
+ return { repo: this.collectionChunkRepo, chunkClass: this.collectionChunkClass };
151
+ }
152
+ /** Resolves a mailbox at most once per request, and only if actually needed. */
153
+ mailboxLoader(mailboxUid) {
99
154
  let cached;
100
155
  return async () => {
101
156
  if (!cached) {
102
- cached = await this.mailboxRepo.findOne(ctx.mailboxUid, { ignoreACL: true });
157
+ cached = await this.mailboxRepo.findOne(mailboxUid, { ignoreACL: true });
103
158
  if (!cached) {
104
- throw new ApiError(ApiErrors.NOT_FOUND, 404, "The caller's own mailbox no longer exists.");
159
+ throw new ApiError(ApiErrors.NOT_FOUND, 404, "The mailbox no longer exists.");
105
160
  }
106
161
  }
107
162
  return cached;
108
163
  };
109
164
  }
110
165
  async handle(ctx) {
111
- if (!this.aclUtils) {
166
+ if (!this.aclUtils || !this.folderRepo || !this.collectionStateRepo || !this.collectionChunkRepo) {
112
167
  throw new ApiError(ApiErrors.INTERNAL_ERROR, 500, ApiErrorMessages.INTERNAL_ERROR);
113
168
  }
114
169
  const collections = ctx.request ? findChild(ctx.request, "Collections") : undefined;
@@ -116,157 +171,279 @@ export class SyncCommand {
116
171
  if (collectionEls.length === 0) {
117
172
  return element(WbxmlCodePage.AirSync, "Sync", [textElement(WbxmlCodePage.AirSync, "Status", "3")]);
118
173
  }
174
+ if (collectionEls.length > MAX_SYNC_COLLECTIONS) {
175
+ return element(WbxmlCodePage.AirSync, "Sync", [textElement(WbxmlCodePage.AirSync, "Status", "4")]);
176
+ }
177
+ const requestWindowSize = childText(ctx.request, "WindowSize");
178
+ const getMailbox = this.mailboxLoader(ctx.mailboxUid);
179
+ const audit = new EasAuditLog({ objectFactory: this._objectFactory, auditLogClass: this.auditLogClass, mailboxRepo: this.mailboxRepo, config: this.config, logger: this.logger }, ctx, this.command);
119
180
  const collectionElements = [];
120
- // Accumulated across every collection below, then written in exactly ONE persistDeviceSyncState call
121
- // after the loop - never one call per collection (see this class's own doc comment on why).
122
- let folderSyncKeys;
123
- let folderCollectionClasses;
124
- const getMailbox = this.mailboxLoader(ctx);
125
181
  for (const collectionEl of collectionEls) {
126
- const result = await this.processCollection(ctx, collectionEl, getMailbox);
127
- collectionElements.push(result.collectionElement);
128
- if (result.folderUid && result.newSyncKey) {
129
- folderSyncKeys = {
130
- ...(folderSyncKeys ?? ctx.deviceSyncState.folderSyncKeys),
131
- [result.folderUid]: result.newSyncKey,
132
- };
133
- }
134
- if (result.folderUid && result.rememberedClass) {
135
- folderCollectionClasses = {
136
- ...(folderCollectionClasses ?? ctx.deviceSyncState.folderCollectionClasses ?? {}),
137
- [result.folderUid]: result.rememberedClass,
138
- };
139
- }
182
+ collectionElements.push(await this.processCollection(ctx, collectionEl, getMailbox, requestWindowSize, audit));
140
183
  }
141
- if (folderSyncKeys || folderCollectionClasses) {
142
- await persistDeviceSyncState(ctx.deviceSyncState, ctx.deviceSyncStateRepo, {
143
- ...(folderSyncKeys ? { folderSyncKeys } : {}),
144
- ...(folderCollectionClasses ? { folderCollectionClasses } : {}),
145
- });
146
- }
147
- return element(WbxmlCodePage.AirSync, "Sync", [
148
- element(WbxmlCodePage.AirSync, "Collections", collectionElements),
149
- ]);
184
+ return element(WbxmlCodePage.AirSync, "Sync", [element(WbxmlCodePage.AirSync, "Collections", collectionElements)]);
150
185
  }
151
- /** Processes one `<Collection>` from the request into its own `<Collection>` response element, plus (when
152
- * this round advanced anything) the `folderUid`/new `SyncKey`/remembered `Class` for `handle()` to fold
153
- * into its single end-of-request `persistDeviceSyncState` call - this method itself never persists
154
- * anything, so it's safe to call once per collection in a request without the write-batching hazard
155
- * `EasSyncKeyUtils.persistDeviceSyncState`'s doc comment describes. */
156
- async processCollection(ctx, collectionEl, getMailbox) {
186
+ effectiveWindowSize(requested) {
187
+ const limit = Math.min(this.windowSize, MAX_WINDOW_SIZE);
188
+ const value = Number(requested);
189
+ return requested !== undefined && Number.isInteger(value) && value > 0 ? Math.min(value, limit) : limit;
190
+ }
191
+ async processCollection(ctx, collectionEl, getMailbox, requestWindowSize, audit) {
157
192
  const requestedClass = childText(collectionEl, "Class");
158
193
  const folderUid = childText(collectionEl, "CollectionId");
159
194
  const clientSyncKey = childText(collectionEl, "SyncKey");
160
- // Class is only required on a collection's first (SyncKey "0") request - see this class's own doc
161
- // comment. A folder never previously synced has no remembered value to fall back to, so omitting Class
162
- // there still (correctly) falls through to the "missing" branch below.
163
- const collectionClass = requestedClass ?? (folderUid ? ctx.deviceSyncState.folderCollectionClasses?.[folderUid] : undefined);
164
- if (!collectionClass || !folderUid) {
165
- return { collectionElement: this.collectionResponse(collectionClass, folderUid, "4", clientSyncKey) };
195
+ // A folder the caller can't even read is reported identically to an unrecognized collection - never
196
+ // reveal whether a client-supplied CollectionId belonging to someone else's mailbox actually exists.
197
+ if (!folderUid || !(await this.aclUtils.hasPermission(ctx.user, folderUid, ACLAction.READ))) {
198
+ return this.collectionResponse(requestedClass, folderUid, "4", clientSyncKey);
199
+ }
200
+ const folder = await this.folderRepo.findOne(folderUid, { ignoreACL: true });
201
+ if (!folder) {
202
+ return this.collectionResponse(requestedClass, folderUid, "4", clientSyncKey);
203
+ }
204
+ const release = await EasCollectionLease.acquire(JSON.stringify([ctx.mailboxUid, ctx.deviceId, folderUid]), {
205
+ redisUrl: this.cacheConfig?.url,
206
+ ttlMs: LEASE_TTL_MS,
207
+ waitMs: this.leaseWaitMs,
208
+ });
209
+ if (!release) {
210
+ return this.collectionResponse(requestedClass, folderUid, STATUS_RETRY, clientSyncKey);
211
+ }
212
+ try {
213
+ return await this.processLocked(ctx, collectionEl, folder, getMailbox, requestWindowSize, audit);
166
214
  }
215
+ finally {
216
+ await release();
217
+ }
218
+ }
219
+ /** The rest of `processCollection`, run while holding the collection's lease. */
220
+ async processLocked(ctx, collectionEl, folder, getMailbox, requestWindowSize, audit) {
221
+ const folderUid = folder.uid;
222
+ const requestedClass = childText(collectionEl, "Class");
223
+ const clientSyncKey = childText(collectionEl, "SyncKey");
224
+ const stored = (await this.collectionStateRepo.find({ mailboxUid: ctx.mailboxUid, deviceId: ctx.deviceId, folderUid }, {
225
+ ignoreACL: true,
226
+ limit: 1,
227
+ }))[0];
228
+ const collectionClass = requestedClass ?? stored?.collectionClass ?? classForFolderType(folder.type);
167
229
  const repo = this.repos.get(collectionClass);
168
230
  const adapter = this.adapters.get(collectionClass);
169
- if (!repo || !adapter) {
170
- return { collectionElement: this.collectionResponse(collectionClass, folderUid, "4", clientSyncKey) };
231
+ const commandsEl = findChild(collectionEl, "Commands");
232
+ if (!repo || !adapter || (commandsEl?.children.length ?? 0) > MAX_SYNC_COMMANDS_PER_COLLECTION) {
233
+ return this.collectionResponse(collectionClass, folderUid, "4", clientSyncKey);
171
234
  }
172
- // A folder the caller can't even read is reported identically to an unrecognized collection - never
173
- // reveal whether a client-supplied CollectionId belonging to someone else's mailbox actually exists.
174
- // See this class's own doc comment for why this check (and the matching ones in applyAdd/applyChange/
175
- // applyDelete below) is required, not optional.
176
- if (!(await this.aclUtils.hasPermission(ctx.user, folderUid, ACLAction.READ))) {
177
- return { collectionElement: this.collectionResponse(collectionClass, folderUid, "4", clientSyncKey) };
178
- }
179
- const storedSyncKey = ctx.deviceSyncState.folderSyncKeys[folderUid];
180
- const resolution = resolveSyncKey(clientSyncKey, storedSyncKey);
181
- if (resolution.kind === "invalid") {
182
- return { collectionElement: this.collectionResponse(collectionClass, folderUid, "3", undefined) };
183
- }
184
- if (resolution.kind === "initial") {
185
- // Same epoch-not-"now" reasoning as FolderSyncCommand's own initial-sync branch: the client's next
186
- // request (echoing this key) is its true first full sync of this folder and must see every
187
- // existing item as an Add, not just ones modified after this handshake started.
188
- const newKey = formatSyncKey({ generation: 1, watermark: new Date(0) });
189
- return {
190
- collectionElement: this.collectionResponse(collectionClass, folderUid, "1", newKey),
191
- folderUid,
192
- newSyncKey: newKey,
193
- rememberedClass: requestedClass,
235
+ const optionsEl = findChild(collectionEl, "Options");
236
+ const requestedFilter = optionsEl ? childText(optionsEl, "FilterType") : undefined;
237
+ if (!clientSyncKey || clientSyncKey === "0") {
238
+ return await this.startCollection(ctx, stored, folderUid, collectionClass, requestedFilter);
239
+ }
240
+ let held;
241
+ try {
242
+ held = await loadHeldSet(stored, this.chunkStore);
243
+ }
244
+ catch (err) {
245
+ this.logger?.warn(`SyncCommand: failed to load sync state for folder ${folderUid}: ${err?.message}`);
246
+ return this.collectionResponse(collectionClass, folderUid, STATUS_SERVER_ERROR, clientSyncKey);
247
+ }
248
+ let working;
249
+ let retry;
250
+ if (stored && clientSyncKey === stored.syncKey) {
251
+ working = workingStateFromRow(stored, held.ids);
252
+ }
253
+ else if (stored?.previous && clientSyncKey === stored.previous.syncKey) {
254
+ working = workingStateFromRound(stored, stored.previous, held.ids);
255
+ retry = {
256
+ removedIds: new Set(stored.previous.removedIds),
257
+ clientIds: new Map(stored.previous.clientIds.map((entry) => [entry.clientId, entry.serverId])),
194
258
  };
195
259
  }
196
- // Computed against the OLD watermark, BEFORE this round's own client-originated writes below are
197
- // applied - this is what stops a client's own fresh Add/Change/Delete from being echoed straight back
198
- // as a Commands/Add|Change|Delete in this SAME response.
199
- const changes = await computeChanges(repo, "folderUid", folderUid, resolution.key.watermark, this.windowSize);
200
- // Process the client's own Commands (if any) - after the read above, before computing the new SyncKey
201
- // below (which must cover these writes too, or the NEXT round would re-report them as incoming
202
- // server-side changes).
203
- const requestCommands = findChild(collectionEl, "Commands");
204
- const responseEntries = [];
205
- let maxWriteWatermark;
206
- const note = (date) => {
207
- if (date && (!maxWriteWatermark || date > maxWriteWatermark))
208
- maxWriteWatermark = date;
209
- };
210
- if (requestCommands) {
211
- for (const el of findChildren(requestCommands, "Add")) {
212
- const outcome = await this.applyAdd(ctx, adapter, repo, folderUid, el, getMailbox);
213
- if (outcome.response)
214
- responseEntries.push(outcome.response);
215
- note(outcome.writtenAt);
260
+ else {
261
+ return this.collectionResponse(collectionClass, folderUid, STATUS_INVALID_SYNC_KEY, undefined);
262
+ }
263
+ if (requestedFilter !== undefined && requestedFilter !== working.filterType) {
264
+ if (working.filterType === undefined && (working.serverIds.size === 0 || working.generation <= 1)) {
265
+ // Started without Options: adopt the first FilterType while the device still holds nothing it was
266
+ // selected without.
267
+ working.filterType = requestedFilter;
216
268
  }
217
- for (const el of findChildren(requestCommands, "Change")) {
218
- const outcome = await this.applyChange(ctx, adapter, repo, folderUid, el);
219
- if (outcome.response)
220
- responseEntries.push(outcome.response);
221
- note(outcome.writtenAt);
269
+ else {
270
+ // The window the device's items were selected with no longer matches - restart from SyncKey 0.
271
+ return this.collectionResponse(collectionClass, folderUid, STATUS_INVALID_SYNC_KEY, undefined);
222
272
  }
223
- for (const el of findChildren(requestCommands, "Delete")) {
224
- const outcome = await this.applyDelete(ctx, repo, folderUid, el);
225
- if (outcome.response)
226
- responseEntries.push(outcome.response);
227
- note(outcome.writtenAt);
273
+ }
274
+ const base = cloneWorkingState(working);
275
+ const round = {
276
+ ctx,
277
+ folder,
278
+ collectionClass,
279
+ adapter,
280
+ repo,
281
+ working,
282
+ retry,
283
+ clientIds: new Map(),
284
+ deletesAsMoves: childText(collectionEl, "DeletesAsMoves") !== "0",
285
+ getMailbox,
286
+ getFolderMailbox: folder.mailboxUid === ctx.mailboxUid ? getMailbox : this.mailboxLoader(folder.mailboxUid),
287
+ audit,
288
+ };
289
+ const responseEntries = [];
290
+ if (commandsEl) {
291
+ for (const el of commandsEl.children) {
292
+ const response = el.tag === "Add"
293
+ ? await this.applyAdd(round, el)
294
+ : el.tag === "Change"
295
+ ? await this.applyChange(round, el)
296
+ : el.tag === "Delete"
297
+ ? await this.applyDelete(round, el)
298
+ : undefined;
299
+ if (response) {
300
+ responseEntries.push(response);
301
+ }
228
302
  }
229
303
  }
230
- // Only ever extends the watermark forward past what `computeChanges` itself already determined - never
231
- // jumps all the way to "now" unconditionally, which would silently skip over not-yet-enumerated
232
- // pending changes whenever `changes.moreAvailable` is true.
233
- const newWatermark = maxWriteWatermark && maxWriteWatermark > changes.newWatermark ? maxWriteWatermark : changes.newWatermark;
234
- const newKey = formatSyncKey({ generation: resolution.key.generation + 1, watermark: newWatermark });
235
- const totalChanges = changes.adds.length + changes.changes.length + changes.deletes.length;
236
- if (totalChanges === 0 && responseEntries.length === 0) {
237
- return {
238
- collectionElement: this.collectionResponse(collectionClass, folderUid, "1", newKey),
304
+ const { commands, moreAvailable } = childText(collectionEl, "GetChanges") === "0"
305
+ ? { commands: [], moreAvailable: false }
306
+ : await enumerateCollection(working, {
307
+ repo,
239
308
  folderUid,
240
- newSyncKey: newKey,
241
- rememberedClass: requestedClass,
242
- };
309
+ folderMailboxUid: folder.mailboxUid,
310
+ windowSize: this.effectiveWindowSize(childText(collectionEl, "WindowSize") ?? requestWindowSize),
311
+ moveScanLimit: this.moveScanLimit,
312
+ reconcileLimit: this.reconcileLimit,
313
+ include: filterPredicate(collectionClass, working.filterType),
314
+ });
315
+ const newKey = formatSyncKey({ generation: working.generation + 1, watermark: working.cursor.date, uid: working.cursor.uid });
316
+ const saved = await this.saveState(stored, { loaded: held, ids: working.serverIds }, {
317
+ mailboxUid: ctx.mailboxUid,
318
+ deviceId: ctx.deviceId,
319
+ folderUid,
320
+ collectionClass,
321
+ syncKey: newKey,
322
+ cursorDate: working.cursor.date,
323
+ cursorUid: working.cursor.uid,
324
+ moveCursorDate: working.moveCursor.date,
325
+ moveCursorUid: working.moveCursor.uid,
326
+ echoes: Object.fromEntries(working.echoes),
327
+ ...(working.recent ? { recent: Object.fromEntries(working.recent) } : {}),
328
+ reconcileCursor: working.reconcileCursor,
329
+ filterType: working.filterType ?? null,
330
+ previous: roundRecord(clientSyncKey, base, working, round.clientIds),
331
+ });
332
+ if (!saved) {
333
+ return this.collectionResponse(collectionClass, folderUid, STATUS_INVALID_SYNC_KEY, undefined);
243
334
  }
244
- const commandElements = [
245
- ...(await Promise.all(changes.adds.map((item) => this.itemToCommandElement("Add", adapter, item)))),
246
- ...(await Promise.all(changes.changes.map((item) => this.itemToCommandElement("Change", adapter, item)))),
247
- ...changes.deletes.map((item) => element(WbxmlCodePage.AirSync, "Delete", [textElement(WbxmlCodePage.AirSync, "ServerId", item.uid)])),
248
- ];
249
- return {
250
- collectionElement: this.collectionResponse(collectionClass, folderUid, "1", newKey, [
251
- ...(changes.moreAvailable ? [element(WbxmlCodePage.AirSync, "MoreAvailable", [])] : []),
252
- ...(commandElements.length > 0 ? [element(WbxmlCodePage.AirSync, "Commands", commandElements)] : []),
253
- ...(responseEntries.length > 0 ? [element(WbxmlCodePage.AirSync, "Responses", responseEntries)] : []),
254
- ]),
335
+ const upserts = commands.filter((c) => c.kind !== "Delete");
336
+ const applicationData = upserts.length === 0
337
+ ? []
338
+ : adapter.toApplicationDataBatch
339
+ ? await adapter.toApplicationDataBatch(upserts.map((c) => c.item))
340
+ : await Promise.all(upserts.map(async (c) => await adapter.toApplicationData(c.item)));
341
+ const commandElements = commands.map((c) => c.kind === "Delete"
342
+ ? element(WbxmlCodePage.AirSync, "Delete", [textElement(WbxmlCodePage.AirSync, "ServerId", c.uid)])
343
+ : element(WbxmlCodePage.AirSync, c.kind, [
344
+ textElement(WbxmlCodePage.AirSync, "ServerId", c.item.uid),
345
+ applicationData[upserts.indexOf(c)],
346
+ ]));
347
+ if (collectionClass === "Email" && upserts.length > 0) {
348
+ const messageUids = upserts.map((c) => c.item.uid);
349
+ await audit.record({
350
+ action: AuditAction.MESSAGE_CONTENT_ACCESSED,
351
+ mailboxUid: folder.mailboxUid,
352
+ targetType: "Folder",
353
+ targetUid: folderUid,
354
+ details: { operation: "Sync", count: messageUids.length, messageUids },
355
+ });
356
+ }
357
+ return this.collectionResponse(collectionClass, folderUid, "1", newKey, [
358
+ ...(moreAvailable ? [element(WbxmlCodePage.AirSync, "MoreAvailable", [])] : []),
359
+ ...(commandElements.length > 0 ? [element(WbxmlCodePage.AirSync, "Commands", commandElements)] : []),
360
+ ...(responseEntries.length > 0 ? [element(WbxmlCodePage.AirSync, "Responses", responseEntries)] : []),
361
+ ]);
362
+ }
363
+ /** `SyncKey 0`: (re)starts the collection with an empty item set. Per [MS-ASCMD] the response carries only the
364
+ * new key; the next round reports every item as an `Add`. */
365
+ async startCollection(ctx, stored, folderUid, collectionClass, filterType) {
366
+ const epoch = new Date(0);
367
+ const newKey = formatSyncKey({ generation: 1, watermark: epoch });
368
+ const saved = await this.saveState(stored, "restart", {
369
+ mailboxUid: ctx.mailboxUid,
370
+ deviceId: ctx.deviceId,
255
371
  folderUid,
256
- newSyncKey: newKey,
257
- rememberedClass: requestedClass,
258
- };
372
+ collectionClass,
373
+ syncKey: newKey,
374
+ cursorDate: epoch,
375
+ cursorUid: "",
376
+ moveCursorDate: new Date(Date.now() - MOVE_CURSOR_SLACK_MS),
377
+ moveCursorUid: "",
378
+ echoes: {},
379
+ recent: {},
380
+ reconcileCursor: "",
381
+ // `null` (not `undefined`) so a restart without Options clears a FilterType recorded earlier on SQL too.
382
+ filterType: filterType ?? null,
383
+ previous: undefined,
384
+ });
385
+ if (!saved) {
386
+ return this.collectionResponse(collectionClass, folderUid, STATUS_SERVER_ERROR, undefined);
387
+ }
388
+ return this.collectionResponse(collectionClass, folderUid, "1", newKey);
259
389
  }
260
- async itemToCommandElement(kind, adapter, item) {
261
- return element(WbxmlCodePage.AirSync, kind, [
262
- textElement(WbxmlCodePage.AirSync, "ServerId", item.uid),
263
- await adapter.toApplicationData(item),
264
- ]);
390
+ /**
391
+ * Writes the held set (`held.ids`, given the set `held.loaded` the round started from) and then creates or
392
+ * updates the collection's state row. Returns `false` - after logging - when anything failed (including losing a
393
+ * race for the row): the caller must then not hand out the new key. A `"restart"` empties the held set.
394
+ *
395
+ * **Chunk writes can't be atomic with the state row**, so before any chunk row is touched the state row is first
396
+ * marked `chunked` with both its `SyncKey` and its previous round's key blanked (`invalidateBeforeChunkWrite()`).
397
+ * If anything after that fails - including the final state write, and even when the device never sees this round's
398
+ * Status 3 - no key matches the half-written chunks any more: the device's next `Sync` gets Status 3 and restarts
399
+ * with `SyncKey 0`, which always removes every chunk row of the collection (whether or not the row says `chunked`,
400
+ * so rows orphaned before this rule existed are cleared too). A collection converting from inline to chunked also
401
+ * clears leftover chunk rows first, so an orphan can never collide with the unique `chunkIndex`.
402
+ */
403
+ async saveState(stored, held, values) {
404
+ try {
405
+ let heldValues;
406
+ let current = stored;
407
+ if (held === "restart") {
408
+ await clearHeldSet(values, this.chunkStore);
409
+ heldValues = { serverIds: [], chunked: false };
410
+ }
411
+ else {
412
+ const wasChunked = !!stored?.chunked;
413
+ if (stored && (wasChunked || held.ids.size > INLINE_HELD_LIMIT)) {
414
+ current = await this.invalidateBeforeChunkWrite(stored);
415
+ if (!wasChunked) {
416
+ await clearHeldSet(values, this.chunkStore);
417
+ }
418
+ }
419
+ heldValues = await saveHeldSet(values, held.loaded, held.ids, wasChunked, this.chunkStore);
420
+ }
421
+ const row = { ...values, ...heldValues };
422
+ if (current) {
423
+ await this.collectionStateRepo.update({ ...row, uid: current.uid, version: current.version }, asEntity(this.collectionStateRepo, current), { ignoreACL: true, skipPush: true });
424
+ }
425
+ else {
426
+ await this.collectionStateRepo.create(new this.collectionStateClass(row), { ignoreACL: true, skipPush: true });
427
+ }
428
+ return true;
429
+ }
430
+ catch (err) {
431
+ this.logger?.warn(`SyncCommand: failed to save sync state for folder ${values.folderUid}: ${err?.message}`);
432
+ return false;
433
+ }
434
+ }
435
+ /** Marks `stored` chunked with no acceptable `SyncKey` (current or previous) before its chunk rows are written - see
436
+ * `saveState()`. Returns the updated row, whose version the final state write must carry. */
437
+ async invalidateBeforeChunkWrite(stored) {
438
+ return await this.collectionStateRepo.update({
439
+ uid: stored.uid,
440
+ version: stored.version,
441
+ syncKey: "",
442
+ ...(stored.previous ? { previous: { ...stored.previous, syncKey: "" } } : {}),
443
+ serverIds: [],
444
+ chunked: true,
445
+ }, asEntity(this.collectionStateRepo, stored), { ignoreACL: true, skipPush: true });
265
446
  }
266
- /** One client-originated command's outcome: `response` is a `Responses/{Add,Change,Delete}` entry to
267
- * include (per MS-ASCMD, always present for `Add`, only present on failure for `Change`/`Delete`);
268
- * `writtenAt` is the resulting `dateModified` of whatever was actually written, used to advance the
269
- * persisted watermark past this round's own writes (see `handle()`'s own comment on why). */
270
447
  addResponseElement(clientId, serverId, status) {
271
448
  return element(WbxmlCodePage.AirSync, "Add", [
272
449
  ...(clientId ? [textElement(WbxmlCodePage.AirSync, "ClientId", clientId)] : []),
@@ -280,105 +457,153 @@ export class SyncCommand {
280
457
  textElement(WbxmlCodePage.AirSync, "Status", status),
281
458
  ]);
282
459
  }
283
- async applyAdd(ctx, adapter, repo, folderUid, el, getMailbox) {
460
+ /** Remembers the `dateModified` the device's own write left on `item`, so the write isn't echoed back. */
461
+ noteWrite(round, item) {
462
+ round.working.serverIds.add(item.uid);
463
+ if (item.dateModified !== undefined) {
464
+ round.working.echoes.set(item.uid, new Date(item.dateModified).toISOString());
465
+ }
466
+ }
467
+ async applyAdd(round, el) {
468
+ const { ctx, adapter, repo, folder } = round;
284
469
  const clientId = childText(el, "ClientId");
470
+ const replayed = clientId ? round.retry?.clientIds.get(clientId) : undefined;
471
+ if (clientId && replayed) {
472
+ // The first attempt already created the item: answer with it, and treat its current state as the device's own
473
+ // write so the replayed round doesn't send it back as a Change.
474
+ const item = await repo.findOne(replayed, { ignoreACL: true });
475
+ if (item) {
476
+ this.noteWrite(round, item);
477
+ }
478
+ round.clientIds.set(clientId, replayed);
479
+ return this.addResponseElement(clientId, replayed, "1");
480
+ }
285
481
  const appData = findChild(el, "ApplicationData");
286
- // [MS-ASCMD] "Add (Sync)": "The Add element cannot be used to add any non-draft email items from the
287
- // client to the server" - this pragmatic subset extends that same Status 6 to every collection whose
288
- // adapter has no fromApplicationData at all, rather than special-casing "Email" by name.
289
482
  if (!adapter.fromApplicationData || !appData) {
290
- return { response: this.addResponseElement(clientId, undefined, "6") };
483
+ return this.addResponseElement(clientId, undefined, "6");
484
+ }
485
+ // [MS-ASCMD] "Add (Sync)": a client can only add *draft* email.
486
+ if (round.collectionClass === "Email" && folder.type !== FolderType.DRAFTS) {
487
+ return this.addResponseElement(clientId, undefined, "6");
291
488
  }
292
- // The top-level per-collection READ check (processCollection) only proves the caller can see this
293
- // folder - a shared/read-only folder still needs its own CREATE check before anything is written into it.
294
- if (!(await this.aclUtils.hasPermission(ctx.user, folderUid, ACLAction.CREATE))) {
295
- return { response: this.addResponseElement(clientId, undefined, "6") };
489
+ if (!(await this.aclUtils.hasPermission(ctx.user, folder.uid, ACLAction.CREATE))) {
490
+ return this.addResponseElement(clientId, undefined, "6");
296
491
  }
297
492
  try {
298
- const defaults = adapter.newEntityDefaults ? adapter.newEntityDefaults(await getMailbox()) : {};
299
- const partial = await adapter.fromApplicationData(appData);
300
- const created = await repo.create({ ...defaults, ...partial, mailboxUid: ctx.mailboxUid, folderUid }, { ignoreACL: true });
301
- return {
302
- response: this.addResponseElement(clientId, created.uid, "1"),
303
- writtenAt: created.dateModified,
304
- };
493
+ const mailbox = await round.getMailbox();
494
+ const defaults = adapter.newEntityDefaults ? adapter.newEntityDefaults(mailbox) : {};
495
+ const partial = await adapter.fromApplicationData(appData, undefined, mailbox);
496
+ const created = await repo.create({ ...defaults, ...partial, mailboxUid: folder.mailboxUid, folderUid: folder.uid }, { ignoreACL: true });
497
+ this.noteWrite(round, created);
498
+ if (clientId) {
499
+ round.clientIds.set(clientId, created.uid);
500
+ }
501
+ return this.addResponseElement(clientId, created.uid, "1");
305
502
  }
306
503
  catch {
307
- // A malformed/invalid item (bad enum value, missing required field like Calendar's OrganizerEmail,
308
- // ...) - Status 6 is [MS-ASCMD]'s own designated code for exactly this ("client/server conversion
309
- // error... client has sent a malformed or invalid item").
310
- return { response: this.addResponseElement(clientId, undefined, "6") };
504
+ // Status 6: "the client has sent a malformed or invalid item".
505
+ return this.addResponseElement(clientId, undefined, "6");
311
506
  }
312
507
  }
313
- async applyChange(ctx, adapter, repo, folderUid, el) {
508
+ async applyChange(round, el) {
509
+ const { ctx, adapter, repo, folder } = round;
314
510
  const serverId = childText(el, "ServerId");
315
511
  if (!serverId) {
316
- return {};
512
+ return undefined;
317
513
  }
318
514
  if (!adapter.fromApplicationData) {
319
- return { response: this.statusResponseElement("Change", serverId, "6") };
515
+ return this.statusResponseElement("Change", serverId, "6");
320
516
  }
321
517
  const existing = await repo.findOne(serverId, { ignoreACL: true });
322
- // A ServerId that resolves to an item outside this (already ACL-verified-for-READ) collection is
323
- // reported identically to "doesn't exist" - never reveal that it's real, just filed elsewhere (a
324
- // different folder, or another mailbox's entirely). See this class's own doc comment.
325
- if (!existing || existing.folderUid !== folderUid) {
326
- return { response: this.statusResponseElement("Change", serverId, "8") };
518
+ // An item outside this (READ-checked) collection is reported identically to "doesn't exist".
519
+ if (!existing || existing.folderUid !== folder.uid) {
520
+ return this.statusResponseElement("Change", serverId, "8");
327
521
  }
328
- if (!(await this.aclUtils.hasPermission(ctx.user, folderUid, ACLAction.UPDATE))) {
329
- return { response: this.statusResponseElement("Change", serverId, "6") };
522
+ if (!(await this.aclUtils.hasPermission(ctx.user, folder.uid, ACLAction.UPDATE))) {
523
+ return this.statusResponseElement("Change", serverId, "6");
330
524
  }
331
525
  const appData = findChild(el, "ApplicationData");
332
526
  if (!appData) {
333
- return { response: this.statusResponseElement("Change", serverId, "6") };
527
+ return this.statusResponseElement("Change", serverId, "6");
334
528
  }
335
529
  try {
336
- const partial = await adapter.fromApplicationData(appData, existing);
337
- const updated = await repo.update({ uid: existing.uid, version: existing.version, ...partial }, existing, { ignoreACL: true });
338
- // Success is silent per [MS-ASCMD]'s own "the client only receives responses for ... failed
339
- // changes" rule - no response entry.
340
- return { writtenAt: updated.dateModified };
530
+ const partial = await adapter.fromApplicationData(appData, existing, await round.getFolderMailbox());
531
+ const updated = await repo.update({ uid: existing.uid, version: existing.version, ...partial }, asEntity(repo, existing), { ignoreACL: true });
532
+ this.noteWrite(round, updated);
533
+ return undefined;
341
534
  }
342
535
  catch (err) {
343
536
  if (err instanceof ApiError && err.code === ApiErrors.INVALID_OBJECT_VERSION) {
344
- return { response: this.statusResponseElement("Change", serverId, "7") };
537
+ return this.statusResponseElement("Change", serverId, "7");
345
538
  }
346
- return { response: this.statusResponseElement("Change", serverId, "6") };
539
+ return this.statusResponseElement("Change", serverId, "6");
347
540
  }
348
541
  }
349
- async applyDelete(ctx, repo, folderUid, el) {
542
+ async applyDelete(round, el) {
543
+ const { ctx, adapter, repo, folder } = round;
350
544
  const serverId = childText(el, "ServerId");
351
545
  if (!serverId) {
352
- return {};
546
+ return undefined;
353
547
  }
354
548
  const existing = await repo.findOne(serverId, { ignoreACL: true });
355
- // Same "treat as not found" rule applyChange uses - see its own comment.
356
- if (!existing || existing.folderUid !== folderUid) {
357
- return { response: this.statusResponseElement("Delete", serverId, "8") };
549
+ if (!existing || existing.folderUid !== folder.uid) {
550
+ // The retried round already deleted (or moved) this item - the device's retry is already satisfied.
551
+ if (round.retry?.removedIds.has(serverId)) {
552
+ round.working.serverIds.delete(serverId);
553
+ return undefined;
554
+ }
555
+ return this.statusResponseElement("Delete", serverId, "8");
556
+ }
557
+ if (!(await this.aclUtils.hasPermission(ctx.user, folder.uid, ACLAction.DELETE))) {
558
+ return this.statusResponseElement("Delete", serverId, "6");
358
559
  }
359
- if (!(await this.aclUtils.hasPermission(ctx.user, folderUid, ACLAction.DELETE))) {
360
- return { response: this.statusResponseElement("Delete", serverId, "6") };
560
+ // Like restapi's delete (409), never while a send of the message is in flight - a moved or deleted message could
561
+ // miss its relay marker and be sent again.
562
+ if (hasLiveSendLease(existing)) {
563
+ return this.statusResponseElement("Delete", serverId, "6");
361
564
  }
362
565
  try {
363
- await repo.delete(existing.uid, { ignoreACL: true });
364
- // Re-read the now-soft-deleted row's own `dateModified` (`RecoverableRepoUtils.delete()` stamps it
365
- // as part of the delete itself) rather than approximating with a fresh `new Date()` here. A plain
366
- // `new Date()` captured after the write resolves is always >= that real timestamp (the delete's own
367
- // internal write already completed by the time this line runs) - close enough for THIS row, but
368
- // `newWatermark` is folder-wide: if it's inflated even slightly past this row's true write time, it
369
- // can also run past a genuinely concurrent, unrelated write to a DIFFERENT message in the same
370
- // folder that `computeChanges` already missed (it snapshotted before this Delete ran), permanently
371
- // skipping that other change instead of picking it up next round. Falls back to `new Date()` only
372
- // if the re-read is unexpectedly empty, which real code paths never hit.
373
- const deleted = await repo.findOne(existing.uid, { ignoreACL: true, includeDeleted: true });
374
- return { writtenAt: deleted?.dateModified ?? new Date() };
566
+ if (round.collectionClass === "Email" && round.deletesAsMoves && folder.type !== FolderType.DELETED_ITEMS) {
567
+ const deletedItems = await findOrCreateWellKnownFolder(this.folderRepo, this.folderClass, folder.mailboxUid, FolderType.DELETED_ITEMS, ctx.user);
568
+ // A move out of Outbox cancels the scheduled send (or is refused once the message was relayed).
569
+ const plan = planMessageMove(existing, folder.type, FolderType.DELETED_ITEMS);
570
+ if (!plan.allowed) {
571
+ return this.statusResponseElement("Delete", serverId, "6");
572
+ }
573
+ await repo.update({ uid: existing.uid, version: existing.version, folderUid: deletedItems.uid, ...plan.patch }, asEntity(repo, existing), {
574
+ ignoreACL: true,
575
+ user: ctx.user,
576
+ });
577
+ }
578
+ else {
579
+ const stamp = adapter.beforeDelete ? adapter.beforeDelete(existing, await round.getFolderMailbox()) : undefined;
580
+ if (stamp) {
581
+ await repo.update({ uid: existing.uid, version: existing.version, ...stamp }, asEntity(repo, existing), { ignoreACL: true });
582
+ }
583
+ await repo.delete(existing.uid, { ignoreACL: true });
584
+ }
585
+ if (round.collectionClass === "Email") {
586
+ await round.audit.record({
587
+ action: AuditAction.MESSAGE_DELETE,
588
+ mailboxUid: existing.mailboxUid,
589
+ targetType: "Message",
590
+ targetUid: existing.uid,
591
+ details: {
592
+ subject: existing.subject,
593
+ folderUid: existing.folderUid,
594
+ movedToDeletedItems: round.deletesAsMoves && folder.type !== FolderType.DELETED_ITEMS,
595
+ },
596
+ });
597
+ }
598
+ round.working.serverIds.delete(serverId);
599
+ round.working.echoes.delete(serverId);
600
+ return undefined;
375
601
  }
376
602
  catch {
377
- return { response: this.statusResponseElement("Delete", serverId, "6") };
603
+ return this.statusResponseElement("Delete", serverId, "6");
378
604
  }
379
605
  }
380
- /** Builds one `<Collection>` response element - `handle()` collects one of these per request `<Collection>`
381
- * and wraps the whole set in a single `<Sync><Collections>`. */
606
+ /** Builds one `<Collection>` response element. */
382
607
  collectionResponse(collectionClass, folderUid, status, syncKey, extra = []) {
383
608
  return element(WbxmlCodePage.AirSync, "Collection", [
384
609
  ...(collectionClass ? [textElement(WbxmlCodePage.AirSync, "Class", collectionClass)] : []),
@@ -393,10 +618,22 @@ __decorate([
393
618
  Config("mail:eas:sync_window_size", DEFAULT_WINDOW_SIZE),
394
619
  __metadata("design:type", Number)
395
620
  ], SyncCommand.prototype, "windowSize", void 0);
621
+ __decorate([
622
+ Config("datastores:cache", null),
623
+ __metadata("design:type", Object)
624
+ ], SyncCommand.prototype, "cacheConfig", void 0);
625
+ __decorate([
626
+ Config(),
627
+ __metadata("design:type", Object)
628
+ ], SyncCommand.prototype, "config", void 0);
396
629
  __decorate([
397
630
  Inject(ACLUtils),
398
631
  __metadata("design:type", ACLUtils)
399
632
  ], SyncCommand.prototype, "aclUtils", void 0);
633
+ __decorate([
634
+ Logger,
635
+ __metadata("design:type", Object)
636
+ ], SyncCommand.prototype, "logger", void 0);
400
637
  __decorate([
401
638
  Init,
402
639
  __metadata("design:type", Function),