@rapidmx/mapi-plugin 1.0.0-beta.3 → 1.0.0-beta.5

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 (154) hide show
  1. package/README.md +4 -4
  2. package/dist/lib/BaseMapiEmsmdbRoute.js +229 -37
  3. package/dist/lib/BaseMapiEmsmdbRoute.js.map +1 -1
  4. package/dist/lib/MapiSessionManager.js +404 -48
  5. package/dist/lib/MapiSessionManager.js.map +1 -1
  6. package/dist/lib/RegexPatternUtils.js +30 -0
  7. package/dist/lib/RegexPatternUtils.js.map +1 -0
  8. package/dist/lib/RopDispatcher.js +130 -19
  9. package/dist/lib/RopDispatcher.js.map +1 -1
  10. package/dist/lib/codec/AppointmentRecurrence.js +22 -21
  11. package/dist/lib/codec/AppointmentRecurrence.js.map +1 -1
  12. package/dist/lib/codec/BufferCursor.js +41 -12
  13. package/dist/lib/codec/BufferCursor.js.map +1 -1
  14. package/dist/lib/codec/GlobalObjectId.js +26 -20
  15. package/dist/lib/codec/GlobalObjectId.js.map +1 -1
  16. package/dist/lib/codec/MapiTimeZone.js +43 -10
  17. package/dist/lib/codec/MapiTimeZone.js.map +1 -1
  18. package/dist/lib/codec/PropertyValue.js +28 -2
  19. package/dist/lib/codec/PropertyValue.js.map +1 -1
  20. package/dist/lib/codec/RopBuffer.js +15 -3
  21. package/dist/lib/codec/RopBuffer.js.map +1 -1
  22. package/dist/lib/mongo/MapiEmsmdbRouteMongo.js +2 -1
  23. package/dist/lib/mongo/MapiEmsmdbRouteMongo.js.map +1 -1
  24. package/dist/lib/nspi/NspiCodec.js +5 -5
  25. package/dist/lib/nspi/NspiCodec.js.map +1 -1
  26. package/dist/lib/nspi/NspiGetMatchesHandler.js +14 -14
  27. package/dist/lib/nspi/NspiGetMatchesHandler.js.map +1 -1
  28. package/dist/lib/rop/AddressList.js +109 -0
  29. package/dist/lib/rop/AddressList.js.map +1 -0
  30. package/dist/lib/rop/CalendarEventTarget.js +7 -4
  31. package/dist/lib/rop/CalendarEventTarget.js.map +1 -1
  32. package/dist/lib/rop/ContactTarget.js +2 -6
  33. package/dist/lib/rop/ContactTarget.js.map +1 -1
  34. package/dist/lib/rop/ContentsTable.js +60 -0
  35. package/dist/lib/rop/ContentsTable.js.map +1 -0
  36. package/dist/lib/rop/ExecuteBudget.js +82 -0
  37. package/dist/lib/rop/ExecuteBudget.js.map +1 -0
  38. package/dist/lib/rop/FastTransferStream.js +99 -2
  39. package/dist/lib/rop/FastTransferStream.js.map +1 -1
  40. package/dist/lib/rop/FolderTarget.js +13 -6
  41. package/dist/lib/rop/FolderTarget.js.map +1 -1
  42. package/dist/lib/rop/HandleDataCache.js +412 -0
  43. package/dist/lib/rop/HandleDataCache.js.map +1 -0
  44. package/dist/lib/rop/MeetingMessageClassHandler.js +194 -48
  45. package/dist/lib/rop/MeetingMessageClassHandler.js.map +1 -1
  46. package/dist/lib/rop/MessageBodyStream.js +33 -7
  47. package/dist/lib/rop/MessageBodyStream.js.map +1 -1
  48. package/dist/lib/rop/MessageTarget.js +17 -4
  49. package/dist/lib/rop/MessageTarget.js.map +1 -1
  50. package/dist/lib/rop/NamedPropertyRegistry.js +8 -5
  51. package/dist/lib/rop/NamedPropertyRegistry.js.map +1 -1
  52. package/dist/lib/rop/PropertyResolvers.js +3 -1
  53. package/dist/lib/rop/PropertyResolvers.js.map +1 -1
  54. package/dist/lib/rop/RepoPaging.js +54 -0
  55. package/dist/lib/rop/RepoPaging.js.map +1 -0
  56. package/dist/lib/rop/RestapiRules.js +52 -0
  57. package/dist/lib/rop/RestapiRules.js.map +1 -0
  58. package/dist/lib/rop/RopCreateMessageHandler.js +4 -2
  59. package/dist/lib/rop/RopCreateMessageHandler.js.map +1 -1
  60. package/dist/lib/rop/RopDeleteFolderHandler.js +118 -77
  61. package/dist/lib/rop/RopDeleteFolderHandler.js.map +1 -1
  62. package/dist/lib/rop/RopDeleteMessagesHandler.js +26 -1
  63. package/dist/lib/rop/RopDeleteMessagesHandler.js.map +1 -1
  64. package/dist/lib/rop/RopFastTransferSourceCopyPropertiesHandler.js +14 -14
  65. package/dist/lib/rop/RopFastTransferSourceCopyPropertiesHandler.js.map +1 -1
  66. package/dist/lib/rop/RopFastTransferSourceCopyToHandler.js +13 -14
  67. package/dist/lib/rop/RopFastTransferSourceCopyToHandler.js.map +1 -1
  68. package/dist/lib/rop/RopFastTransferSourceGetBufferHandler.js +33 -32
  69. package/dist/lib/rop/RopFastTransferSourceGetBufferHandler.js.map +1 -1
  70. package/dist/lib/rop/RopGetContentsTableHandler.js +17 -38
  71. package/dist/lib/rop/RopGetContentsTableHandler.js.map +1 -1
  72. package/dist/lib/rop/RopGetHierarchyTableHandler.js +4 -2
  73. package/dist/lib/rop/RopGetHierarchyTableHandler.js.map +1 -1
  74. package/dist/lib/rop/RopGetPropertiesSpecificHandler.js +6 -8
  75. package/dist/lib/rop/RopGetPropertiesSpecificHandler.js.map +1 -1
  76. package/dist/lib/rop/RopHandler.js +5 -1
  77. package/dist/lib/rop/RopHandler.js.map +1 -1
  78. package/dist/lib/rop/RopLogonHandler.js +5 -2
  79. package/dist/lib/rop/RopLogonHandler.js.map +1 -1
  80. package/dist/lib/rop/RopOpenFolderHandler.js +3 -1
  81. package/dist/lib/rop/RopOpenFolderHandler.js.map +1 -1
  82. package/dist/lib/rop/RopOpenMessageHandler.js +3 -1
  83. package/dist/lib/rop/RopOpenMessageHandler.js.map +1 -1
  84. package/dist/lib/rop/RopOpenStreamHandler.js +11 -8
  85. package/dist/lib/rop/RopOpenStreamHandler.js.map +1 -1
  86. package/dist/lib/rop/RopQueryRowsHandler.js +31 -5
  87. package/dist/lib/rop/RopQueryRowsHandler.js.map +1 -1
  88. package/dist/lib/rop/RopReadStreamHandler.js +22 -4
  89. package/dist/lib/rop/RopReadStreamHandler.js.map +1 -1
  90. package/dist/lib/rop/RopReleaseHandler.js +4 -1
  91. package/dist/lib/rop/RopReleaseHandler.js.map +1 -1
  92. package/dist/lib/rop/RopSaveChangesMessageHandler.js +66 -7
  93. package/dist/lib/rop/RopSaveChangesMessageHandler.js.map +1 -1
  94. package/dist/lib/rop/RopSetColumnsHandler.js +6 -8
  95. package/dist/lib/rop/RopSetColumnsHandler.js.map +1 -1
  96. package/dist/lib/rop/RopSetPropertiesHandler.js +23 -12
  97. package/dist/lib/rop/RopSetPropertiesHandler.js.map +1 -1
  98. package/dist/lib/rop/RopSubmitMessageHandler.js +148 -120
  99. package/dist/lib/rop/RopSubmitMessageHandler.js.map +1 -1
  100. package/dist/lib/rop/RopWriteStreamHandler.js +37 -10
  101. package/dist/lib/rop/RopWriteStreamHandler.js.map +1 -1
  102. package/dist/lib/rop/TaskTarget.js +2 -6
  103. package/dist/lib/rop/TaskTarget.js.map +1 -1
  104. package/dist/lib/rop/TransportSend.js +16 -0
  105. package/dist/lib/rop/TransportSend.js.map +1 -0
  106. package/dist/lib/sql/MapiEmsmdbRouteSQL.js +2 -1
  107. package/dist/lib/sql/MapiEmsmdbRouteSQL.js.map +1 -1
  108. package/dist/types/BaseMapiEmsmdbRoute.d.ts +59 -2
  109. package/dist/types/MapiSessionManager.d.ts +218 -63
  110. package/dist/types/RegexPatternUtils.d.ts +14 -0
  111. package/dist/types/RopDispatcher.d.ts +47 -11
  112. package/dist/types/codec/BufferCursor.d.ts +14 -0
  113. package/dist/types/codec/GlobalObjectId.d.ts +9 -5
  114. package/dist/types/codec/PropertyValue.d.ts +10 -0
  115. package/dist/types/codec/RopBuffer.d.ts +7 -0
  116. package/dist/types/mongo/MapiEmsmdbRouteMongo.d.ts +1 -0
  117. package/dist/types/nspi/NspiGetMatchesHandler.d.ts +2 -0
  118. package/dist/types/rop/AddressList.d.ts +48 -0
  119. package/dist/types/rop/CalendarEventTarget.d.ts +7 -3
  120. package/dist/types/rop/ContactTarget.d.ts +3 -3
  121. package/dist/types/rop/ContentsTable.d.ts +16 -0
  122. package/dist/types/rop/ExecuteBudget.d.ts +48 -0
  123. package/dist/types/rop/FastTransferStream.d.ts +41 -2
  124. package/dist/types/rop/FolderTarget.d.ts +3 -2
  125. package/dist/types/rop/HandleDataCache.d.ts +145 -0
  126. package/dist/types/rop/MeetingMessageClassHandler.d.ts +23 -29
  127. package/dist/types/rop/MessageBodyStream.d.ts +16 -7
  128. package/dist/types/rop/MessageTarget.d.ts +10 -3
  129. package/dist/types/rop/NamedPropertyRegistry.d.ts +6 -2
  130. package/dist/types/rop/PropertyResolvers.d.ts +1 -1
  131. package/dist/types/rop/RepoPaging.d.ts +30 -0
  132. package/dist/types/rop/RestapiRules.d.ts +32 -0
  133. package/dist/types/rop/RopCreateMessageHandler.d.ts +1 -0
  134. package/dist/types/rop/RopDeleteFolderHandler.d.ts +36 -21
  135. package/dist/types/rop/RopDeleteMessagesHandler.d.ts +7 -0
  136. package/dist/types/rop/RopFastTransferSourceCopyPropertiesHandler.d.ts +1 -0
  137. package/dist/types/rop/RopFastTransferSourceCopyToHandler.d.ts +1 -0
  138. package/dist/types/rop/RopFastTransferSourceGetBufferHandler.d.ts +10 -11
  139. package/dist/types/rop/RopGetContentsTableHandler.d.ts +7 -13
  140. package/dist/types/rop/RopGetHierarchyTableHandler.d.ts +1 -0
  141. package/dist/types/rop/RopHandler.d.ts +40 -1
  142. package/dist/types/rop/RopOpenFolderHandler.d.ts +1 -0
  143. package/dist/types/rop/RopOpenMessageHandler.d.ts +1 -0
  144. package/dist/types/rop/RopOpenStreamHandler.d.ts +1 -0
  145. package/dist/types/rop/RopQueryRowsHandler.d.ts +3 -0
  146. package/dist/types/rop/RopReadStreamHandler.d.ts +3 -1
  147. package/dist/types/rop/RopReleaseHandler.d.ts +1 -0
  148. package/dist/types/rop/RopSaveChangesMessageHandler.d.ts +16 -1
  149. package/dist/types/rop/RopSubmitMessageHandler.d.ts +33 -23
  150. package/dist/types/rop/RopWriteStreamHandler.d.ts +18 -8
  151. package/dist/types/rop/TaskTarget.d.ts +3 -3
  152. package/dist/types/rop/TransportSend.d.ts +10 -0
  153. package/dist/types/sql/MapiEmsmdbRouteSQL.d.ts +1 -0
  154. package/package.json +3 -3
@@ -50,6 +50,16 @@ export type PropertyValueData = number | bigint | boolean | string | Buffer | Da
50
50
  * the low half. On the wire (little-endian), that means `PropertyType` is read/written first.
51
51
  */
52
52
  export declare function readPropertyTag(reader: BufferReader): PropertyTag;
53
+ /** The most `PropertyTag`s accepted in one request's tag array (`RopSetColumns`, `RopGetPropertiesSpecific`,
54
+ * the FastTransfer source ROPs, NSPI `Columns`). Every tag multiplies the per-row resolution work and, for
55
+ * `RopSetColumns`, the stored session state; a real client asks for far fewer. */
56
+ export declare const MAX_PROPERTY_TAG_COUNT = 256;
57
+ /**
58
+ * Reads `count` consecutive `PropertyTag`s. When `count` exceeds `MAX_PROPERTY_TAG_COUNT`, the tags' bytes are
59
+ * skipped (still bounds-checked, so a count larger than the buffer throws) and `undefined` is returned, leaving
60
+ * the reader positioned after the array so the caller can send an error response and keep parsing later ROPs.
61
+ */
62
+ export declare function readPropertyTagArray(reader: BufferReader, count: number): PropertyTag[] | undefined;
53
63
  export declare function writePropertyTag(writer: BufferWriter, tag: PropertyTag): void;
54
64
  /** `TypedPropertyValue` structure (`[MS-OXCDATA]` §2.11.4): `PropertyType` (2 bytes) followed by the value
55
65
  * itself, encoded per `readPropertyValue`/`writePropertyValue` below. */
@@ -21,5 +21,12 @@ export interface RopBuffer {
21
21
  * ROPs within `ropsList` reference them by index. */
22
22
  handleTable: number[];
23
23
  }
24
+ /** The largest `ServerObjectHandleTable` accepted. Every ROP addresses a handle by a one-byte index, so a table
25
+ * with more than 255 entries can't be referenced anyway; a larger one is only a way to make the server allocate
26
+ * and echo back an oversized array. */
27
+ export declare const MAX_HANDLE_TABLE_ENTRIES = 255;
24
28
  export declare function encodeRopBuffer(buf: RopBuffer): Buffer;
29
+ /** Throws a `RangeError` for a malformed buffer: a `RopSize` smaller than its own field or larger than the
30
+ * buffer, a handle table that isn't a whole number of 4-byte entries, or one with more than
31
+ * `MAX_HANDLE_TABLE_ENTRIES` entries. */
25
32
  export declare function decodeRopBuffer(buffer: Buffer): RopBuffer;
@@ -15,5 +15,6 @@ export declare class MapiEmsmdbRouteMongo extends BaseMapiEmsmdbRoute<MailboxMon
15
15
  protected contactClass: any;
16
16
  protected taskClass: any;
17
17
  protected labelClass: any;
18
+ protected auditLogClass: any;
18
19
  protected ropHandlerClasses: any[];
19
20
  }
@@ -1,4 +1,6 @@
1
1
  import type { HttpRequest, HttpResponse, RepoUtils } from "@rapidrest/service-core";
2
+ /** The most rows one `GetMatches` lookup asks the repo for per query - `RepoUtils.find()`'s own page cap. */
3
+ export declare const MAX_MATCH_ROWS = 1000;
2
4
  /**
3
5
  * `GetMatches` request type (`[MS-OXCMAPIHTTP]` §2.2.5.5, backed by `[MS-OXNSPI]`'s own `NspiGetMatches`
4
6
  * method): searches the GAL (this library's `Contact` records, the same source of truth EAS's own `Search`
@@ -0,0 +1,48 @@
1
+ import type { RopContext } from "./RopHandler.js";
2
+ /** Splits a `PidTagDisplayTo`/`Cc`/`Bcc`-style string on the semicolons Outlook separates recipients with,
3
+ * trimming and dropping empty entries. Commas are not separators: a resolved recipient's display name commonly
4
+ * contains one (`Doe, Jane <jane@example.com>`). */
5
+ export declare function splitAddressList(value: string | undefined): string[];
6
+ /** `true` for a plain SMTP address (see `PLAIN_EMAIL_ADDRESS`) no longer than RFC 5321's 254 characters. These
7
+ * strings reach MIME headers, the SMTP envelope and iCalendar `mailto:` lines, where a CR/LF would inject content. */
8
+ export declare function isPlainEmailAddress(value: string): boolean;
9
+ /** One entry of a display string: an address with an optional display name, a display name alone (`address`
10
+ * undefined, still to be resolved), or something that can't be used (`invalid`). */
11
+ export interface RecipientEntry {
12
+ name: string;
13
+ address?: string;
14
+ invalid?: boolean;
15
+ }
16
+ /**
17
+ * Parses one `;`-separated entry with nodemailer's `addressparser`: `jane@example.com`, `Jane <jane@example.com>`,
18
+ * `"Doe, Jane" <jane@example.com>` or a bare display name `Jane Doe`. An entry holding several addresses yields one
19
+ * recipient each. Any address that isn't a plain SMTP address marks the entry invalid.
20
+ */
21
+ export declare function parseRecipientEntry(entry: string): RecipientEntry[];
22
+ /** Every entry of a `PidTagDisplayTo`/`Cc`/`Bcc`-style string - see `parseRecipientEntry`. */
23
+ export declare function parseRecipientList(value: string | undefined): RecipientEntry[];
24
+ /** The plain addresses in a `PidTagDisplayTo`/`Cc`/`Bcc`-style string, including those written as
25
+ * `Name <address>`, skipping display names without an address and anything invalid. */
26
+ export declare function parseAddressList(value: string | undefined): string[];
27
+ /** A recipient ready to send to. */
28
+ export interface ResolvedRecipient {
29
+ name: string;
30
+ address: string;
31
+ }
32
+ /** The outcome of `resolveRecipientList`: the usable recipients, and the entries that were display names with no
33
+ * single matching contact (`unresolved`) or not usable at all (`invalid`). */
34
+ export interface RecipientResolution {
35
+ recipients: ResolvedRecipient[];
36
+ unresolved: string[];
37
+ invalid: string[];
38
+ }
39
+ /** The most recipients (To, Cc and Bcc together) one submitted message or meeting may have - the same limit
40
+ * `@rapidmx/activesync`'s `ComposeMailCommand` enforces (`MAX_COMPOSE_RECIPIENTS`). */
41
+ export declare const MAX_RECIPIENTS_PER_MESSAGE = 500;
42
+ /**
43
+ * Resolves a display string into addresses. An entry carrying an address is used as is; a bare display name (Outlook
44
+ * writes names, not addresses, into `PidTagDisplayTo` once it has resolved a recipient against an address book) is
45
+ * looked up among the caller's own contacts by exact display name and used when exactly one contact matches, with
46
+ * that contact's first valid email address.
47
+ */
48
+ export declare function resolveRecipientList(value: string | undefined, context: Pick<RopContext, "mailboxUid" | "contactRepo">): Promise<RecipientResolution>;
@@ -1,5 +1,7 @@
1
1
  import type { RepoUtils } from "@rapidrest/service-core";
2
2
  import { Attendee, BusyStatus, RecurrenceRule } from "@rapidmx/restapi";
3
+ import type { ExecuteBudget } from "./ExecuteBudget.js";
4
+ import { type RepoSort } from "./RepoPaging.js";
3
5
  /**
4
6
  * The `RopGetContentsTable`/`RopOpenMessage` analog of `MessageTarget.ts`, for a `Folder` of type `CALENDAR`:
5
7
  * resolves a `"calendarEvent:<uid>"` row/handle target into the display data a calendar-item row or an opened
@@ -24,6 +26,8 @@ export interface CalendarEventTargetInfo {
24
26
  * vanished (soft-deleted or otherwise) - the same "don't fail the whole ROP over one stale row" principle
25
27
  * `MessageTarget.resolveMessageInfo`/`FolderTarget.resolveFolderInfo` already apply. */
26
28
  export declare function resolveCalendarEventInfo(target: string, calendarEventRepo: RepoUtils<any>): Promise<CalendarEventTargetInfo>;
27
- /** Resolves the calendar events directly in `folderUid`, as `"calendarEvent:<uid>"` target strings, for
28
- * `RopGetContentsTable` against a `CALENDAR`-type folder. */
29
- export declare function resolveFolderCalendarEvents(folderUid: string, calendarEventRepo: RepoUtils<any>): Promise<string[]>;
29
+ /** The order a calendar folder's events are listed in: latest start first, ties broken by `uid`. */
30
+ export declare const CALENDAR_EVENT_SORT: RepoSort;
31
+ /** Resolves the calendar events directly in `folderUid`, latest first and capped at `MAX_COLLECTION_ROWS`, as
32
+ * `"calendarEvent:<uid>"` target strings. */
33
+ export declare function resolveFolderCalendarEvents(folderUid: string, calendarEventRepo: RepoUtils<any>, budget?: ExecuteBudget): Promise<string[]>;
@@ -1,4 +1,5 @@
1
1
  import type { RepoUtils } from "@rapidrest/service-core";
2
+ import type { RepoSort } from "./RepoPaging.js";
2
3
  /**
3
4
  * The `RopGetContentsTable`/`RopOpenMessage` analog of `MessageTarget.ts`, for a `Folder` of type `CONTACTS`:
4
5
  * resolves a `"contact:<uid>"` row/handle target into the display data a contact-item row or an opened
@@ -24,6 +25,5 @@ export interface ContactTargetInfo {
24
25
  * (soft-deleted or otherwise) - the same "don't fail the whole ROP over one stale row" principle
25
26
  * `MessageTarget.resolveMessageInfo`/`CalendarEventTarget.resolveCalendarEventInfo` already apply. */
26
27
  export declare function resolveContactInfo(target: string, contactRepo: RepoUtils<any>): Promise<ContactTargetInfo>;
27
- /** Resolves the contacts directly in `folderUid`, as `"contact:<uid>"` target strings, for
28
- * `RopGetContentsTable` against a `CONTACTS`-type folder. */
29
- export declare function resolveFolderContacts(folderUid: string, contactRepo: RepoUtils<any>): Promise<string[]>;
28
+ /** The order a `CONTACTS` folder's contents table lists contacts in (see `ContentsTable.ts`). */
29
+ export declare const CONTACT_SORT: RepoSort;
@@ -0,0 +1,16 @@
1
+ import type { MapiObjectHandle } from "../MapiSessionManager.js";
2
+ import type { RopContext } from "./RopHandler.js";
3
+ export type ContentsKind = NonNullable<MapiObjectHandle["contentsKind"]>;
4
+ /** Which repo a folder's items live in, decided by the folder's own `type`: a `Contact`/`Task`/`CalendarEvent` is
5
+ * its own entity, never a `Message` row. */
6
+ export declare function resolveContentsKind(folderUid: string, context: RopContext): Promise<ContentsKind>;
7
+ /**
8
+ * The `"<kind>:<uid>"` row targets at positions `[start, start + count)` of a contents table (a `"folder:<uid>"`
9
+ * table with `contentsKind` set), read straight from the database in a fixed, stable order. A context without the
10
+ * matching optional repo yields no rows.
11
+ *
12
+ * Reading a window per `RopQueryRows` instead of snapshotting every row at `RopGetContentsTable` time keeps session
13
+ * state small and makes no folder too large to page through. The trade-off is that a message added or removed
14
+ * between two `RopQueryRows` calls can shift the rows that follow.
15
+ */
16
+ export declare function resolveContentsWindow(context: RopContext, table: MapiObjectHandle, start: number, count: number): Promise<string[]>;
@@ -0,0 +1,48 @@
1
+ /** The most rows (items or folders) whose properties one `Execute` resolves, across all of its ROPs. */
2
+ export declare const MAX_ROWS_RESOLVED_PER_EXECUTE = 20000;
3
+ /** The most bytes one `Execute` builds or parses (FastTransfer streams and message bodies), across all of its ROPs. */
4
+ export declare const MAX_BYTES_BUILT_PER_EXECUTE: number;
5
+ /** The most database queries (paged reads, table lookups, deletes) one `Execute` runs through the budgeted paths. */
6
+ export declare const MAX_QUERIES_PER_EXECUTE = 20000;
7
+ /** The most rows one `Execute` reads back from paged queries (folder lists, folder walks, item lists). */
8
+ export declare const MAX_ROWS_FETCHED_PER_EXECUTE = 100000;
9
+ /** The most `RopSubmitMessage`s one `Execute` runs. Each one can relay mail, so a request can't repeat it 1024 times. */
10
+ export declare const MAX_SUBMITS_PER_EXECUTE = 16;
11
+ /** Thrown when an `Execute` has used up its `ExecuteBudget`. The ROP that hit it fails; see `RopDispatcher`. */
12
+ export declare class WorkBudgetExceededError extends Error {
13
+ constructor(what: string);
14
+ }
15
+ /**
16
+ * The work one `Execute` may do. Limits on ROP count and per-ROP sizes alone still let one request chain many
17
+ * expensive ROPs (a dozen 10000-row FastTransfer dumps, the same large body parsed by several streams, or a thousand
18
+ * hierarchy tables or folder walks), so rows resolved, bytes built, queries run, rows fetched and submits are counted
19
+ * across the whole request. Every charge happens before the work it pays for. Also dedupes message body parses within
20
+ * the request (`bodies`).
21
+ */
22
+ export declare class ExecuteBudget {
23
+ rowsRemaining: number;
24
+ bytesRemaining: number;
25
+ queriesRemaining: number;
26
+ fetchedRowsRemaining: number;
27
+ submitsRemaining: number;
28
+ /** Message uid -> its decoded body, so opening the same body twice in one request parses it once. */
29
+ readonly bodies: Map<string, Promise<Buffer<ArrayBufferLike>>>;
30
+ constructor(maxRows?: number, maxBytes?: number, limits?: {
31
+ maxQueries?: number;
32
+ maxFetchedRows?: number;
33
+ maxSubmits?: number;
34
+ });
35
+ /** Uses `count` rows of the budget, throwing `WorkBudgetExceededError` once it is exhausted. */
36
+ chargeRows(count?: number): void;
37
+ /** Uses `count` bytes of the budget, throwing `WorkBudgetExceededError` once it is exhausted. */
38
+ chargeBytes(count: number): void;
39
+ /** Throws `WorkBudgetExceededError` when no bytes are left, so work that can't be sized up front (fetching a blob)
40
+ * isn't started at all. */
41
+ assertBytesLeft(): void;
42
+ /** Uses `count` queries of the budget. Called before running them. */
43
+ chargeQueries(count?: number): void;
44
+ /** Uses `count` fetched rows of the budget. */
45
+ chargeFetchedRows(count: number): void;
46
+ /** Uses one submit of the budget. */
47
+ chargeSubmit(): void;
48
+ }
@@ -1,18 +1,57 @@
1
1
  import { PropertyType } from "../codec/PropertyValue.js";
2
- import type { MapiObjectHandle } from "../MapiSessionManager.js";
3
- import type { RopContext } from "./RopHandler.js";
2
+ import { type MapiObjectHandle } from "../MapiSessionManager.js";
3
+ import { type RopContext } from "./RopHandler.js";
4
4
  interface PropertyColumn {
5
5
  propertyId: number;
6
6
  propertyType: PropertyType;
7
7
  }
8
+ /** The largest FastTransfer stream built for one handle; a larger source fails with `MAPI_E_TOO_BIG`. */
9
+ export declare const MAX_FAST_TRANSFER_BYTES: number;
10
+ /** How long a built stream is kept for paging out. A client pages a stream out right after opening it; one that
11
+ * leaves it longer than this has to open the transfer again. */
12
+ export declare const FAST_TRANSFER_TTL_SECONDS: number;
13
+ /** Thrown while building a stream that has grown past its byte limit. */
14
+ export declare class FastTransferTooBigError extends Error {
15
+ constructor();
16
+ }
8
17
  /**
9
18
  * Builds the complete FastTransfer stream for `handle` (a `"folder"` or `"message"` Server object). `columns`,
10
19
  * when given (a `RopFastTransferSourceCopyProperties` explicit include list), replaces every default column set
11
20
  * uniformly at both folder- and message-level; `excludePropertyIds` (a `RopFastTransferSourceCopyTo` exclude
12
21
  * list) is only ever applied to this pragmatic subset's own default columns, per the same reasoning.
22
+ *
23
+ * The size is checked after every item, so a huge folder stops being built as soon as it passes `maxBytes`
24
+ * (`FastTransferTooBigError`) instead of being fully built first. Every item resolved and the finished stream's bytes
25
+ * count against the request's `ExecuteBudget` when there is one.
13
26
  */
14
27
  export declare function buildFastTransferStream(handle: MapiObjectHandle, context: RopContext, options?: {
15
28
  columns?: PropertyColumn[];
16
29
  excludePropertyIds?: ReadonlySet<number>;
30
+ maxBytes?: number;
17
31
  }): Promise<Buffer>;
32
+ /** Why `loadFastTransferBuffer` has no stream: `"tooBig"` when a rebuild passed `MAX_FAST_TRANSFER_BYTES`, `"lost"`
33
+ * when the stored stream is gone part-way through paging. */
34
+ export type FastTransferLoadFailure = "tooBig" | "lost";
35
+ /**
36
+ * The built stream for the `"fastTransfer"` handle at `handleIndex`, from the shared `HandleDataStore` (Redis when
37
+ * configured, so any replica can page it out).
38
+ *
39
+ * When the stream is gone (expired, evicted, or built by a replica without shared storage), it is rebuilt only if
40
+ * nothing has been paged out yet. Part-way through, a rebuild could differ from what the client already has (items
41
+ * added or changed since), and continuing at the old offset would hand it bytes from a different stream, so that
42
+ * case reports `"lost"` and the client restarts the transfer. A rebuild is held to `MAX_FAST_TRANSFER_BYTES` like
43
+ * the original build, and storing it to the session's and user's handle data quota (`handleDataOwners`); either
44
+ * failing reports `"tooBig"`.
45
+ */
46
+ export declare function loadFastTransferBuffer(context: RopContext, handleIndex: number, transfer: MapiObjectHandle): Promise<Buffer | FastTransferLoadFailure>;
47
+ /**
48
+ * Shared by `RopFastTransferSourceCopyTo`/`CopyProperties`: builds the stream for `source`, and on success stores a
49
+ * `"fastTransfer"` handle at `outputHandleIndex` with the stream in the `HandleDataStore`. Returns `false`, storing
50
+ * nothing, when the stream exceeds `MAX_FAST_TRANSFER_BYTES` or the session's or user's handle data quota
51
+ * (`handleDataOwners`) has no room for it.
52
+ */
53
+ export declare function openFastTransferHandle(context: RopContext, outputHandleIndex: number, source: MapiObjectHandle, options: {
54
+ columns?: PropertyColumn[];
55
+ excludePropertyIds?: number[];
56
+ }): Promise<boolean>;
18
57
  export {};
@@ -1,6 +1,7 @@
1
1
  import type { RepoUtils } from "@rapidrest/service-core";
2
2
  import { Folder } from "@rapidmx/restapi";
3
3
  import type { MapiSessionContext } from "../MapiSessionManager.js";
4
+ import type { ExecuteBudget } from "./ExecuteBudget.js";
4
5
  export interface FolderTargetInfo {
5
6
  displayName: string;
6
7
  unreadCount: number;
@@ -19,7 +20,7 @@ export interface FolderResolutionCache {
19
20
  /** Resolves a target string into the display data a folder-table row needs. A `"folder:<uid>"` target whose
20
21
  * `Folder` has since been deleted (soft-deleted or otherwise vanished) degrades to empty-looking values rather
21
22
  * than throwing - the row simply won't be interesting to a client, not a reason to fail the whole ROP. */
22
- export declare function resolveFolderInfo(mailboxUid: string, target: string, folderRepo: RepoUtils<any>, cache?: FolderResolutionCache): Promise<FolderTargetInfo>;
23
+ export declare function resolveFolderInfo(mailboxUid: string, target: string, folderRepo: RepoUtils<any>, cache?: FolderResolutionCache, budget?: ExecuteBudget): Promise<FolderTargetInfo>;
23
24
  /**
24
25
  * Resolves the direct children of `target`, as an array of the same target-string format, for
25
26
  * `RopGetHierarchyTable`. Only `"virtual:root"`/`"virtual:ipmSubtree"` (this mailbox's top-level real
@@ -44,7 +45,7 @@ export declare function resolveFolderInfo(mailboxUid: string, target: string, fo
44
45
  * that batch so the full list is fetched at most once per batch instead of once per row - see
45
46
  * `FolderResolutionCache`'s own doc comment for why this isn't session state.
46
47
  */
47
- export declare function resolveFolderChildren(mailboxUid: string, target: string, folderRepo: RepoUtils<any>, cache?: FolderResolutionCache): Promise<string[]>;
48
+ export declare function resolveFolderChildren(mailboxUid: string, target: string, folderRepo: RepoUtils<any>, cache?: FolderResolutionCache, budget?: ExecuteBudget): Promise<string[]>;
48
49
  /**
49
50
  * Returns `target`'s existing FID if `RopLogon` or an earlier `RopGetHierarchyTable` row already assigned
50
51
  * one, otherwise assigns and remembers the next free small integer FID. This is what lets a client
@@ -0,0 +1,145 @@
1
+ /** How long an entry lives without being read - the same idle lifetime as a session. */
2
+ export declare const HANDLE_DATA_TTL_MS: number;
3
+ /** The most bytes this process keeps across all entries before evicting the least recently used ones. */
4
+ export declare const HANDLE_DATA_MAX_BYTES: number;
5
+ /**
6
+ * A process-local, byte-bounded LRU with TTL for large per-handle data: an opened stream's decoded message body
7
+ * and a FastTransfer handle's built stream. Keeping these out of the session JSON means an `Execute` doesn't
8
+ * re-serialize megabytes to Redis, and a `RopReadStream` doesn't re-fetch and re-parse the whole MIME message
9
+ * for every chunk.
10
+ *
11
+ * It is only a cache. Every caller can rebuild an entry from the handle's own (small) session state, so a miss
12
+ * (another replica served the previous request, eviction, TTL) costs one rebuild, never wrong data. Keys carry
13
+ * the handle's generation (see `assignHandle`), so a reused handle index never picks up a previous handle's data.
14
+ */
15
+ export declare class HandleDataCache {
16
+ private readonly maxBytes;
17
+ private readonly ttlMs;
18
+ private readonly entries;
19
+ private totalBytes;
20
+ constructor(maxBytes?: number, ttlMs?: number);
21
+ get(key: string): Buffer | undefined;
22
+ /** Stores `value` under `key`. A value larger than the whole budget isn't cached at all. */
23
+ set(key: string, value: Buffer): void;
24
+ /** The size of the live entry at `key`, without refreshing it or changing its recency. */
25
+ sizeOf(key: string): number | undefined;
26
+ delete(key: string): void;
27
+ /** The bytes currently held, for tests and diagnostics. */
28
+ get size(): number;
29
+ }
30
+ /** The process-wide instance every handler shares. */
31
+ export declare const handleDataCache: HandleDataCache;
32
+ /** The cache key for the handle at `handleIndex`, as of its current generation. A generation is a random nonce
33
+ * (see `assignHandle`), so a key is never reused, not even by a request whose session changes were never saved. */
34
+ export declare function handleDataKey(sessionUid: string, handleIndex: number, generation: string | undefined): string;
35
+ /** The `HandleDataStore` key of the write stream at `handleIndex`. */
36
+ export declare function writeStreamKey(sessionUid: string, handleIndex: number, generation: string | undefined): string;
37
+ /** The most bytes of FastTransfer streams and write-stream chunks one session keeps stored at once. */
38
+ export declare const MAX_HANDLE_DATA_BYTES_PER_SESSION: number;
39
+ /** The most bytes of FastTransfer streams and write-stream chunks one user keeps stored at once, across all of their
40
+ * sessions. */
41
+ export declare const MAX_HANDLE_DATA_BYTES_PER_USER: number;
42
+ /** How long an owner index outlives its last write: a session's longest lifetime. */
43
+ export declare const HANDLE_DATA_INDEX_TTL_SECONDS: number;
44
+ /** The most chunks one write stream keeps. Each `RopWriteStream` stores one chunk, so this bounds the hash a stream
45
+ * builds up (and the work `readChunks` does) however small the writes are. */
46
+ export declare const MAX_CHUNKS_PER_STREAM = 8192;
47
+ /** How often, at most, an owner index that is over its quota is recounted from its members. Between recounts a write is
48
+ * checked against the index's running total alone, so a client hammering a full quota can't make every write walk
49
+ * every member. */
50
+ export declare const OWNER_RECOUNT_INTERVAL_MS = 1000;
51
+ /** An index that stored data is counted against: the keys it holds and the most bytes they may add up to. */
52
+ export interface HandleDataOwner {
53
+ index: string;
54
+ maxBytes: number;
55
+ }
56
+ /** The index of everything a session stores, used for its quota and to delete it all when the session ends. */
57
+ export declare function sessionHandleDataIndex(sessionUid: string): string;
58
+ /** The quota owners a write by `sessionUid` (belonging to `userUid`) counts against. */
59
+ export declare function handleDataOwners(sessionUid: string, userUid: string): HandleDataOwner[];
60
+ /**
61
+ * Where per-handle data that has to outlive one `Execute` lives: a built FastTransfer stream, and the chunks a write
62
+ * stream has accumulated. With Redis (`RedisHandleDataStore`) every replica sees the same data, so a client can page
63
+ * a stream out or finish writing a body through any pod. Without it (`MemoryHandleDataStore`) the data is only in
64
+ * this process.
65
+ *
66
+ * **Quotas.** A write given `owners` is recorded in each owner's index and refused (`false`, nothing stored) when it
67
+ * would take any owner past its `maxBytes`. Sizes are raw (unencoded) bytes. Every write costs O(1) per owner: each
68
+ * index keeps a running total, and a chunked stream keeps its own size (the end of its furthest chunk), so neither the
69
+ * stream's chunks nor the index's members are walked. A delete given the same `owners` takes the key's bytes off their
70
+ * totals. Data that disappears otherwise (TTL, eviction, `deleteOwnedBy` of another index, a delete without owners)
71
+ * keeps counting until a write that the total would refuse recounts the index from its live members, at most once per
72
+ * `OWNER_RECOUNT_INTERVAL_MS` per index. Totals only ever over-count, never under-count.
73
+ */
74
+ export interface HandleDataStore {
75
+ get(key: string): Promise<Buffer | undefined>;
76
+ /** Stores `value` at `key`. `false` when an owner's quota refused it. */
77
+ set(key: string, value: Buffer, ttlSeconds: number, owners?: HandleDataOwner[]): Promise<boolean>;
78
+ /** Stores `data` as the chunk starting at byte `offset` of the stream at `key`. Writing the same offset again
79
+ * replaces the chunk, so a retried write never duplicates bytes or charges the quota twice. `false` when an owner's
80
+ * quota refused it, or the stream already has `MAX_CHUNKS_PER_STREAM` chunks and this is a new one. */
81
+ putChunk(key: string, offset: number, data: Buffer, ttlSeconds: number, owners?: HandleDataOwner[]): Promise<boolean>;
82
+ /** The first `size` bytes of the stream at `key`, assembled from its chunks, or `undefined` when a chunk is
83
+ * missing (expired or evicted) and the stream can't be reassembled exactly. */
84
+ readChunks(key: string, size: number): Promise<Buffer | undefined>;
85
+ /** Deletes the value or chunked stream at `key`, taking it off the totals of `owners`. */
86
+ delete(key: string, owners?: HandleDataOwner[]): Promise<void>;
87
+ /** Deletes everything recorded in the owner index `index`, and the index itself. */
88
+ deleteOwnedBy(index: string): Promise<void>;
89
+ }
90
+ /** A `HandleDataStore` in this process only, on a `HandleDataCache` (so it is byte-bounded and can evict). */
91
+ export declare class MemoryHandleDataStore implements HandleDataStore {
92
+ private readonly cache;
93
+ private readonly indexes;
94
+ private readonly streams;
95
+ constructor(cache?: HandleDataCache);
96
+ get(key: string): Promise<Buffer | undefined>;
97
+ set(key: string, value: Buffer, _ttlSeconds: number, owners?: HandleDataOwner[]): Promise<boolean>;
98
+ putChunk(key: string, offset: number, data: Buffer, _ttlSeconds: number, owners?: HandleDataOwner[]): Promise<boolean>;
99
+ readChunks(key: string, size: number): Promise<Buffer | undefined>;
100
+ delete(key: string, owners?: HandleDataOwner[]): Promise<void>;
101
+ deleteOwnedBy(index: string): Promise<void>;
102
+ /** `true` while `key` still holds a value or its stream's first chunk (a stream missing that can't be read). */
103
+ private exists;
104
+ /** Records `key` at `bytes` in every owner index, unless that would take one past its quota. */
105
+ private admit;
106
+ /** `true` when `key` at `bytes` keeps `index` within `maxBytes`, recounting an index that looks full (rate limited). */
107
+ private fits;
108
+ }
109
+ /** Stores a value within its owners' quotas. KEYS: data key, then each owner index. ARGV: data key prefix, member name,
110
+ * index ttl, recount interval ms, base64 value, ttl, raw bytes, then each owner's max bytes. Returns 1 stored, 0 refused. */
111
+ export declare const SET_WITH_QUOTA_SCRIPT = "\nlocal function recount(index, prefix, indexTtl)\n local total = 0\n local members = redis.call('ZRANGE', index, 0, -1, 'WITHSCORES')\n for j = 1, #members, 2 do\n if redis.call('EXISTS', prefix .. members[j]) == 0 then\n redis.call('ZREM', index, members[j])\n else\n total = total + tonumber(members[j + 1])\n end\n end\n redis.call('SET', index .. '.total', tostring(total), 'EX', indexTtl)\n return total\nend\nlocal function admits(index, member, bytes, maxBytes, prefix, recountMs, indexTtl)\n local stored = redis.call('GET', index .. '.total')\n local fresh = not stored\n local total = stored and tonumber(stored) or recount(index, prefix, indexTtl)\n local old = tonumber(redis.call('ZSCORE', index, member) or 0)\n if total - old + bytes <= maxBytes then return true end\n if fresh or not redis.call('SET', index .. '.recount', '1', 'PX', recountMs, 'NX') then return false end\n total = recount(index, prefix, indexTtl)\n old = tonumber(redis.call('ZSCORE', index, member) or 0)\n return total - old + bytes <= maxBytes\nend\nlocal function record(index, member, bytes, indexTtl)\n local old = tonumber(redis.call('ZSCORE', index, member) or 0)\n redis.call('ZADD', index, tostring(bytes), member)\n redis.call('INCRBY', index .. '.total', tostring(bytes - old))\n redis.call('EXPIRE', index, indexTtl)\n redis.call('EXPIRE', index .. '.total', indexTtl)\nend\n\nlocal bytes = tonumber(ARGV[7])\nfor i = 2, #KEYS do\n if not admits(KEYS[i], ARGV[2], bytes, tonumber(ARGV[i + 6]), ARGV[1], ARGV[4], ARGV[3]) then return 0 end\nend\nredis.call('SETEX', KEYS[1], ARGV[6], ARGV[5])\nfor i = 2, #KEYS do record(KEYS[i], ARGV[2], bytes, ARGV[3]) end\nreturn 1\n";
112
+ /** Stores one write-stream chunk within its owners' quotas, in O(1): the hash keeps its size (the end of its furthest
113
+ * chunk) in the `size` field, and that size is what the owners are charged, so a chunk rewritten at the same offset
114
+ * isn't charged twice. KEYS: hash key, then each owner index. ARGV: data key prefix, member name, index ttl, recount
115
+ * interval ms, unpadded base64url chunk, ttl, raw chunk bytes, hash field (offset), max chunks, then each owner's max
116
+ * bytes. Returns 1 stored, 0 refused (quota, or a new chunk past the max chunks). */
117
+ export declare const PUT_CHUNK_WITH_QUOTA_SCRIPT = "\nlocal function recount(index, prefix, indexTtl)\n local total = 0\n local members = redis.call('ZRANGE', index, 0, -1, 'WITHSCORES')\n for j = 1, #members, 2 do\n if redis.call('EXISTS', prefix .. members[j]) == 0 then\n redis.call('ZREM', index, members[j])\n else\n total = total + tonumber(members[j + 1])\n end\n end\n redis.call('SET', index .. '.total', tostring(total), 'EX', indexTtl)\n return total\nend\nlocal function admits(index, member, bytes, maxBytes, prefix, recountMs, indexTtl)\n local stored = redis.call('GET', index .. '.total')\n local fresh = not stored\n local total = stored and tonumber(stored) or recount(index, prefix, indexTtl)\n local old = tonumber(redis.call('ZSCORE', index, member) or 0)\n if total - old + bytes <= maxBytes then return true end\n if fresh or not redis.call('SET', index .. '.recount', '1', 'PX', recountMs, 'NX') then return false end\n total = recount(index, prefix, indexTtl)\n old = tonumber(redis.call('ZSCORE', index, member) or 0)\n return total - old + bytes <= maxBytes\nend\nlocal function record(index, member, bytes, indexTtl)\n local old = tonumber(redis.call('ZSCORE', index, member) or 0)\n redis.call('ZADD', index, tostring(bytes), member)\n redis.call('INCRBY', index .. '.total', tostring(bytes - old))\n redis.call('EXPIRE', index, indexTtl)\n redis.call('EXPIRE', index .. '.total', indexTtl)\nend\n\nlocal field = ARGV[8]\nif redis.call('HEXISTS', KEYS[1], field) == 0 and redis.call('HLEN', KEYS[1]) > tonumber(ARGV[9]) then return 0 end\nlocal size = math.max(tonumber(redis.call('HGET', KEYS[1], 'size') or 0), tonumber(field) + tonumber(ARGV[7]))\nfor i = 2, #KEYS do\n if not admits(KEYS[i], ARGV[2], size, tonumber(ARGV[i + 8]), ARGV[1], ARGV[4], ARGV[3]) then return 0 end\nend\nredis.call('HSET', KEYS[1], field, ARGV[5], 'size', tostring(size))\nredis.call('EXPIRE', KEYS[1], ARGV[6])\nfor i = 2, #KEYS do record(KEYS[i], ARGV[2], size, ARGV[3]) end\nreturn 1\n";
118
+ /** Deletes one data key and takes it off its owners' totals. KEYS: data key, then each owner index. ARGV: member name. */
119
+ export declare const DELETE_WITH_OWNERS_SCRIPT = "\nredis.call('DEL', KEYS[1])\nfor i = 2, #KEYS do\n local old = redis.call('ZSCORE', KEYS[i], ARGV[1])\n if old then\n redis.call('ZREM', KEYS[i], ARGV[1])\n if redis.call('EXISTS', KEYS[i] .. '.total') == 1 then redis.call('INCRBY', KEYS[i] .. '.total', tostring(-tonumber(old))) end\n end\nend\nreturn 1\n";
120
+ /** Deletes every key an owner index names, then the index with its total and recount marker. KEYS: index. ARGV: data key
121
+ * prefix. Returns the members (unprefixed keys) it deleted, so the caller can drop its local copies too. */
122
+ export declare const DELETE_OWNED_SCRIPT = "\nlocal members = redis.call('ZRANGE', KEYS[1], 0, -1)\nfor _, member in ipairs(members) do\n redis.call('DEL', ARGV[1] .. member)\nend\nredis.call('DEL', KEYS[1], KEYS[1] .. '.total', KEYS[1] .. '.recount')\nreturn members\n";
123
+ /** The prefix of every handle data key in Redis. */
124
+ export declare const HANDLE_DATA_PREFIX = "mapi.handle.";
125
+ /**
126
+ * A `HandleDataStore` on a node-redis client, with this process's `HandleDataCache` in front for reads. Values are
127
+ * stored base64-encoded (a plain string works with any client configuration); a write stream is a hash of
128
+ * `offset -> chunk` plus its `size`. Owner indexes are sorted sets (`mapi.handle-owner.<index>`) of key -> raw bytes, with
129
+ * a running total beside each (see `OWNER_INDEX_LUA`).
130
+ */
131
+ export declare class RedisHandleDataStore implements HandleDataStore {
132
+ private readonly client;
133
+ private readonly local;
134
+ constructor(client: any, local?: HandleDataCache);
135
+ get(key: string): Promise<Buffer | undefined>;
136
+ set(key: string, value: Buffer, ttlSeconds: number, owners?: HandleDataOwner[]): Promise<boolean>;
137
+ putChunk(key: string, offset: number, data: Buffer, ttlSeconds: number, owners?: HandleDataOwner[]): Promise<boolean>;
138
+ readChunks(key: string, size: number): Promise<Buffer | undefined>;
139
+ delete(key: string, owners?: HandleDataOwner[]): Promise<void>;
140
+ deleteOwnedBy(index: string): Promise<void>;
141
+ }
142
+ /** The Redis key of an owner index. */
143
+ export declare function ownerKey(index: string): string;
144
+ /** The store used when a `RopContext` doesn't carry one (unit tests, or a route built without a session manager). */
145
+ export declare const defaultHandleDataStore: HandleDataStore;
@@ -1,36 +1,30 @@
1
1
  import type { RopContext } from "./RopHandler.js";
2
2
  /**
3
3
  * Handles a submitted `"IPM.Schedule.Meeting.Resp.{Pos,Neg,Tent}"` message - an attendee's own response to a
4
- * meeting invite this server previously sent (`RopSubmitMessageHandler.submitAppointment`). Delegated to from
5
- * `RopSubmitMessageHandler` for that message-class prefix, instead of the ordinary mail or Appointment paths.
4
+ * meeting invite. Delegated to from `RopSubmitMessageHandler` for that message-class prefix, instead of the ordinary
5
+ * mail or Appointment paths. Returns the ROP's `ReturnValue`.
6
6
  *
7
- * Correlates the response back to the original `CalendarEvent` via `PidLidGlobalObjectId` (echoed by a real
8
- * client from the invite it's responding to - see `GlobalObjectId.ts`'s own doc comment for why this requires
9
- * no separate correlation table) and updates the caller's own `Attendee.responseStatus` in place, reusing the
10
- * exact same matching-by-mailbox-address logic `MeetingResponseCommand` (EAS's own `MeetingResponse` handler)
11
- * already implements - this is a from-scratch reimplementation of that small algorithm rather than a
12
- * cross-import, since EAS and MAPI are independent protocol verticals in this codebase with no dependency
13
- * between them.
7
+ * Mirrors restapi's `BaseCalendarEventRoute.respond()`. It correlates the response via `PidLidGlobalObjectId`
8
+ * (echoed by a real client from the invite it's responding to - see `GlobalObjectId.ts`) to **the caller's own
9
+ * copy** of the meeting: the query is scoped to the caller's `mailboxUid`, which is the access check. The
10
+ * organizer's copy (in another mailbox) is never touched here; the organizer's server applies the iTIP `REPLY` sent
11
+ * below, exactly as for a REST response. A native Outlook `GlobalObjectId` decodes to the uppercase hex form of its
12
+ * iCalendar UID, which is matched in either case.
14
13
  *
15
- * Every failure mode here (no `PidLidGlobalObjectId` set, no matching `CalendarEvent`, no resolvable mailbox,
16
- * caller isn't actually an attendee, unrecognized message-class suffix) is a silent no-op rather than an error
17
- * response - the same "don't fail the whole ROP over a stale/unresolvable reference" principle this pragmatic
18
- * subset applies throughout (e.g. `MessageTarget.resolveMessageInfo`'s own doc comment). A response that can't
19
- * be correlated or applied is simply dropped, not a reason to reject the client's `RopSubmitMessage` call.
14
+ * **One occurrence of a recurring meeting** (the `GlobalObjectId`'s instance date is set): the caller's exception
15
+ * copy for that date is used when there is one, comparing dates in the event's own time zone. Without one, the
16
+ * series is left alone - responding to one occurrence must not change or delete every other occurrence. Declining
17
+ * adds the occurrence to the caller's series `recurrenceRule.exceptions` (so it leaves their calendar); accepting or
18
+ * tentatively accepting changes nothing locally. Either way the `REPLY` names just that occurrence (`RECURRENCE-ID`).
20
19
  *
21
- * **The one query in this package that isn't `mailboxUid`-scoped, and why that's unavoidable here**: unlike
22
- * every other `ignoreACL: true` repo call in this codebase (all transitively scoped to the caller's own
23
- * mailbox via a session FID/MID registry that itself only ever gets populated from mailbox-scoped queries),
24
- * `calendarEventRepo.find({ icalUid })` below deliberately searches *every* mailbox on this server - the
25
- * `CalendarEvent` being responded to belongs to the meeting's *organizer*, who is very often a different
26
- * mailbox than the attendee submitting this response, and the caller's own session has no way to know which
27
- * mailbox that is. Safety instead comes from two independent properties: `icalUid` is a server-generated
28
- * `crypto.randomUUID()` (see `RopSaveChangesMessageHandler.saveAppointment`), for all practical purposes
29
- * globally unique, so this can't be used to enumerate or collide with another organizer's events; and the
30
- * actual authorization gate is the attendee-membership check a few lines down (`callerAddresses.has(...)`),
31
- * derived entirely from *this* caller's own resolved mailbox record, not from anything the client supplied -
32
- * a crafted `PidLidGlobalObjectId` can at best name a real `icalUid` it doesn't already know the value of
33
- * (astronomically unlikely against a random UUID) and still can't mutate an event unless the caller's own
34
- * mailbox is genuinely a listed attendee on it.
20
+ * **The whole meeting** (no instance date, or a copy that doesn't recur): the caller's `Attendee.responseStatus` is
21
+ * recorded; declining soft-deletes the caller's copy instead. Declining an occurrence through its exception copy also
22
+ * adds that occurrence to the series' exceptions, so the series doesn't show it again. Every update is versioned
23
+ * (`asEntity`), so a concurrent change fails the response instead of being overwritten.
24
+ *
25
+ * The `REPLY` (restapi's own `buildEventIcs`) goes out through `sendOrThrow`, so a transport that rejects it is
26
+ * reported as `MAPI_E_CALL_FAILED` instead of success; the response itself stays recorded. An organizer or attendee
27
+ * address that isn't a plain SMTP address is not mailed at all. A response that can't be matched to a meeting the
28
+ * caller attends is an error (`MAPI_E_NOT_FOUND`/`MAPI_E_INVALID_PARAMETER`), not a silent success.
35
29
  */
36
- export declare function submitMeetingResponse(messageClass: string, draftProperties: Record<string, string>, context: RopContext): Promise<void>;
30
+ export declare function submitMeetingResponse(messageClass: string, draftProperties: Record<string, string>, context: RopContext): Promise<number>;
@@ -1,5 +1,8 @@
1
1
  import type { RepoUtils } from "@rapidrest/service-core";
2
2
  import { type BlobStore } from "@rapidmx/restapi";
3
+ import type { MapiObjectHandle } from "../MapiSessionManager.js";
4
+ import type { ExecuteBudget } from "./ExecuteBudget.js";
5
+ import type { RopContext } from "./RopHandler.js";
3
6
  /** `PidTagBody` (`[MS-OXPROPS]`): property ID `0x1000`, `PtypString`. The only streamable property this
4
7
  * pragmatic subset supports via `RopOpenStream`/`RopReadStream` - `PidTagHtml`/`PidTagRtfCompressed` are a
5
8
  * documented gap (see `RopOpenStreamHandler`'s own doc comment). */
@@ -16,11 +19,17 @@ export declare const PID_TAG_BODY = 4096;
16
19
  * `sanitizedHtmlBlobKey` - `PidTagBody` is specifically the plain-text body per `[MS-OXPROPS]`, unlike EAS's
17
20
  * `Body` element, which can carry either format tagged by its own `Type` field.
18
21
  *
19
- * Recomputed on every call rather than cached anywhere (including on the `"stream"` handle itself) -
20
- * `MapiSessionContext` is serialized through `RedisCache`'s JSON round trip for multi-instance deployments, so
21
- * a raw `Buffer` stored there wouldn't survive it (the same class of gap this codebase already documented for
22
- * `Date` fields, see `MapiSessionContext`'s own doc comment). A documented, pragmatic trade-off: a large body
23
- * read across several `RopReadStream` calls re-parses the MIME source each time rather than once - correct,
24
- * not byte-perfect-efficient.
22
+ * Does no caching itself - `loadStreamBody` below is what the stream ROPs call. With a `budget`, nothing is fetched
23
+ * once its bytes are used up, and the raw message's size is charged before it is parsed.
25
24
  */
26
- export declare function resolveMessageBodyBytes(target: string, messageRepo: RepoUtils<any>, blobStore: BlobStore): Promise<Buffer>;
25
+ export declare function resolveMessageBodyBytes(target: string, messageRepo: RepoUtils<any>, blobStore: BlobStore, budget?: ExecuteBudget): Promise<Buffer>;
26
+ /**
27
+ * The body bytes for the read stream at `handleIndex`. Parsed once and kept in `HandleDataCache` (outside the
28
+ * session JSON) for the life of the handle, so reading a large body in many `RopReadStream` chunks costs one
29
+ * fetch and one MIME parse instead of one per chunk. A cache miss (eviction, or a request served by another
30
+ * replica) just resolves the body again: a stored message's body never changes, so the bytes are the same.
31
+ *
32
+ * Within one `Execute`, streams opened on the same message share a single parse (`ExecuteBudget.bodies`), and each
33
+ * parse's bytes count against the request's byte budget.
34
+ */
35
+ export declare function loadStreamBody(context: RopContext, handleIndex: number, stream: MapiObjectHandle): Promise<Buffer>;
@@ -1,5 +1,7 @@
1
1
  import type { RepoUtils } from "@rapidrest/service-core";
2
2
  import type { MapiSessionContext } from "../MapiSessionManager.js";
3
+ import type { ExecuteBudget } from "./ExecuteBudget.js";
4
+ import { type RepoSort } from "./RepoPaging.js";
3
5
  /**
4
6
  * The `RopGetContentsTable` analog of `FolderTarget.ts`: resolves a `"message:<uid>"` row target (see
5
7
  * `RopGetContentsTableHandler`'s own doc comment for why contents-table rows use this prefix instead of
@@ -19,9 +21,11 @@ export interface MessageTargetInfo {
19
21
  * (soft-deleted or otherwise), the same "don't fail the whole ROP over one stale row" principle
20
22
  * `FolderTarget.resolveFolderInfo` already applies. */
21
23
  export declare function resolveMessageInfo(target: string, messageRepo: RepoUtils<any>): Promise<MessageTargetInfo>;
22
- /** Resolves the messages directly in `folderUid`, as `"message:<uid>"` target strings, for
23
- * `RopGetContentsTable`. */
24
- export declare function resolveFolderMessages(folderUid: string, messageRepo: RepoUtils<any>): Promise<string[]>;
24
+ /** The order a folder's messages are listed in: newest first, ties broken by `uid` so paging is stable. */
25
+ export declare const MESSAGE_SORT: RepoSort;
26
+ /** Resolves the messages directly in `folderUid`, newest first and capped at `MAX_COLLECTION_ROWS`, as
27
+ * `"message:<uid>"` target strings. */
28
+ export declare function resolveFolderMessages(folderUid: string, messageRepo: RepoUtils<any>, budget?: ExecuteBudget): Promise<string[]>;
25
29
  /**
26
30
  * Returns `target`'s existing MID if an earlier `RopQueryRows` row already assigned one, otherwise assigns and
27
31
  * remembers the next free small integer MID. The exact `FolderTarget.assignOrGetFid` pattern, adapted for
@@ -36,3 +40,6 @@ export declare function resolveFolderMessages(folderUid: string, messageRepo: Re
36
40
  * that pages through many messages.
37
41
  */
38
42
  export declare function assignOrGetMid(session: MapiSessionContext, target: string): number;
43
+ /** The most MIDs one session keeps mapped. A session paging through a very large mailbox would otherwise grow its
44
+ * MID maps - saved with the session on every `Execute` - without bound. */
45
+ export declare const MAX_MESSAGE_IDS = 20000;
@@ -11,6 +11,10 @@ export interface PropertyName {
11
11
  lid?: number;
12
12
  name?: string;
13
13
  }
14
+ /** The most distinct named properties one session registers. The registry is saved with the session on every
15
+ * `Execute`, and a name can be up to 255 bytes, so letting a client fill the whole `0x8000`-`0xFFFF` ID space
16
+ * would make every request carry megabytes of session state. Real clients register a few hundred at most. */
17
+ export declare const MAX_NAMED_PROPERTIES_PER_SESSION = 4096;
14
18
  /**
15
19
  * Returns `propertyName`'s existing numeric property ID if an earlier `RopGetPropertyIdsFromNames` call in
16
20
  * this session already assigned one, otherwise assigns and remembers the next free ID starting at
@@ -28,8 +32,8 @@ export interface PropertyName {
28
32
  * lifetime, so a linear scan repeated once per property per row is quadratic over a session that resolves many
29
33
  * named properties.
30
34
  *
31
- * Once `nextNamedPropertyId` would exceed `LAST_NAMED_PROPERTY_ID` (32,768 distinct names already registered
32
- * this session - the entire `0x8000`-`0xFFFF` numeric ID space this pragmatic subset has to hand out), a *new*
35
+ * Once `nextNamedPropertyId` would exceed `LAST_NAMED_PROPERTY_ID` (`MAX_NAMED_PROPERTIES_PER_SESSION` distinct
36
+ * names already registered this session), a *new*
33
37
  * name can no longer be assigned a real ID; this returns `0x0000` for it instead of throwing, the exact value
34
38
  * `[MS-OXCPRPT]`'s own `RopGetPropertyIdsFromNames` processing rules already use for "this `PropertyName`
35
39
  * could not be resolved" (the same value this pragmatic subset already produces for a `Kind = 0xFF` entry) -
@@ -57,4 +57,4 @@ export interface ResolutionCache extends FolderResolutionCache {
57
57
  export declare function resolvePropertyValues(target: string, columns: {
58
58
  propertyId: number;
59
59
  propertyType: PropertyType;
60
- }[], context: Pick<RopContext, "mailboxUid" | "session" | "folderRepo" | "messageRepo" | "calendarEventRepo" | "mailboxRepo" | "contactRepo" | "taskRepo" | "labelRepo">, cache?: ResolutionCache): Promise<PropertyValueData[]>;
60
+ }[], context: Pick<RopContext, "mailboxUid" | "session" | "folderRepo" | "messageRepo" | "calendarEventRepo" | "mailboxRepo" | "contactRepo" | "taskRepo" | "labelRepo" | "budget">, cache?: ResolutionCache): Promise<PropertyValueData[]>;