@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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { RecoverableBaseEntity, RepoUtils } from "@rapidrest/service-core";
|
|
2
|
+
import { FolderType } from "@rapidmx/restapi";
|
|
3
|
+
import { type ChangeCursor } from "./EasSyncKeyUtils.js";
|
|
4
|
+
import type { EasCollectionRound, EasCollectionState } from "./models/EasCollectionState.js";
|
|
5
|
+
/** How far behind each cursor a round re-reads the stream for rows committed out of order by another replica. */
|
|
6
|
+
export declare const DEFAULT_OVERLAP_MS = 5000;
|
|
7
|
+
/** Most rows one overlap re-read returns, and most entries `recent` keeps. */
|
|
8
|
+
export declare const DEFAULT_OVERLAP_LIMIT = 1000;
|
|
9
|
+
/** The in-memory, mutable form of an `EasCollectionState` for one `Sync`/`GetItemEstimate` round. */
|
|
10
|
+
export interface CollectionWorkingState {
|
|
11
|
+
generation: number;
|
|
12
|
+
cursor: ChangeCursor;
|
|
13
|
+
moveCursor: ChangeCursor;
|
|
14
|
+
serverIds: Set<string>;
|
|
15
|
+
echoes: Map<string, string>;
|
|
16
|
+
/** Rows the folder stream processed within the overlap window (see `EasCollectionState.recent`). `undefined` for a
|
|
17
|
+
* row written before overlap tracking existed - the rows currently in the window are then taken as reported. */
|
|
18
|
+
recent?: Map<string, string>;
|
|
19
|
+
/** See `EasCollectionState.reconcileCursor`. */
|
|
20
|
+
reconcileCursor: string;
|
|
21
|
+
/** `undefined` when the collection was started without a `FilterType`. */
|
|
22
|
+
filterType?: string;
|
|
23
|
+
}
|
|
24
|
+
/** One server-side change to report to the device. */
|
|
25
|
+
export type CollectionCommand<T> = {
|
|
26
|
+
kind: "Add" | "Change";
|
|
27
|
+
item: T;
|
|
28
|
+
} | {
|
|
29
|
+
kind: "Delete";
|
|
30
|
+
uid: string;
|
|
31
|
+
};
|
|
32
|
+
/** Loads the working state for the round a client's current `SyncKey` (`state.syncKey`) continues. `held` is the
|
|
33
|
+
* collection's held set (see `EasCollectionStore.loadHeldSet`); defaults to the inline `serverIds`. */
|
|
34
|
+
export declare function workingStateFromRow(state: EasCollectionState, held?: Set<string>): CollectionWorkingState;
|
|
35
|
+
/** Rebuilds the working state as it stood before the most recent round, for a client retrying `round.syncKey`. */
|
|
36
|
+
export declare function workingStateFromRound(state: EasCollectionState, round: EasCollectionRound, held?: Set<string>): CollectionWorkingState;
|
|
37
|
+
/** Deep-copies a working state so a dry run (`GetItemEstimate`) can't disturb the original. */
|
|
38
|
+
export declare function cloneWorkingState(state: CollectionWorkingState): CollectionWorkingState;
|
|
39
|
+
/** Records the round that turned `base` into `result` (see `EasCollectionRound`). */
|
|
40
|
+
export declare function roundRecord(syncKey: string, base: CollectionWorkingState, result: CollectionWorkingState, clientIds: Map<string, string>): EasCollectionRound;
|
|
41
|
+
export interface EnumerateCollectionOptions<T> {
|
|
42
|
+
repo: RepoUtils<T & RecoverableBaseEntity>;
|
|
43
|
+
folderUid: string;
|
|
44
|
+
/** The mailbox that owns the folder - items moved out of it stay within this mailbox. */
|
|
45
|
+
folderMailboxUid: string;
|
|
46
|
+
/** Most commands to report. */
|
|
47
|
+
windowSize: number;
|
|
48
|
+
/** Rows read from the out-of-folder stream per round. */
|
|
49
|
+
moveScanLimit: number;
|
|
50
|
+
/** `FilterType` window: an item the device doesn't hold yet is only added when this returns `true`. */
|
|
51
|
+
include?: (item: T) => boolean;
|
|
52
|
+
/** How far behind each cursor the streams are re-read (default `DEFAULT_OVERLAP_MS`; `0` disables). */
|
|
53
|
+
overlapMs?: number;
|
|
54
|
+
/** Most rows per overlap re-read and entries kept in `recent` (default `DEFAULT_OVERLAP_LIMIT`). */
|
|
55
|
+
overlapLimit?: number;
|
|
56
|
+
/** Held `ServerId`s checked against the store per caught-up round (`0`/absent disables the reconcile). */
|
|
57
|
+
reconcileLimit?: number;
|
|
58
|
+
now?: Date;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Computes one round of server-side changes for a `Sync` collection and advances `state` past them:
|
|
62
|
+
*
|
|
63
|
+
* - **Out-of-folder stream** (rows of the same mailbox in any other folder after `state.moveCursor`, live or
|
|
64
|
+
* deleted): an item the device holds has been moved out of this folder and is a `Delete`. Read before the folder
|
|
65
|
+
* rows are processed, so when an item moves between the two reads the out-of-folder row (the newer one) wins.
|
|
66
|
+
* Skipped while the device holds nothing; the cursor is then fast-forwarded to just before the round started
|
|
67
|
+
* instead, so the first real scan doesn't have to crawl the whole mailbox history.
|
|
68
|
+
* - **Folder stream** (rows of this folder after `state.cursor`): a live item the device holds is a `Change`, one
|
|
69
|
+
* it doesn't hold is an `Add` (subject to `include`), a soft-deleted item it holds is a `Delete`; a row whose
|
|
70
|
+
* `dateModified` still equals the device's own recorded write (`state.echoes`) is skipped.
|
|
71
|
+
* - **Overlap**: `dateModified` is stamped before a write commits, so with several replicas a row can become visible
|
|
72
|
+
* after a cursor has already passed its timestamp. Both streams therefore also re-read the last `overlapMs` behind
|
|
73
|
+
* their cursor. Out-of-folder rows are idempotent (an item is only deleted while held), so they're simply
|
|
74
|
+
* re-applied; folder rows are deduplicated against `state.recent` (uid -> the `dateModified` it was processed at),
|
|
75
|
+
* and only a row not recorded there at that timestamp - one that became visible late - is processed.
|
|
76
|
+
* - **Reconcile**: a hard-purged row leaves no trace in either stream. Once a round has caught up (nothing more
|
|
77
|
+
* available), up to `reconcileLimit` held ids after `state.reconcileCursor` are looked up in the folder, and each
|
|
78
|
+
* one that no longer exists there is a `Delete`; the cursor wraps around after the last held id.
|
|
79
|
+
*
|
|
80
|
+
* Both cursors only ever advance past rows actually processed - never to "now" - so nothing is skipped when the
|
|
81
|
+
* window fills up (`moreAvailable`).
|
|
82
|
+
*/
|
|
83
|
+
export declare function enumerateCollection<T extends RecoverableBaseEntity>(state: CollectionWorkingState, options: EnumerateCollectionOptions<T>): Promise<{
|
|
84
|
+
commands: CollectionCommand<T>[];
|
|
85
|
+
moreAvailable: boolean;
|
|
86
|
+
}>;
|
|
87
|
+
/** The `Class` a folder of `type` holds - for a `Sync`/`GetItemEstimate` request that omits `Class` for a collection
|
|
88
|
+
* with no remembered class. */
|
|
89
|
+
export declare function classForFolderType(type: FolderType): string;
|
|
90
|
+
/**
|
|
91
|
+
* Builds the `include` predicate for a collection's `FilterType`, or `undefined` for no filtering: `Email` keeps
|
|
92
|
+
* messages received within the window; `Calendar` keeps recurring events and events ending within it; `Tasks`
|
|
93
|
+
* keeps incomplete tasks for `8`. Values a class doesn't define (and no `FilterType` at all) are treated as "no
|
|
94
|
+
* filter".
|
|
95
|
+
*/
|
|
96
|
+
export declare function filterPredicate(collectionClass: string, filterType: string | undefined, now?: Date): ((item: any) => boolean) | undefined;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { JWTUser } from "@rapidrest/core";
|
|
2
|
-
import type { HttpRequest, RepoUtils } from "@rapidrest/service-core";
|
|
2
|
+
import type { HttpRequest, HttpResponse, RepoUtils } from "@rapidrest/service-core";
|
|
3
3
|
import type { WbxmlElement } from "./codec/WbxmlElement.js";
|
|
4
4
|
import type { DeviceSyncState } from "./models/DeviceSyncState.js";
|
|
5
5
|
/**
|
|
@@ -18,9 +18,9 @@ export interface EasCommandContext {
|
|
|
18
18
|
readonly deviceId: string;
|
|
19
19
|
/** The client-supplied `?DeviceType=` query value (e.g. `iPhone`, `Android`). */
|
|
20
20
|
readonly deviceType: string;
|
|
21
|
-
/** The
|
|
22
|
-
*
|
|
23
|
-
* `
|
|
21
|
+
/** The policy key the device presented (`X-MS-PolicyKey` header, else the `?PolicyKey=` query value). For every
|
|
22
|
+
* command except `Provision`/`Settings`, `BaseEasRoute` has already refused the request unless this equals the
|
|
23
|
+
* stored `deviceSyncState.policyKey`. */
|
|
24
24
|
readonly policyKey?: string;
|
|
25
25
|
/** This device's persisted sync/provisioning state, looked up (or newly created) by `BaseEasRoute` before
|
|
26
26
|
* dispatch. Handlers read/write cursor and provisioning fields on this directly. */
|
|
@@ -41,6 +41,9 @@ export interface EasCommandContext {
|
|
|
41
41
|
/** The raw underlying HTTP request, for the rare handler that needs something this context doesn't
|
|
42
42
|
* already surface (e.g. a header). */
|
|
43
43
|
readonly req: HttpRequest;
|
|
44
|
+
/** The underlying HTTP response, when dispatched over HTTP - `PingCommand` registers `onFinish()` on it so a
|
|
45
|
+
* long-poll stops waiting as soon as the client disconnects. Optional so isolated handler tests can omit it. */
|
|
46
|
+
readonly res?: HttpResponse;
|
|
44
47
|
}
|
|
45
48
|
/**
|
|
46
49
|
* One EAS protocol command (`Provision`, `FolderSync`, `Sync`, ...). `BaseEasRoute` builds one instance of
|
|
@@ -1,14 +1,38 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type RecoverableBaseEntity, type RepoUtils } from "@rapidrest/service-core";
|
|
2
2
|
import type { DeviceSyncState } from "./models/DeviceSyncState.js";
|
|
3
|
+
/**
|
|
4
|
+
* A position in a `(dateModified, uid)`-ordered change stream. `dateModified` alone is not a total order - several
|
|
5
|
+
* rows can share one timestamp (a bulk update inside one millisecond) - so a cursor that only remembered the
|
|
6
|
+
* timestamp would either re-send or skip the rows sharing the page boundary's timestamp. `uid` breaks the tie;
|
|
7
|
+
* `""` means "before every row at `date`".
|
|
8
|
+
*/
|
|
9
|
+
export interface ChangeCursor {
|
|
10
|
+
date: Date;
|
|
11
|
+
uid: string;
|
|
12
|
+
}
|
|
13
|
+
/** Whether `uid` can be listed in an `in(...)` query operand as itself: no comma (the operand separator), no parentheses
|
|
14
|
+
* or other punctuation, and not one of the literals the query parser substitutes (`me`, `null`). */
|
|
15
|
+
export declare function isListableUid(uid: string): boolean;
|
|
16
|
+
/** The cursor that precedes every row ever written. */
|
|
17
|
+
export declare function epochCursor(): ChangeCursor;
|
|
18
|
+
/** Orders two `(date, uid)` positions: negative when `a` sorts before `b`, positive when after, `0` when equal. */
|
|
19
|
+
export declare function compareCursor(a: ChangeCursor, b: ChangeCursor): number;
|
|
20
|
+
/** The cursor position of `row`. */
|
|
21
|
+
export declare function cursorOf(row: {
|
|
22
|
+
dateModified: Date | string;
|
|
23
|
+
uid: string;
|
|
24
|
+
}): ChangeCursor;
|
|
3
25
|
/**
|
|
4
26
|
* A parsed EAS `SyncKey`. The wire value is opaque to the client per spec, so this library encodes it as
|
|
5
|
-
* `"<generation>:<watermarkIso>"
|
|
6
|
-
*
|
|
7
|
-
*
|
|
27
|
+
* `"<generation>:<watermarkIso>"`, optionally followed by `"#<uid>"` when the cursor has a tie-breaking uid (e.g.
|
|
28
|
+
* `"3:2026-09-04T12:00:00.000Z#6f1c..."`) - a monotonic generation counter (bumped once per successful sync round,
|
|
29
|
+
* satisfying the spec's "the server MUST return a different SyncKey every time" requirement) paired with the
|
|
30
|
+
* `(dateModified, uid)` cursor that generation was issued at.
|
|
8
31
|
*/
|
|
9
32
|
export interface SyncKey {
|
|
10
33
|
generation: number;
|
|
11
34
|
watermark: Date;
|
|
35
|
+
uid?: string;
|
|
12
36
|
}
|
|
13
37
|
/** Formats a `SyncKey` back into its wire string form. */
|
|
14
38
|
export declare function formatSyncKey(key: SyncKey): string;
|
|
@@ -26,7 +50,7 @@ export type SyncKeyResolution = {
|
|
|
26
50
|
};
|
|
27
51
|
/**
|
|
28
52
|
* Resolves an incoming client `SyncKey` string against the value this library itself previously issued and
|
|
29
|
-
* stored (`storedValue`, e.g. `DeviceSyncState.folderSyncKeys[
|
|
53
|
+
* stored (`storedValue`, e.g. `DeviceSyncState.folderSyncKeys["$foldersync"]`) — deliberately a plain string
|
|
30
54
|
* equality check, not a re-parse-and-compare of the client's value, so a client that echoes back anything
|
|
31
55
|
* other than the exact opaque string it was handed is treated as `"invalid"` even if it happens to parse.
|
|
32
56
|
*
|
|
@@ -40,50 +64,57 @@ export type SyncKeyResolution = {
|
|
|
40
64
|
export declare function resolveSyncKey(clientValue: string | undefined, storedValue: string | undefined): SyncKeyResolution;
|
|
41
65
|
/**
|
|
42
66
|
* Applies `patch` to `deviceSyncState` and persists it - the one correct way any EAS command handler (or
|
|
43
|
-
* `BaseEasRoute` itself) should ever write to a `DeviceSyncState
|
|
44
|
-
*
|
|
45
|
-
*
|
|
67
|
+
* `BaseEasRoute` itself) should ever write to a `DeviceSyncState`.
|
|
68
|
+
*
|
|
69
|
+
* `RepoUtils.update()` does **not** mutate the `existing` object passed to it - it only returns a freshly-fetched
|
|
70
|
+
* instance reflecting the write - so the returned row (including its bumped `version`) is copied back onto
|
|
71
|
+
* `deviceSyncState`, keeping a second write later in the same request from being built off a stale version.
|
|
72
|
+
*
|
|
73
|
+
* **Concurrent requests**: one device routinely has several requests in flight at once (a long-poll `Ping`
|
|
74
|
+
* alongside a `Sync`, or two `Sync`s for different folders), and the row's optimistic lock covers the whole row.
|
|
75
|
+
* Losing that race used to surface as a 409 *after* the command's side effects had already happened. Instead, a
|
|
76
|
+
* version conflict re-reads the row, re-applies the patch on top of the fresh copy and retries (bounded). Pass
|
|
77
|
+
* `patch` as a function when it derives from the row's current content (e.g. updating one entry of the
|
|
78
|
+
* `folderSyncKeys` map) so the retry merges into what the other request wrote rather than overwriting it.
|
|
79
|
+
*/
|
|
80
|
+
export declare function persistDeviceSyncState(deviceSyncState: DeviceSyncState, deviceSyncStateRepo: RepoUtils<any>, patch: Record<string, unknown> | ((current: DeviceSyncState) => Record<string, unknown>)): Promise<void>;
|
|
81
|
+
/**
|
|
82
|
+
* Reads up to `limit` rows matching `criteria` that sort strictly after `cursor` in `(dateModified, uid)` order,
|
|
83
|
+
* live and soft-deleted rows merged into one stream. `more` is `true` when rows beyond the returned page exist.
|
|
46
84
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* per request is harmless (nothing re-reads the stale copy), but a *second* write to the same `deviceSyncState`
|
|
51
|
-
* later in the same request - e.g. `BaseEasRoute.dispatch()`'s own trailing `lastSyncAt` update, or a second
|
|
52
|
-
* collection's SyncKey in a multi-collection `Sync` - would build its own patch's `version` off that stale
|
|
53
|
-
* value. `RepoUtils.update()` filters its underlying write by exactly that `version`, so the write silently
|
|
54
|
-
* matches zero rows instead of throwing - a silent no-op, not a visible error. Copying `update()`'s own
|
|
55
|
-
* returned result back onto `deviceSyncState` (last line below) keeps every field, including `version`, current
|
|
56
|
-
* for whatever writes this same request still has left to make.
|
|
85
|
+
* `RepoUtils.find()` excludes soft-deleted rows unless the query names `deleted` explicitly, so the two halves are
|
|
86
|
+
* separate queries (the second with a literal `deleted: true`), each over-fetched by one and merged. `limit`/`sort`
|
|
87
|
+
* are baked into the query object as well as the options - the SQL query builder only reads the former.
|
|
57
88
|
*/
|
|
58
|
-
export declare function
|
|
59
|
-
|
|
60
|
-
|
|
89
|
+
export declare function scanAfter<T extends RecoverableBaseEntity>(repo: RepoUtils<T>, criteria: Record<string, unknown>, cursor: ChangeCursor, limit: number): Promise<{
|
|
90
|
+
rows: T[];
|
|
91
|
+
more: boolean;
|
|
92
|
+
}>;
|
|
93
|
+
/**
|
|
94
|
+
* Reads the rows matching `criteria` at or before `cursor` whose `dateModified` lies within `windowMs` of it - the
|
|
95
|
+
* part of the stream a cursor has already passed but where a row committed out of order (another replica stamped
|
|
96
|
+
* `dateModified` before this one read past it, but committed after) can still appear. At most `limit` rows, the
|
|
97
|
+
* newest ones, returned in ascending `(dateModified, uid)` order; live and soft-deleted rows merged as `scanAfter`
|
|
98
|
+
* does. Returns nothing for the epoch cursor (nothing has been passed yet).
|
|
99
|
+
*/
|
|
100
|
+
export declare function scanOverlap<T extends RecoverableBaseEntity>(repo: RepoUtils<T>, criteria: Record<string, unknown>, cursor: ChangeCursor, windowMs: number, limit: number): Promise<T[]>;
|
|
101
|
+
/** One page of enumerated changes for a `RecoverableBaseEntity` collection scoped by a single field. */
|
|
61
102
|
export interface ChangeSet<T extends RecoverableBaseEntity> {
|
|
62
103
|
adds: T[];
|
|
63
104
|
changes: T[];
|
|
64
105
|
deletes: T[];
|
|
65
|
-
/** The
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
|
|
69
|
-
/** `true` when more changed rows exist beyond `windowSize
|
|
70
|
-
* `MoreAvailable` flag so the client immediately re-syncs for the rest, rather than waiting a full poll
|
|
71
|
-
* interval. */
|
|
106
|
+
/** The cursor to persist for the next round - the last row actually included in this page, never simply
|
|
107
|
+
* "now" (which would silently skip any row modified after this page was read). Equal to the input cursor when
|
|
108
|
+
* nothing changed. */
|
|
109
|
+
cursor: ChangeCursor;
|
|
110
|
+
/** `true` when more changed rows exist beyond `windowSize`. */
|
|
72
111
|
moreAvailable: boolean;
|
|
73
112
|
}
|
|
74
113
|
/**
|
|
75
|
-
* Enumerates `Add`/`Change`/`Delete`s for one scoped collection
|
|
76
|
-
* (
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* `RepoUtils.find()` does **not** support an `includeDeleted` option - confirmed by reading its source: unlike
|
|
81
|
-
* `count()`/`findOne()`, it never strips `ModelUtils.buildSearchQuery()`'s default `deleted: false` exclusion
|
|
82
|
-
* back out. Rather than the (harmless-looking but silently no-op) `includeDeleted: true` this function used to
|
|
83
|
-
* pass, deleted rows are fetched via a **second** `find()` call with an explicit, literal `deleted: true` in
|
|
84
|
-
* the query object itself - `buildSearchQuery()`'s exclusion only applies when the caller's query has no
|
|
85
|
-
* `"deleted"` key at all, so supplying one directly (rather than relying on an option `find()` doesn't honor)
|
|
86
|
-
* reliably selects exactly the soft-deleted rows instead. The two result sets are then merged and re-windowed
|
|
87
|
-
* together so `windowSize`/`MoreAvailable` still describe the combined stream, not each half independently.
|
|
114
|
+
* Enumerates `Add`/`Change`/`Delete`s for one scoped collection after `cursor` - used by `FolderSyncCommand`
|
|
115
|
+
* (scoped by `mailboxUid` over `Folder`). `Sync` item collections use `EasCollectionSync` instead, which tracks the
|
|
116
|
+
* exact set of items each device holds; the folder hierarchy is small and always fully re-sent on `SyncKey 0`, so
|
|
117
|
+
* a creation-time rule is enough here: a row created after the cursor can't be on the device yet, so a live one is
|
|
118
|
+
* an `Add` (never an `Update` for a `ServerId` the device has never seen) and a deleted one is not reported at all.
|
|
88
119
|
*/
|
|
89
|
-
export declare function computeChanges<T extends RecoverableBaseEntity>(repo: RepoUtils<T>, scopeField: string, scopeUid: string,
|
|
120
|
+
export declare function computeChanges<T extends RecoverableBaseEntity>(repo: RepoUtils<T>, scopeField: string, scopeUid: string, cursor: ChangeCursor, windowSize: number): Promise<ChangeSet<T>>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { FolderType, type Message } from "@rapidmx/restapi";
|
|
2
|
+
/** Whether a send of `message` is in flight: its `scheduledSendLeaseExpiresAt` (set when `send()` or `ScheduledSendJob`
|
|
3
|
+
* claims it for relay) is still in the future. restapi refuses to move or delete such a message (409,
|
|
4
|
+
* `assertNotInFlight()`): a moved or soft-deleted message could miss its relay marker and be sent a second time. */
|
|
5
|
+
export declare function hasLiveSendLease(message: Message): boolean;
|
|
6
|
+
/** The outcome of `planMessageMove()`: refused (with why), or allowed with extra fields the move's update must carry. */
|
|
7
|
+
export type MessageMovePlan = {
|
|
8
|
+
allowed: false;
|
|
9
|
+
reason: "destination" | "sent" | "inFlight";
|
|
10
|
+
} | {
|
|
11
|
+
allowed: true;
|
|
12
|
+
patch: Record<string, unknown>;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Decides whether an ActiveSync client may move `message` from a folder of `sourceType` into one of `destinationType`,
|
|
16
|
+
* matching restapi's own REST rules for a non-trusted caller (`BaseMessageRoute.prepareScheduledSendUpdate()`), plus
|
|
17
|
+
* one of this plugin's own:
|
|
18
|
+
* - **Into Outbox** is always refused (`"destination"`): only a send puts a message there, after checking its sender -
|
|
19
|
+
* `ScheduledSendJob` relays whatever it finds in Outbox.
|
|
20
|
+
* - **Into Drafts** is refused unless the message already is in Drafts, or is taken back out of Outbox to cancel its send
|
|
21
|
+
* (`"destination"`): a draft's body can be rewritten over ActiveSync (`EmailSyncAdapter.fromApplicationData`), so moving a
|
|
22
|
+
* received, sent or held message into Drafts, editing it and moving it back would forge its content under its original
|
|
23
|
+
* sender and dates. Out of Outbox only for what a send queued: a delivered message (`scanResultUid`) a mail filter rule
|
|
24
|
+
* filed into Outbox is refused, as restapi refuses it with 403.
|
|
25
|
+
* - **A send in flight** (`hasLiveSendLease()` - claimed for relay by `send()` or `ScheduledSendJob`) can't be moved
|
|
26
|
+
* anywhere (`"inFlight"`), as restapi's `assertNotInFlight()` refuses with 409: moving it back to Drafts would let it be
|
|
27
|
+
* sent again while the first relay still runs. Once the lease has lapsed, it can move. Deletes check the same.
|
|
28
|
+
* - **Out of Outbox** cancels the scheduled send: the plan clears `scheduledSendTime`, the job's retry state and its
|
|
29
|
+
* lease (`scheduledSendAttempts`/`scheduledSendError`/`scheduledSendLeaseExpiresAt`, only where the stored row has them -
|
|
30
|
+
* the SQL model of restapi 0.9.0 has no such columns). A message the job already relayed (`scheduledSendRelayedAt`, whose
|
|
31
|
+
* filing is still pending) is refused (`"sent"`).
|
|
32
|
+
*/
|
|
33
|
+
export declare function planMessageMove(message: Message, sourceType: FolderType | undefined, destinationType: FolderType | undefined): MessageMovePlan;
|
|
34
|
+
/**
|
|
35
|
+
* Whether `message`, filed in a folder of `folderType`, is a genuine draft whose content an ActiveSync client may
|
|
36
|
+
* rewrite: it lives in Drafts and carries none of the marks a delivery or a send leaves (`SENT_OR_DELIVERED_MARKERS`).
|
|
37
|
+
* Moves into Drafts are refused over ActiveSync (`planMessageMove()`), but a message can still reach Drafts other ways
|
|
38
|
+
* (restapi's REST moves out of Outbox, a mail filter, an import), so a delivered or sent message found there is not
|
|
39
|
+
* treated as a draft.
|
|
40
|
+
*
|
|
41
|
+
* `sentDate` can't be the marker: every message has one, and a draft gets it when it is created. **Residual**: a
|
|
42
|
+
* plain-text, unencrypted sent copy with no receipt request carries none of these marks; restapi keeps it out of
|
|
43
|
+
* Drafts (non-trusted moves into Drafts only from Drafts or Outbox, and only a send from Drafts queues into Outbox).
|
|
44
|
+
*/
|
|
45
|
+
export declare function isGenuineDraft(message: Message, folderType: FolderType | undefined): boolean;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** The raw (unfolded, trimmed) values of a message's top-level `From` and `Sender` headers. */
|
|
2
|
+
export interface OriginatorHeaders {
|
|
3
|
+
from: string[];
|
|
4
|
+
sender: string[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Every top-level `From` and `Sender` header value of `raw`, found exactly the way `checkOriginatorHeaders()` finds
|
|
8
|
+
* them: header names case-insensitive, whitespace before the colon allowed (the obsolete `From :` form), folded
|
|
9
|
+
* values unfolded, and a bare CR treated as a line break.
|
|
10
|
+
*/
|
|
11
|
+
export declare function extractOriginatorHeaders(raw: Buffer): OriginatorHeaders;
|
|
12
|
+
/**
|
|
13
|
+
* Whether any display name, group name or comment in one `From`/`Sender` header value contains an address-like `@`
|
|
14
|
+
* (RFC 2047 encoded words decoded, look-alike `@` characters included): `"ceo@example.com" <me@example.com>` shows
|
|
15
|
+
* the reader an address the sender doesn't own, though its real address is fine.
|
|
16
|
+
*/
|
|
17
|
+
export declare function hasAddressLikeDisplayName(value: string): boolean;
|
|
18
|
+
/** Options for `checkOriginatorHeaders()`. */
|
|
19
|
+
export interface OriginatorHeaderCheckOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Also refuse a `From`/`Sender` whose display name, group name or comment contains an address
|
|
22
|
+
* (`hasAddressLikeDisplayName()`). Every path that sends a user-composed message as one of a mailbox's addresses
|
|
23
|
+
* should set it - the REST send path and `ScheduledSendJob` do, and so should protocol plugins (ActiveSync, MAPI).
|
|
24
|
+
*/
|
|
25
|
+
rejectAddressLikeDisplayNames?: boolean;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Refuses a raw RFC 5322 message whose originator headers name anyone other than an allowed sender. Every `From`
|
|
29
|
+
* and `Sender` header in the top-level header block is checked - header names case-insensitively (including the
|
|
30
|
+
* obsolete `From :` form with whitespace before the colon), folded values unfolded, and a bare CR treated as a line
|
|
31
|
+
* break too, so a header can't be hidden from this scan behind a line ending another parser would honor. Returns a
|
|
32
|
+
* refusal reason, or `undefined` if the message passes. Fails closed on:
|
|
33
|
+
* - no `From` header, more than one `From` header, or more than one `Sender` header;
|
|
34
|
+
* - a `From`/`Sender` value that yields no address at all (e.g. only an empty group);
|
|
35
|
+
* - any parsed entry without an address (a malformed list, e.g. an unquoted display name containing a comma);
|
|
36
|
+
* - any parsed address - group members included - that `isAllowed` rejects;
|
|
37
|
+
* - any addr-spec-looking token outside quoted strings/comments that `isAllowed` rejects - covers the tolerant
|
|
38
|
+
* parser recovering `<me@example.com> <other@example.com>` as one mailbox with the second as its "display name";
|
|
39
|
+
* - with `options.rejectAddressLikeDisplayNames`, any display name, group name or comment containing an address.
|
|
40
|
+
*
|
|
41
|
+
* Quoted display names and RFC 2047 encoded words are never treated as addresses. `isAllowed` receives each address
|
|
42
|
+
* exactly as parsed; normalize (e.g. lowercase) inside it.
|
|
43
|
+
*/
|
|
44
|
+
export declare function checkOriginatorHeaders(raw: Buffer, isAllowed: (address: string) => boolean, options?: OriginatorHeaderCheckOptions): string | undefined;
|
|
45
|
+
/** Whether `address` is exactly one plain address (`local@domain`, nothing around it), at most 320 characters - safe to
|
|
46
|
+
* hand to a composer as one recipient, e.g. a meeting attendee or organizer. */
|
|
47
|
+
export declare function isPlainAddress(address: unknown): address is string;
|
|
48
|
+
/**
|
|
49
|
+
* `name` as a display name that's safe to put in front of one of our own addresses in a `From` (or an iCalendar `CN`)
|
|
50
|
+
* this server composes: trimmed, or `undefined` - so the caller omits the name - when it isn't a string, is blank,
|
|
51
|
+
* contains a line break or other control character, or shows an address-like `@` (look-alikes and RFC 2047 encoded
|
|
52
|
+
* words included, the same rule as `hasAddressLikeDisplayName()`). A display name like `ceo@example.com` in front of a
|
|
53
|
+
* real address shows the reader an address the sender doesn't own.
|
|
54
|
+
*/
|
|
55
|
+
export declare function safeDisplayName(name: unknown): string | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* The ActiveSync compose sender check: restapi's `checkOriginatorHeaders()` with `rejectAddressLikeDisplayNames`, plus
|
|
58
|
+
* two plugin-side refusals restapi doesn't make. Returns a refusal reason, or `undefined` if the message passes.
|
|
59
|
+
* - **Relaxed for the sender's own address**: a display name or comment that shows an address is still accepted when
|
|
60
|
+
* every address it shows is one of the mailbox's own (`"me@example.com" <me@example.com>`) - see
|
|
61
|
+
* `displayTextShowsOnlyAllowedAddresses()`. Any other address, or a look-alike `@`, is refused as restapi refuses it.
|
|
62
|
+
* - **A `From`/`Sender` field restapi's lexer can't see** - the tolerant `lexHeaderFields()` counts more of them (e.g.
|
|
63
|
+
* a leading space on the first line, or a form feed before the colon, both of which mailsplit still reads as `From`).
|
|
64
|
+
* - **An empty group** (`victims:;, me@example.com`) - it contributes no address, only text of the sender's choice
|
|
65
|
+
* shown beside the real address, and RFC 5322 doesn't allow groups in `From`/`Sender` at all.
|
|
66
|
+
*/
|
|
67
|
+
export declare function checkComposedOriginators(raw: Buffer, isAllowed: (address: string) => boolean): string | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* Returns a copy of `raw` with every top-level header field named `name` (case-insensitive, whitespace before the
|
|
70
|
+
* colon allowed, including its folded continuation lines) removed, found with `lexHeaderFields()` - so a field
|
|
71
|
+
* written `Bcc :` is removed too. Only the header block is touched; every other byte is copied verbatim.
|
|
72
|
+
*/
|
|
73
|
+
export declare function stripHeader(raw: Buffer, name: string): Buffer;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mirrors `@rapidrest/service-core`'s private `ModelUtils.MAX_PATTERN_LENGTH`: a `regex(...)` query operand
|
|
3
|
+
* longer than this is rejected as a potential ReDoS pattern with `INVALID_REQUEST`. The check runs against the
|
|
4
|
+
* operand AFTER escaping, so a raw client term well under the limit can still exceed it once each regex
|
|
5
|
+
* metacharacter doubles in length.
|
|
6
|
+
*/
|
|
7
|
+
export declare const MAX_REGEX_PATTERN_LENGTH = 100;
|
|
8
|
+
/**
|
|
9
|
+
* Escapes `value` for a literal-substring `regex(...)` match, truncating the raw term (never mid-escape or
|
|
10
|
+
* mid-code-point) so the escaped result always fits `MAX_REGEX_PATTERN_LENGTH`. Matching on a prefix of an
|
|
11
|
+
* over-long search term still yields a superset of the full term's matches, which is a far better outcome than
|
|
12
|
+
* the whole command failing.
|
|
13
|
+
*/
|
|
14
|
+
export declare function boundedEscapedPattern(value: string, maxLength?: number): string;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type RepoUtils } from "@rapidrest/service-core";
|
|
2
|
+
/**
|
|
3
|
+
* Inline copies of small restapi helpers that the `@rapidmx/restapi` 0.9.0 this plugin builds against doesn't export
|
|
4
|
+
* yet. Each matches restapi's source exactly - replace the copy with restapi's export once the dependency is bumped.
|
|
5
|
+
*/
|
|
6
|
+
/** The longest value `boundIndexedValue()` stores verbatim (restapi `util/ConversationUtils.ts`). */
|
|
7
|
+
export declare const MAX_INDEXED_VALUE_LENGTH: number;
|
|
8
|
+
/**
|
|
9
|
+
* Returns `value` unchanged when it's at most `MAX_INDEXED_VALUE_LENGTH` characters, otherwise
|
|
10
|
+
* `sha256:<64 hex digits>` of its UTF-8 bytes - how restapi stores indexed identifiers from untrusted mail/calendar
|
|
11
|
+
* data (`Message.messageId`, `Message.conversationId`, `CalendarEvent.icalUid`). Idempotent, so a lookup value passed
|
|
12
|
+
* through it matches the stored one. Copy of restapi's `boundIndexedValue()` (`util/ConversationUtils.ts`).
|
|
13
|
+
*/
|
|
14
|
+
export declare function boundIndexedValue<T extends string | null | undefined>(value: T): T;
|
|
15
|
+
/**
|
|
16
|
+
* `row` as an instance of `repo`'s model class, for use as the `existing` argument of `RepoUtils.update()`.
|
|
17
|
+
*
|
|
18
|
+
* `update()` only enforces its optimistic lock (a version mismatch -> 409, a version-filtered write, and the
|
|
19
|
+
* `version`/`dateModified` bump) when `existing instanceof BaseEntity`. The Mongo backend's `find()`/`findOne()` return
|
|
20
|
+
* plain documents, so passing one straight through silently turns a version-checked write into an unconditional
|
|
21
|
+
* overwrite that other devices' change streams never see. SQL reads already return entity instances, which pass
|
|
22
|
+
* through as-is. Copy of restapi's `asEntity()` (`util/EntityUtils.ts`).
|
|
23
|
+
*/
|
|
24
|
+
export declare function asEntity<T>(repo: RepoUtils<any>, row: T): T;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { type WbxmlElement } from "../codec/WbxmlElement.js";
|
|
2
2
|
import type { EasCollectionSyncAdapter } from "./EasCollectionSyncAdapter.js";
|
|
3
|
-
import { type CalendarEvent } from "@rapidmx/restapi";
|
|
3
|
+
import { type CalendarEvent, type Mailbox } from "@rapidmx/restapi";
|
|
4
|
+
/** Most attendees one event may carry from a device - restapi's `MAX_EVENT_ATTENDEES` (REST calendar writes) and
|
|
5
|
+
* `MeetingSchedulingJob`'s default `max_attendees`, and the MAPI plugin's cap. The job mails every attendee, so an
|
|
6
|
+
* unbounded list would make one Sync item a bulk mailing. */
|
|
7
|
+
export declare const MAX_CALENDAR_ATTENDEES = 500;
|
|
4
8
|
/**
|
|
5
9
|
* Maps `CalendarEvent` to/from the EAS `Sync` `Calendar` collection class (MS-ASCAL).
|
|
6
10
|
*
|
|
@@ -13,9 +17,8 @@ import { type CalendarEvent } from "@rapidmx/restapi";
|
|
|
13
17
|
* its own to source a real value from.
|
|
14
18
|
* - Recurrence patterns keyed by an ordinal weekday (MS-ASCAL `Type` 3/6, e.g. "the 2nd Tuesday of the month")
|
|
15
19
|
* are not emitted - see `RECURRENCE_TYPE_CODES`'s own doc comment.
|
|
16
|
-
* - Recurrence exceptions (individually modified/cancelled occurrences of a recurring series) are not synced
|
|
17
|
-
*
|
|
18
|
-
* replay-protection gap).
|
|
20
|
+
* - Recurrence exceptions (individually modified/cancelled occurrences of a recurring series) are not synced to the
|
|
21
|
+
* device; a device `Change` of the recurrence keeps the series' existing exceptions rather than wiping them.
|
|
19
22
|
*
|
|
20
23
|
* @author Jean-Philippe Steinmetz
|
|
21
24
|
*/
|
|
@@ -24,23 +27,40 @@ export declare class CalendarSyncAdapter implements EasCollectionSyncAdapter<Cal
|
|
|
24
27
|
toApplicationData(event: CalendarEvent): WbxmlElement;
|
|
25
28
|
private recurrenceElement;
|
|
26
29
|
/**
|
|
27
|
-
* Reverse of `toApplicationData`. `timezone`/`status`/`
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* the returned partial - `newEntityDefaults()` below supplies `icalUid`/`sequence` for a brand new event
|
|
31
|
-
* (`status`/`timezone` are left at the model's own constructor defaults), and `applyChange` leaves all four
|
|
32
|
-
* untouched by construction (merging onto `existing`).
|
|
30
|
+
* Reverse of `toApplicationData`. `timezone`/`status`/`icalUid` have no wire representation at all and are
|
|
31
|
+
* never included in the returned partial - `newEntityDefaults()` supplies `icalUid`/`sequence` for a brand new
|
|
32
|
+
* event, and a `Change` merges onto `existing`.
|
|
33
33
|
*
|
|
34
|
-
* `
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
34
|
+
* **Organizer** (`mailbox` = the caller's own mailbox, supplied by `SyncCommand`): a device can only create an
|
|
35
|
+
* event organized by itself - on an `Add`, an `OrganizerEmail` that isn't one of the mailbox's own addresses (or
|
|
36
|
+
* a missing one) is replaced by the mailbox's primary address, since the organizer is who iTIP invitations are
|
|
37
|
+
* sent as. The organizer's display name is always the mailbox's own (`MimeHeaderUtils.safeDisplayName()`: omitted
|
|
38
|
+
* when it looks like an address or has a line break) - never the device's `OrganizerName`, which invitations would
|
|
39
|
+
* otherwise show as the sender's name (e.g. `payroll@corp.com`). On a `Change` the organizer is never reassigned (an
|
|
40
|
+
* attendee's copy of someone else's meeting keeps its real organizer). Without a `mailbox` (direct use),
|
|
41
|
+
* `OrganizerEmail` must be one plain address (`isPlainAddress()`) and `OrganizerName` is kept only when it passes the
|
|
42
|
+
* same rule.
|
|
39
43
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
44
|
+
* **Attendees** match restapi's REST calendar validation (`BaseCalendarEventRoute.assertParticipants()`, 400 there):
|
|
45
|
+
* each `Email` must be one plain address (`MimeHeaderUtils.isPlainAddress()`, so not a list like `a@x, b@y`), and at
|
|
46
|
+
* most `MAX_CALENDAR_ATTENDEES` are accepted - refused, never truncated. As in restapi, on a `Change` only a changed
|
|
47
|
+
* list is checked: a device re-sending exactly the addresses the event already has (e.g. an attendee copy filed with
|
|
48
|
+
* a larger or odder list) still saves. A `Name` is kept only when `safeDisplayName()` allows it. A refusal throws,
|
|
49
|
+
* which `SyncCommand` reports as Status 6.
|
|
50
|
+
*
|
|
51
|
+
* **Change merging** (`existing` given): `Attendees`/`Recurrence` are ghosted as a whole element - present ->
|
|
52
|
+
* rebuilt from what's there, absent -> untouched - but a rebuilt attendee the event already had keeps the fields
|
|
53
|
+
* the device didn't send (`AttendeeStatus`/`AttendeeType`/`Name`, and `isOrganizer`), and a rebuilt recurrence
|
|
54
|
+
* keeps the series' existing exceptions (cancelled occurrences have no wire representation here). A change to the
|
|
55
|
+
* time, location, attendees or recurrence bumps `sequence`, as `BaseCalendarEventRoute.update` does, so updated
|
|
56
|
+
* invitations go out - but only on the organizer's copy: when `mailbox` (the item's owner on a `Change`) isn't
|
|
57
|
+
* the organizer, `sequence` is left alone and `inviteSequenceSent` is kept equal to it, so an attendee editing
|
|
58
|
+
* their own copy never makes `MeetingSchedulingJob` send invitations as the organizer.
|
|
42
59
|
*/
|
|
43
|
-
fromApplicationData(el: WbxmlElement): Partial<CalendarEvent>;
|
|
60
|
+
fromApplicationData(el: WbxmlElement, existing?: CalendarEvent, mailbox?: Mailbox): Partial<CalendarEvent>;
|
|
61
|
+
/** Stamps `cancelNoticeSentAt` on an attendee's copy of a meeting before it is deleted, so the deletion is never
|
|
62
|
+
* taken as the organizer cancelling the meeting (see `EasCollectionSyncAdapter.beforeDelete`). */
|
|
63
|
+
beforeDelete(existing: CalendarEvent, mailbox: Mailbox): Partial<CalendarEvent> | undefined;
|
|
44
64
|
/** `icalUid`/`sequence` have no wire representation on `Add` (see `fromApplicationData`'s own doc comment)
|
|
45
65
|
* - without this, every Sync-created event would fall back to `CalendarEventMongo`/`CalendarEventSQL`'s own
|
|
46
66
|
* constructor default of `icalUid: ""`, violating RFC 5545's uniqueness expectation for `UID`. Mirrors
|
|
@@ -50,3 +70,12 @@ export declare class CalendarSyncAdapter implements EasCollectionSyncAdapter<Cal
|
|
|
50
70
|
private attendeeFromElement;
|
|
51
71
|
private recurrenceRuleFromElement;
|
|
52
72
|
}
|
|
73
|
+
/** The calendar day (1-31) and month (1-12) `date` falls on in IANA `timezone`, falling back to UTC for a zone
|
|
74
|
+
* `Intl` doesn't recognize. */
|
|
75
|
+
export declare function localDayAndMonth(date: Date, timezone: string): {
|
|
76
|
+
day: number;
|
|
77
|
+
month: number;
|
|
78
|
+
};
|
|
79
|
+
/** `true` when `event`'s organizer is one of `mailbox`'s own addresses - the organizer's copy of a meeting, as opposed
|
|
80
|
+
* to an attendee's copy of someone else's. An event without an organizer address counts as the mailbox's own. */
|
|
81
|
+
export declare function isOrganizedBy(event: CalendarEvent, mailbox: Mailbox): boolean;
|
|
@@ -23,6 +23,10 @@ export interface EasCollectionSyncAdapter<T extends RecoverableBaseEntity> {
|
|
|
23
23
|
* rendering `Categories`; every other adapter today returns a plain `WbxmlElement`, which callers `await`
|
|
24
24
|
* through unchanged (the same optional-async shape `fromApplicationData` already established below). */
|
|
25
25
|
toApplicationData(item: T): WbxmlElement | Promise<WbxmlElement>;
|
|
26
|
+
/** Optional bulk form of `toApplicationData`, returning one element per item in the same order. Implemented
|
|
27
|
+
* where rendering needs a lookup that is far cheaper done once for a whole page (`EmailSyncAdapter`'s
|
|
28
|
+
* `Label` resolution); callers fall back to per-item `toApplicationData` when absent. */
|
|
29
|
+
toApplicationDataBatch?(items: T[]): Promise<WbxmlElement[]>;
|
|
26
30
|
/**
|
|
27
31
|
* Parses one client-originated `Add`/`Change` command's `<ApplicationData>` element (`el`) into a partial
|
|
28
32
|
* entity update. Only fields actually present in `el` are included in the result - an omitted field means
|
|
@@ -37,9 +41,21 @@ export interface EasCollectionSyncAdapter<T extends RecoverableBaseEntity> {
|
|
|
37
41
|
* @param el The command's `<ApplicationData>` element.
|
|
38
42
|
* @param existing The item being changed, for a `Change` command; `undefined` for `Add`. Adapters that
|
|
39
43
|
* need to know the item's current field values to correctly interpret a partial update - `EmailSyncAdapter`
|
|
40
|
-
* uses this to
|
|
44
|
+
* uses this to refuse a `Body` change on anything but a Draft, and to carry over unchanged MIME headers.
|
|
45
|
+
* @param mailbox For an `Add`, the caller's own mailbox - `CalendarSyncAdapter` uses it so a device can never
|
|
46
|
+
* make someone else the organizer of an event it creates. For a `Change`, the mailbox that owns the item (the
|
|
47
|
+
* synced folder's mailbox) - `CalendarSyncAdapter` uses it to tell the organizer's copy of a meeting from an
|
|
48
|
+
* attendee's copy.
|
|
41
49
|
*/
|
|
42
|
-
fromApplicationData?(el: WbxmlElement, existing?: T): Partial<T> | Promise<Partial<T>>;
|
|
50
|
+
fromApplicationData?(el: WbxmlElement, existing?: T, mailbox?: Mailbox): Partial<T> | Promise<Partial<T>>;
|
|
51
|
+
/**
|
|
52
|
+
* Fields to stamp on an item just before a client-originated `Delete` removes it, or `undefined` for none.
|
|
53
|
+
* `CalendarSyncAdapter` marks an attendee's copy of a meeting as already cancelled, so deleting your own copy
|
|
54
|
+
* never makes restapi's `MeetingSchedulingJob` mail a cancellation to everyone on the organizer's behalf.
|
|
55
|
+
*
|
|
56
|
+
* @param mailbox The mailbox that owns the item.
|
|
57
|
+
*/
|
|
58
|
+
beforeDelete?(existing: T, mailbox: Mailbox): Partial<T> | undefined;
|
|
43
59
|
/**
|
|
44
60
|
* Supplies default field values for a brand-new entity created via a client-originated `Add`, applied
|
|
45
61
|
* *before* `fromApplicationData`'s own partial is merged on top (so anything the client actually sent
|