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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/README.md +4 -4
  2. package/dist/lib/BaseDeviceSyncStateRoute.js +32 -0
  3. package/dist/lib/BaseDeviceSyncStateRoute.js.map +1 -1
  4. package/dist/lib/BaseEasRoute.js +91 -15
  5. package/dist/lib/BaseEasRoute.js.map +1 -1
  6. package/dist/lib/EasAuditLog.js +56 -0
  7. package/dist/lib/EasAuditLog.js.map +1 -0
  8. package/dist/lib/EasCollectionLease.js +199 -0
  9. package/dist/lib/EasCollectionLease.js.map +1 -0
  10. package/dist/lib/EasCollectionStore.js +102 -0
  11. package/dist/lib/EasCollectionStore.js.map +1 -0
  12. package/dist/lib/EasCollectionSync.js +294 -0
  13. package/dist/lib/EasCollectionSync.js.map +1 -0
  14. package/dist/lib/EasSyncKeyUtils.js +125 -62
  15. package/dist/lib/EasSyncKeyUtils.js.map +1 -1
  16. package/dist/lib/MessageMoveRules.js +100 -0
  17. package/dist/lib/MessageMoveRules.js.map +1 -0
  18. package/dist/lib/MimeHeaderUtils.js +395 -0
  19. package/dist/lib/MimeHeaderUtils.js.map +1 -0
  20. package/dist/lib/RegexPatternUtils.js +30 -0
  21. package/dist/lib/RegexPatternUtils.js.map +1 -0
  22. package/dist/lib/RestapiCompat.js +41 -0
  23. package/dist/lib/RestapiCompat.js.map +1 -0
  24. package/dist/lib/adapters/CalendarSyncAdapter.js +142 -33
  25. package/dist/lib/adapters/CalendarSyncAdapter.js.map +1 -1
  26. package/dist/lib/adapters/EmailSyncAdapter.js +114 -27
  27. package/dist/lib/adapters/EmailSyncAdapter.js.map +1 -1
  28. package/dist/lib/adapters/mongo/EmailSyncAdapterMongo.js +2 -1
  29. package/dist/lib/adapters/mongo/EmailSyncAdapterMongo.js.map +1 -1
  30. package/dist/lib/adapters/sql/EmailSyncAdapterSQL.js +2 -1
  31. package/dist/lib/adapters/sql/EmailSyncAdapterSQL.js.map +1 -1
  32. package/dist/lib/codec/WbxmlDecoder.js +86 -34
  33. package/dist/lib/codec/WbxmlDecoder.js.map +1 -1
  34. package/dist/lib/codec/WbxmlEncoder.js +103 -22
  35. package/dist/lib/codec/WbxmlEncoder.js.map +1 -1
  36. package/dist/lib/commands/ComposeMailCommand.js +105 -31
  37. package/dist/lib/commands/ComposeMailCommand.js.map +1 -1
  38. package/dist/lib/commands/FolderSyncCommand.js +67 -27
  39. package/dist/lib/commands/FolderSyncCommand.js.map +1 -1
  40. package/dist/lib/commands/GetItemEstimateCommand.js +80 -59
  41. package/dist/lib/commands/GetItemEstimateCommand.js.map +1 -1
  42. package/dist/lib/commands/ItemOperationsCommand.js +219 -56
  43. package/dist/lib/commands/ItemOperationsCommand.js.map +1 -1
  44. package/dist/lib/commands/MeetingResponseCommand.js +212 -53
  45. package/dist/lib/commands/MeetingResponseCommand.js.map +1 -1
  46. package/dist/lib/commands/MoveItemsCommand.js +54 -21
  47. package/dist/lib/commands/MoveItemsCommand.js.map +1 -1
  48. package/dist/lib/commands/PingCommand.js +288 -56
  49. package/dist/lib/commands/PingCommand.js.map +1 -1
  50. package/dist/lib/commands/ProvisionCommand.js +34 -17
  51. package/dist/lib/commands/ProvisionCommand.js.map +1 -1
  52. package/dist/lib/commands/ResolveRecipientsCommand.js +43 -5
  53. package/dist/lib/commands/ResolveRecipientsCommand.js.map +1 -1
  54. package/dist/lib/commands/SearchCommand.js +92 -17
  55. package/dist/lib/commands/SearchCommand.js.map +1 -1
  56. package/dist/lib/commands/SettingsCommand.js +2 -1
  57. package/dist/lib/commands/SettingsCommand.js.map +1 -1
  58. package/dist/lib/commands/SmartForwardCommand.js +2 -1
  59. package/dist/lib/commands/SmartForwardCommand.js.map +1 -1
  60. package/dist/lib/commands/SmartReplyCommand.js +2 -1
  61. package/dist/lib/commands/SmartReplyCommand.js.map +1 -1
  62. package/dist/lib/commands/SyncCommand.js +481 -244
  63. package/dist/lib/commands/SyncCommand.js.map +1 -1
  64. package/dist/lib/commands/mongo/GetItemEstimateCommandMongo.js +6 -1
  65. package/dist/lib/commands/mongo/GetItemEstimateCommandMongo.js.map +1 -1
  66. package/dist/lib/commands/mongo/ItemOperationsCommandMongo.js +3 -1
  67. package/dist/lib/commands/mongo/ItemOperationsCommandMongo.js.map +1 -1
  68. package/dist/lib/commands/mongo/MeetingResponseCommandMongo.js +2 -1
  69. package/dist/lib/commands/mongo/MeetingResponseCommandMongo.js.map +1 -1
  70. package/dist/lib/commands/mongo/PingCommandMongo.js +23 -0
  71. package/dist/lib/commands/mongo/PingCommandMongo.js.map +1 -0
  72. package/dist/lib/commands/mongo/SearchCommandMongo.js +3 -1
  73. package/dist/lib/commands/mongo/SearchCommandMongo.js.map +1 -1
  74. package/dist/lib/commands/mongo/SendMailCommandMongo.js +2 -1
  75. package/dist/lib/commands/mongo/SendMailCommandMongo.js.map +1 -1
  76. package/dist/lib/commands/mongo/SmartForwardCommandMongo.js +2 -1
  77. package/dist/lib/commands/mongo/SmartForwardCommandMongo.js.map +1 -1
  78. package/dist/lib/commands/mongo/SmartReplyCommandMongo.js +2 -1
  79. package/dist/lib/commands/mongo/SmartReplyCommandMongo.js.map +1 -1
  80. package/dist/lib/commands/mongo/SyncCommandMongo.js +7 -1
  81. package/dist/lib/commands/mongo/SyncCommandMongo.js.map +1 -1
  82. package/dist/lib/commands/mongo/index.js +1 -0
  83. package/dist/lib/commands/mongo/index.js.map +1 -1
  84. package/dist/lib/commands/sql/GetItemEstimateCommandSQL.js +6 -1
  85. package/dist/lib/commands/sql/GetItemEstimateCommandSQL.js.map +1 -1
  86. package/dist/lib/commands/sql/ItemOperationsCommandSQL.js +3 -1
  87. package/dist/lib/commands/sql/ItemOperationsCommandSQL.js.map +1 -1
  88. package/dist/lib/commands/sql/MeetingResponseCommandSQL.js +2 -1
  89. package/dist/lib/commands/sql/MeetingResponseCommandSQL.js.map +1 -1
  90. package/dist/lib/commands/sql/PingCommandSQL.js +23 -0
  91. package/dist/lib/commands/sql/PingCommandSQL.js.map +1 -0
  92. package/dist/lib/commands/sql/SearchCommandSQL.js +3 -1
  93. package/dist/lib/commands/sql/SearchCommandSQL.js.map +1 -1
  94. package/dist/lib/commands/sql/SendMailCommandSQL.js +2 -1
  95. package/dist/lib/commands/sql/SendMailCommandSQL.js.map +1 -1
  96. package/dist/lib/commands/sql/SmartForwardCommandSQL.js +2 -1
  97. package/dist/lib/commands/sql/SmartForwardCommandSQL.js.map +1 -1
  98. package/dist/lib/commands/sql/SmartReplyCommandSQL.js +2 -1
  99. package/dist/lib/commands/sql/SmartReplyCommandSQL.js.map +1 -1
  100. package/dist/lib/commands/sql/SyncCommandSQL.js +7 -1
  101. package/dist/lib/commands/sql/SyncCommandSQL.js.map +1 -1
  102. package/dist/lib/commands/sql/index.js +1 -0
  103. package/dist/lib/commands/sql/index.js.map +1 -1
  104. package/dist/lib/index.js +5 -0
  105. package/dist/lib/index.js.map +1 -1
  106. package/dist/lib/jobs/EasDeviceStateCleanupJob.js +44 -2
  107. package/dist/lib/jobs/EasDeviceStateCleanupJob.js.map +1 -1
  108. package/dist/lib/jobs/mongo/EasDeviceStateCleanupJobMongo.js +4 -0
  109. package/dist/lib/jobs/mongo/EasDeviceStateCleanupJobMongo.js.map +1 -1
  110. package/dist/lib/jobs/sql/EasDeviceStateCleanupJobSQL.js +9 -0
  111. package/dist/lib/jobs/sql/EasDeviceStateCleanupJobSQL.js.map +1 -1
  112. package/dist/lib/models/EasCollectionChunk.js +2 -0
  113. package/dist/lib/models/EasCollectionChunk.js.map +1 -0
  114. package/dist/lib/models/EasCollectionState.js +2 -0
  115. package/dist/lib/models/EasCollectionState.js.map +1 -0
  116. package/dist/lib/models/mongo/DeviceSyncStateMongo.js +7 -0
  117. package/dist/lib/models/mongo/DeviceSyncStateMongo.js.map +1 -1
  118. package/dist/lib/models/mongo/EasCollectionChunkMongo.js +83 -0
  119. package/dist/lib/models/mongo/EasCollectionChunkMongo.js.map +1 -0
  120. package/dist/lib/models/mongo/EasCollectionStateMongo.js +162 -0
  121. package/dist/lib/models/mongo/EasCollectionStateMongo.js.map +1 -0
  122. package/dist/lib/models/sql/DeviceSyncStateSQL.js +7 -0
  123. package/dist/lib/models/sql/DeviceSyncStateSQL.js.map +1 -1
  124. package/dist/lib/models/sql/EasCollectionChunkSQL.js +83 -0
  125. package/dist/lib/models/sql/EasCollectionChunkSQL.js.map +1 -0
  126. package/dist/lib/models/sql/EasCollectionStateSQL.js +162 -0
  127. package/dist/lib/models/sql/EasCollectionStateSQL.js.map +1 -0
  128. package/dist/lib/mongo/EasRouteMongo.js +2 -2
  129. package/dist/lib/mongo/EasRouteMongo.js.map +1 -1
  130. package/dist/lib/mongo.js +2 -0
  131. package/dist/lib/mongo.js.map +1 -1
  132. package/dist/lib/sql/EasRouteSQL.js +2 -2
  133. package/dist/lib/sql/EasRouteSQL.js.map +1 -1
  134. package/dist/lib/sql.js +2 -0
  135. package/dist/lib/sql.js.map +1 -1
  136. package/dist/types/BaseDeviceSyncStateRoute.d.ts +10 -0
  137. package/dist/types/BaseEasRoute.d.ts +15 -4
  138. package/dist/types/EasAuditLog.d.ts +47 -0
  139. package/dist/types/EasCollectionLease.d.ts +60 -0
  140. package/dist/types/EasCollectionStore.d.ts +51 -0
  141. package/dist/types/EasCollectionSync.d.ts +96 -0
  142. package/dist/types/EasCommandHandler.d.ts +7 -4
  143. package/dist/types/EasSyncKeyUtils.d.ts +73 -42
  144. package/dist/types/MessageMoveRules.d.ts +45 -0
  145. package/dist/types/MimeHeaderUtils.d.ts +73 -0
  146. package/dist/types/RegexPatternUtils.d.ts +14 -0
  147. package/dist/types/RestapiCompat.d.ts +24 -0
  148. package/dist/types/adapters/CalendarSyncAdapter.d.ts +47 -18
  149. package/dist/types/adapters/EasCollectionSyncAdapter.d.ts +18 -2
  150. package/dist/types/adapters/EmailSyncAdapter.d.ts +24 -7
  151. package/dist/types/adapters/mongo/EmailSyncAdapterMongo.d.ts +1 -0
  152. package/dist/types/adapters/sql/EmailSyncAdapterSQL.d.ts +1 -0
  153. package/dist/types/codec/WbxmlDecoder.d.ts +39 -0
  154. package/dist/types/codec/WbxmlEncoder.d.ts +36 -1
  155. package/dist/types/commands/ComposeMailCommand.d.ts +33 -15
  156. package/dist/types/commands/FolderSyncCommand.d.ts +4 -1
  157. package/dist/types/commands/GetItemEstimateCommand.d.ts +20 -15
  158. package/dist/types/commands/ItemOperationsCommand.d.ts +29 -4
  159. package/dist/types/commands/MeetingResponseCommand.d.ts +52 -13
  160. package/dist/types/commands/MoveItemsCommand.d.ts +13 -3
  161. package/dist/types/commands/PingCommand.d.ts +72 -14
  162. package/dist/types/commands/ProvisionCommand.d.ts +16 -11
  163. package/dist/types/commands/ResolveRecipientsCommand.d.ts +4 -0
  164. package/dist/types/commands/SearchCommand.d.ts +13 -1
  165. package/dist/types/commands/SyncCommand.d.ts +102 -62
  166. package/dist/types/commands/mongo/GetItemEstimateCommandMongo.d.ts +3 -0
  167. package/dist/types/commands/mongo/ItemOperationsCommandMongo.d.ts +2 -0
  168. package/dist/types/commands/mongo/MeetingResponseCommandMongo.d.ts +1 -0
  169. package/dist/types/commands/mongo/PingCommandMongo.d.ts +8 -0
  170. package/dist/types/commands/mongo/SearchCommandMongo.d.ts +2 -0
  171. package/dist/types/commands/mongo/SendMailCommandMongo.d.ts +1 -0
  172. package/dist/types/commands/mongo/SmartForwardCommandMongo.d.ts +1 -0
  173. package/dist/types/commands/mongo/SmartReplyCommandMongo.d.ts +1 -0
  174. package/dist/types/commands/mongo/SyncCommandMongo.d.ts +4 -0
  175. package/dist/types/commands/mongo/index.d.ts +1 -0
  176. package/dist/types/commands/sql/GetItemEstimateCommandSQL.d.ts +3 -0
  177. package/dist/types/commands/sql/ItemOperationsCommandSQL.d.ts +2 -0
  178. package/dist/types/commands/sql/MeetingResponseCommandSQL.d.ts +1 -0
  179. package/dist/types/commands/sql/PingCommandSQL.d.ts +8 -0
  180. package/dist/types/commands/sql/SearchCommandSQL.d.ts +2 -0
  181. package/dist/types/commands/sql/SendMailCommandSQL.d.ts +1 -0
  182. package/dist/types/commands/sql/SmartForwardCommandSQL.d.ts +1 -0
  183. package/dist/types/commands/sql/SmartReplyCommandSQL.d.ts +1 -0
  184. package/dist/types/commands/sql/SyncCommandSQL.d.ts +4 -0
  185. package/dist/types/commands/sql/index.d.ts +1 -0
  186. package/dist/types/index.d.ts +5 -0
  187. package/dist/types/jobs/EasDeviceStateCleanupJob.d.ts +15 -0
  188. package/dist/types/jobs/mongo/EasDeviceStateCleanupJobMongo.d.ts +2 -0
  189. package/dist/types/jobs/sql/EasDeviceStateCleanupJobSQL.d.ts +4 -0
  190. package/dist/types/models/DeviceSyncState.d.ts +13 -2
  191. package/dist/types/models/EasCollectionChunk.d.ts +20 -0
  192. package/dist/types/models/EasCollectionState.d.ts +79 -0
  193. package/dist/types/models/mongo/DeviceSyncStateMongo.d.ts +1 -0
  194. package/dist/types/models/mongo/EasCollectionChunkMongo.d.ts +16 -0
  195. package/dist/types/models/mongo/EasCollectionStateMongo.d.ts +27 -0
  196. package/dist/types/models/sql/DeviceSyncStateSQL.d.ts +1 -0
  197. package/dist/types/models/sql/EasCollectionChunkSQL.d.ts +16 -0
  198. package/dist/types/models/sql/EasCollectionStateSQL.d.ts +27 -0
  199. package/dist/types/mongo.d.ts +2 -0
  200. package/dist/types/sql.d.ts +2 -0
  201. package/package.json +267 -238
@@ -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 client-supplied `?PolicyKey=` query value, if present - the provisioning policy key the device is
22
- * currently operating under. Not yet validated against `deviceSyncState.policyKey` here (deferred to
23
- * `ProvisionCommand`'s own implementation); handlers that care should compare it themselves for now. */
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 { RecoverableBaseEntity, RepoUtils } from "@rapidrest/service-core";
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>"` (e.g. `"3:2026-09-04T12:00:00.000Z"`) - a monotonic generation counter (bumped
6
- * once per successful sync round, satisfying the spec's "the server MUST return a different SyncKey every
7
- * time" requirement) paired with the `dateModified` watermark that generation was issued at.
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[folderUid]`) — deliberately a plain string
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`, replacing the inline
44
- * `deviceSyncStateRepo.update(...)` calls `ProvisionCommand`/`FolderSyncCommand`/`SyncCommand` used to each
45
- * repeat individually.
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
- * This exists because `RepoUtils.update()` does **not** mutate the `existing` object passed to it - confirmed
48
- * by reading its implementation directly - it only returns a freshly-fetched instance reflecting the write,
49
- * leaving the caller's in-memory copy (including its optimistic-concurrency `version`) stale. A single write
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 persistDeviceSyncState(deviceSyncState: DeviceSyncState, deviceSyncStateRepo: RepoUtils<any>, patch: Record<string, unknown>): Promise<void>;
59
- /** One page of enumerated changes for a `RecoverableBaseEntity` collection scoped by a single field (e.g.
60
- * `folderUid` for `Message`/`CalendarEvent`/.../`mailboxUid` for `Folder`), since the last sync at `watermark`. */
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 new watermark to persist as this generation's cursor - the latest `dateModified` actually included
66
- * in this page, never simply "now" (which would silently skip any row modified after this page was read
67
- * but before the caller finishes processing it). Equal to the input `watermark` when nothing changed. */
68
- newWatermark: Date;
69
- /** `true` when more changed rows exist beyond `windowSize` - the caller should set the response's
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 since `watermark`, for `FolderSyncCommand` and
76
- * (eventually) `SyncCommand`'s shared cursor mechanism. Requires `T` to be `RecoverableBaseEntity` (soft
77
- * delete) see `RecoverableRepoUtils`'s own doc comment for why a plain hard-deleted entity can't support
78
- * this at all (nothing left to enumerate once a row is actually gone).
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, watermark: Date, windowSize: number): Promise<ChangeSet<T>>;
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
- * deferred, matching this library's "pragmatic subset" precedent elsewhere (e.g. `FolderSyncCommand`'s SyncKey
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`/`sequence`/`icalUid` have no wire representation at
28
- * all (see this class's own "pragmatic subset" doc comment for `timezone`; the other three are purely
29
- * server-managed identifiers/state a client was never sent in the first place) and are never included in
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
- * `OrganizerEmail` is required for a new event (there is nowhere else to default it from - this adapter
35
- * has no mailbox context of its own) - a real calendar client always sends it regardless, since it already
36
- * knows its own account's address. Omitting it on an `Add` throws, which `SyncCommand.applyAdd` turns into
37
- * Status `6` ("client has sent a malformed or invalid item"), the spec's own designated code for exactly
38
- * this case.
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
- * `Attendees`/`Recurrence` are ghosted as a whole element, like `ContactsSyncAdapter`'s arrays: present at
41
- * all -> rebuilt entirely from what's there; absent -> left untouched on a `Change`.
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 reuse an existing Draft's `bodyBlobKey` on `Change` rather than minting a new one.
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