@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.
- package/README.md +4 -4
- package/dist/lib/BaseDeviceSyncStateRoute.js +32 -0
- package/dist/lib/BaseDeviceSyncStateRoute.js.map +1 -1
- package/dist/lib/BaseEasRoute.js +91 -15
- package/dist/lib/BaseEasRoute.js.map +1 -1
- package/dist/lib/EasAuditLog.js +56 -0
- package/dist/lib/EasAuditLog.js.map +1 -0
- package/dist/lib/EasCollectionLease.js +199 -0
- package/dist/lib/EasCollectionLease.js.map +1 -0
- package/dist/lib/EasCollectionStore.js +102 -0
- package/dist/lib/EasCollectionStore.js.map +1 -0
- package/dist/lib/EasCollectionSync.js +294 -0
- package/dist/lib/EasCollectionSync.js.map +1 -0
- package/dist/lib/EasSyncKeyUtils.js +125 -62
- package/dist/lib/EasSyncKeyUtils.js.map +1 -1
- package/dist/lib/MessageMoveRules.js +100 -0
- package/dist/lib/MessageMoveRules.js.map +1 -0
- package/dist/lib/MimeHeaderUtils.js +395 -0
- package/dist/lib/MimeHeaderUtils.js.map +1 -0
- package/dist/lib/RegexPatternUtils.js +30 -0
- package/dist/lib/RegexPatternUtils.js.map +1 -0
- package/dist/lib/RestapiCompat.js +41 -0
- package/dist/lib/RestapiCompat.js.map +1 -0
- package/dist/lib/adapters/CalendarSyncAdapter.js +142 -33
- package/dist/lib/adapters/CalendarSyncAdapter.js.map +1 -1
- package/dist/lib/adapters/EmailSyncAdapter.js +114 -27
- package/dist/lib/adapters/EmailSyncAdapter.js.map +1 -1
- package/dist/lib/adapters/mongo/EmailSyncAdapterMongo.js +2 -1
- package/dist/lib/adapters/mongo/EmailSyncAdapterMongo.js.map +1 -1
- package/dist/lib/adapters/sql/EmailSyncAdapterSQL.js +2 -1
- package/dist/lib/adapters/sql/EmailSyncAdapterSQL.js.map +1 -1
- package/dist/lib/codec/WbxmlDecoder.js +86 -34
- package/dist/lib/codec/WbxmlDecoder.js.map +1 -1
- package/dist/lib/codec/WbxmlEncoder.js +103 -22
- package/dist/lib/codec/WbxmlEncoder.js.map +1 -1
- package/dist/lib/commands/ComposeMailCommand.js +105 -31
- package/dist/lib/commands/ComposeMailCommand.js.map +1 -1
- package/dist/lib/commands/FolderSyncCommand.js +67 -27
- package/dist/lib/commands/FolderSyncCommand.js.map +1 -1
- package/dist/lib/commands/GetItemEstimateCommand.js +80 -59
- package/dist/lib/commands/GetItemEstimateCommand.js.map +1 -1
- package/dist/lib/commands/ItemOperationsCommand.js +219 -56
- package/dist/lib/commands/ItemOperationsCommand.js.map +1 -1
- package/dist/lib/commands/MeetingResponseCommand.js +212 -53
- package/dist/lib/commands/MeetingResponseCommand.js.map +1 -1
- package/dist/lib/commands/MoveItemsCommand.js +54 -21
- package/dist/lib/commands/MoveItemsCommand.js.map +1 -1
- package/dist/lib/commands/PingCommand.js +288 -56
- package/dist/lib/commands/PingCommand.js.map +1 -1
- package/dist/lib/commands/ProvisionCommand.js +34 -17
- package/dist/lib/commands/ProvisionCommand.js.map +1 -1
- package/dist/lib/commands/ResolveRecipientsCommand.js +43 -5
- package/dist/lib/commands/ResolveRecipientsCommand.js.map +1 -1
- package/dist/lib/commands/SearchCommand.js +92 -17
- package/dist/lib/commands/SearchCommand.js.map +1 -1
- package/dist/lib/commands/SettingsCommand.js +2 -1
- package/dist/lib/commands/SettingsCommand.js.map +1 -1
- package/dist/lib/commands/SmartForwardCommand.js +2 -1
- package/dist/lib/commands/SmartForwardCommand.js.map +1 -1
- package/dist/lib/commands/SmartReplyCommand.js +2 -1
- package/dist/lib/commands/SmartReplyCommand.js.map +1 -1
- package/dist/lib/commands/SyncCommand.js +481 -244
- package/dist/lib/commands/SyncCommand.js.map +1 -1
- package/dist/lib/commands/mongo/GetItemEstimateCommandMongo.js +6 -1
- package/dist/lib/commands/mongo/GetItemEstimateCommandMongo.js.map +1 -1
- package/dist/lib/commands/mongo/ItemOperationsCommandMongo.js +3 -1
- package/dist/lib/commands/mongo/ItemOperationsCommandMongo.js.map +1 -1
- package/dist/lib/commands/mongo/MeetingResponseCommandMongo.js +2 -1
- package/dist/lib/commands/mongo/MeetingResponseCommandMongo.js.map +1 -1
- package/dist/lib/commands/mongo/PingCommandMongo.js +23 -0
- package/dist/lib/commands/mongo/PingCommandMongo.js.map +1 -0
- package/dist/lib/commands/mongo/SearchCommandMongo.js +3 -1
- package/dist/lib/commands/mongo/SearchCommandMongo.js.map +1 -1
- package/dist/lib/commands/mongo/SendMailCommandMongo.js +2 -1
- package/dist/lib/commands/mongo/SendMailCommandMongo.js.map +1 -1
- package/dist/lib/commands/mongo/SmartForwardCommandMongo.js +2 -1
- package/dist/lib/commands/mongo/SmartForwardCommandMongo.js.map +1 -1
- package/dist/lib/commands/mongo/SmartReplyCommandMongo.js +2 -1
- package/dist/lib/commands/mongo/SmartReplyCommandMongo.js.map +1 -1
- package/dist/lib/commands/mongo/SyncCommandMongo.js +7 -1
- package/dist/lib/commands/mongo/SyncCommandMongo.js.map +1 -1
- package/dist/lib/commands/mongo/index.js +1 -0
- package/dist/lib/commands/mongo/index.js.map +1 -1
- package/dist/lib/commands/sql/GetItemEstimateCommandSQL.js +6 -1
- package/dist/lib/commands/sql/GetItemEstimateCommandSQL.js.map +1 -1
- package/dist/lib/commands/sql/ItemOperationsCommandSQL.js +3 -1
- package/dist/lib/commands/sql/ItemOperationsCommandSQL.js.map +1 -1
- package/dist/lib/commands/sql/MeetingResponseCommandSQL.js +2 -1
- package/dist/lib/commands/sql/MeetingResponseCommandSQL.js.map +1 -1
- package/dist/lib/commands/sql/PingCommandSQL.js +23 -0
- package/dist/lib/commands/sql/PingCommandSQL.js.map +1 -0
- package/dist/lib/commands/sql/SearchCommandSQL.js +3 -1
- package/dist/lib/commands/sql/SearchCommandSQL.js.map +1 -1
- package/dist/lib/commands/sql/SendMailCommandSQL.js +2 -1
- package/dist/lib/commands/sql/SendMailCommandSQL.js.map +1 -1
- package/dist/lib/commands/sql/SmartForwardCommandSQL.js +2 -1
- package/dist/lib/commands/sql/SmartForwardCommandSQL.js.map +1 -1
- package/dist/lib/commands/sql/SmartReplyCommandSQL.js +2 -1
- package/dist/lib/commands/sql/SmartReplyCommandSQL.js.map +1 -1
- package/dist/lib/commands/sql/SyncCommandSQL.js +7 -1
- package/dist/lib/commands/sql/SyncCommandSQL.js.map +1 -1
- package/dist/lib/commands/sql/index.js +1 -0
- package/dist/lib/commands/sql/index.js.map +1 -1
- package/dist/lib/index.js +5 -0
- package/dist/lib/index.js.map +1 -1
- package/dist/lib/jobs/EasDeviceStateCleanupJob.js +44 -2
- package/dist/lib/jobs/EasDeviceStateCleanupJob.js.map +1 -1
- package/dist/lib/jobs/mongo/EasDeviceStateCleanupJobMongo.js +4 -0
- package/dist/lib/jobs/mongo/EasDeviceStateCleanupJobMongo.js.map +1 -1
- package/dist/lib/jobs/sql/EasDeviceStateCleanupJobSQL.js +9 -0
- package/dist/lib/jobs/sql/EasDeviceStateCleanupJobSQL.js.map +1 -1
- package/dist/lib/models/EasCollectionChunk.js +2 -0
- package/dist/lib/models/EasCollectionChunk.js.map +1 -0
- package/dist/lib/models/EasCollectionState.js +2 -0
- package/dist/lib/models/EasCollectionState.js.map +1 -0
- package/dist/lib/models/mongo/DeviceSyncStateMongo.js +7 -0
- package/dist/lib/models/mongo/DeviceSyncStateMongo.js.map +1 -1
- package/dist/lib/models/mongo/EasCollectionChunkMongo.js +83 -0
- package/dist/lib/models/mongo/EasCollectionChunkMongo.js.map +1 -0
- package/dist/lib/models/mongo/EasCollectionStateMongo.js +162 -0
- package/dist/lib/models/mongo/EasCollectionStateMongo.js.map +1 -0
- package/dist/lib/models/sql/DeviceSyncStateSQL.js +7 -0
- package/dist/lib/models/sql/DeviceSyncStateSQL.js.map +1 -1
- package/dist/lib/models/sql/EasCollectionChunkSQL.js +83 -0
- package/dist/lib/models/sql/EasCollectionChunkSQL.js.map +1 -0
- package/dist/lib/models/sql/EasCollectionStateSQL.js +162 -0
- package/dist/lib/models/sql/EasCollectionStateSQL.js.map +1 -0
- package/dist/lib/mongo/EasRouteMongo.js +2 -2
- package/dist/lib/mongo/EasRouteMongo.js.map +1 -1
- package/dist/lib/mongo.js +2 -0
- package/dist/lib/mongo.js.map +1 -1
- package/dist/lib/sql/EasRouteSQL.js +2 -2
- package/dist/lib/sql/EasRouteSQL.js.map +1 -1
- package/dist/lib/sql.js +2 -0
- package/dist/lib/sql.js.map +1 -1
- package/dist/types/BaseDeviceSyncStateRoute.d.ts +10 -0
- package/dist/types/BaseEasRoute.d.ts +15 -4
- package/dist/types/EasAuditLog.d.ts +47 -0
- package/dist/types/EasCollectionLease.d.ts +60 -0
- package/dist/types/EasCollectionStore.d.ts +51 -0
- package/dist/types/EasCollectionSync.d.ts +96 -0
- package/dist/types/EasCommandHandler.d.ts +7 -4
- package/dist/types/EasSyncKeyUtils.d.ts +73 -42
- package/dist/types/MessageMoveRules.d.ts +45 -0
- package/dist/types/MimeHeaderUtils.d.ts +73 -0
- package/dist/types/RegexPatternUtils.d.ts +14 -0
- package/dist/types/RestapiCompat.d.ts +24 -0
- package/dist/types/adapters/CalendarSyncAdapter.d.ts +47 -18
- package/dist/types/adapters/EasCollectionSyncAdapter.d.ts +18 -2
- package/dist/types/adapters/EmailSyncAdapter.d.ts +24 -7
- package/dist/types/adapters/mongo/EmailSyncAdapterMongo.d.ts +1 -0
- package/dist/types/adapters/sql/EmailSyncAdapterSQL.d.ts +1 -0
- package/dist/types/codec/WbxmlDecoder.d.ts +39 -0
- package/dist/types/codec/WbxmlEncoder.d.ts +36 -1
- package/dist/types/commands/ComposeMailCommand.d.ts +33 -15
- package/dist/types/commands/FolderSyncCommand.d.ts +4 -1
- package/dist/types/commands/GetItemEstimateCommand.d.ts +20 -15
- package/dist/types/commands/ItemOperationsCommand.d.ts +29 -4
- package/dist/types/commands/MeetingResponseCommand.d.ts +52 -13
- package/dist/types/commands/MoveItemsCommand.d.ts +13 -3
- package/dist/types/commands/PingCommand.d.ts +72 -14
- package/dist/types/commands/ProvisionCommand.d.ts +16 -11
- package/dist/types/commands/ResolveRecipientsCommand.d.ts +4 -0
- package/dist/types/commands/SearchCommand.d.ts +13 -1
- package/dist/types/commands/SyncCommand.d.ts +102 -62
- package/dist/types/commands/mongo/GetItemEstimateCommandMongo.d.ts +3 -0
- package/dist/types/commands/mongo/ItemOperationsCommandMongo.d.ts +2 -0
- package/dist/types/commands/mongo/MeetingResponseCommandMongo.d.ts +1 -0
- package/dist/types/commands/mongo/PingCommandMongo.d.ts +8 -0
- package/dist/types/commands/mongo/SearchCommandMongo.d.ts +2 -0
- package/dist/types/commands/mongo/SendMailCommandMongo.d.ts +1 -0
- package/dist/types/commands/mongo/SmartForwardCommandMongo.d.ts +1 -0
- package/dist/types/commands/mongo/SmartReplyCommandMongo.d.ts +1 -0
- package/dist/types/commands/mongo/SyncCommandMongo.d.ts +4 -0
- package/dist/types/commands/mongo/index.d.ts +1 -0
- package/dist/types/commands/sql/GetItemEstimateCommandSQL.d.ts +3 -0
- package/dist/types/commands/sql/ItemOperationsCommandSQL.d.ts +2 -0
- package/dist/types/commands/sql/MeetingResponseCommandSQL.d.ts +1 -0
- package/dist/types/commands/sql/PingCommandSQL.d.ts +8 -0
- package/dist/types/commands/sql/SearchCommandSQL.d.ts +2 -0
- package/dist/types/commands/sql/SendMailCommandSQL.d.ts +1 -0
- package/dist/types/commands/sql/SmartForwardCommandSQL.d.ts +1 -0
- package/dist/types/commands/sql/SmartReplyCommandSQL.d.ts +1 -0
- package/dist/types/commands/sql/SyncCommandSQL.d.ts +4 -0
- package/dist/types/commands/sql/index.d.ts +1 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/jobs/EasDeviceStateCleanupJob.d.ts +15 -0
- package/dist/types/jobs/mongo/EasDeviceStateCleanupJobMongo.d.ts +2 -0
- package/dist/types/jobs/sql/EasDeviceStateCleanupJobSQL.d.ts +4 -0
- package/dist/types/models/DeviceSyncState.d.ts +13 -2
- package/dist/types/models/EasCollectionChunk.d.ts +20 -0
- package/dist/types/models/EasCollectionState.d.ts +79 -0
- package/dist/types/models/mongo/DeviceSyncStateMongo.d.ts +1 -0
- package/dist/types/models/mongo/EasCollectionChunkMongo.d.ts +16 -0
- package/dist/types/models/mongo/EasCollectionStateMongo.d.ts +27 -0
- package/dist/types/models/sql/DeviceSyncStateSQL.d.ts +1 -0
- package/dist/types/models/sql/EasCollectionChunkSQL.d.ts +16 -0
- package/dist/types/models/sql/EasCollectionStateSQL.d.ts +27 -0
- package/dist/types/mongo.d.ts +2 -0
- package/dist/types/sql.d.ts +2 -0
- 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 {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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
|
-
* **
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
-
* **
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
* **
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* `
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
|
86
|
-
//
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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(
|
|
157
|
+
cached = await this.mailboxRepo.findOne(mailboxUid, { ignoreACL: true });
|
|
103
158
|
if (!cached) {
|
|
104
|
-
throw new ApiError(ApiErrors.NOT_FOUND, 404, "The
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
293
|
-
|
|
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
|
|
299
|
-
const
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
//
|
|
308
|
-
|
|
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(
|
|
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
|
|
515
|
+
return this.statusResponseElement("Change", serverId, "6");
|
|
320
516
|
}
|
|
321
517
|
const existing = await repo.findOne(serverId, { ignoreACL: true });
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
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,
|
|
329
|
-
return
|
|
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
|
|
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
|
-
|
|
339
|
-
|
|
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
|
|
537
|
+
return this.statusResponseElement("Change", serverId, "7");
|
|
345
538
|
}
|
|
346
|
-
return
|
|
539
|
+
return this.statusResponseElement("Change", serverId, "6");
|
|
347
540
|
}
|
|
348
541
|
}
|
|
349
|
-
async applyDelete(
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
603
|
+
return this.statusResponseElement("Delete", serverId, "6");
|
|
378
604
|
}
|
|
379
605
|
}
|
|
380
|
-
/** Builds one `<Collection>` response element
|
|
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),
|