@littlebearapps/outlook-assistant 3.12.0 → 3.12.1

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.
@@ -86,14 +86,255 @@ function assertSafeResourcePath(resourcePath) {
86
86
  }
87
87
  }
88
88
 
89
+ /**
90
+ * Build the request URL for a Graph resource path (or validate a full
91
+ * continuation URL). Pure: never mutates `queryParams`, so retries and
92
+ * callers that reuse the object always see the original `$filter`.
93
+ * Host and dot-segment guards run here, once, before any attempt.
94
+ * @param {string} path - Relative resource path, or a full nextLink/deltaLink URL
95
+ * @param {object} [queryParams] - Query parameters (`$filter` gets OData-safe encoding)
96
+ * @returns {string} Absolute URL
97
+ * @throws {Error} If the full URL is not Graph, or the path has dot segments
98
+ */
99
+ function buildGraphUrl(path, queryParams = {}) {
100
+ if (path.startsWith('http://') || path.startsWith('https://')) {
101
+ // Path is already a full URL (from pagination nextLink / deltaLink)
102
+ assertGraphUrl(path);
103
+ return path;
104
+ }
105
+
106
+ // Refuse dot segments before encoding: encodeURIComponent leaves `..`
107
+ // intact, and the URL parser would then resolve it to a different resource.
108
+ assertSafeResourcePath(path);
109
+ const encodedPath = path
110
+ .split('/')
111
+ .map((segment) => encodeURIComponent(segment))
112
+ .join('/');
113
+
114
+ // $filter is encoded separately to ensure proper OData URI encoding
115
+ const { $filter: filter, ...rest } = queryParams || {};
116
+ const params = new URLSearchParams();
117
+ for (const [key, value] of Object.entries(rest)) {
118
+ params.append(key, value);
119
+ }
120
+ let queryString = params.toString();
121
+ if (filter) {
122
+ const encodedFilter = `$filter=${encodeURIComponent(filter)}`;
123
+ queryString = queryString
124
+ ? `${queryString}&${encodedFilter}`
125
+ : encodedFilter;
126
+ }
127
+
128
+ return `${config.GRAPH_API_ENDPOINT}${encodedPath}${queryString ? `?${queryString}` : ''}`;
129
+ }
130
+
131
+ // --- Concurrency gate -------------------------------------------------------
132
+ // Graph throttles per app+mailbox; bulk tools fan out many calls at once.
133
+ // At most MAX_CONCURRENT_REQUESTS sends are in flight; the rest queue FIFO.
134
+ const MAX_CONCURRENT_REQUESTS = 4;
135
+ let inFlightRequests = 0;
136
+ const slotWaiters = [];
137
+
138
+ function acquireSlot() {
139
+ if (inFlightRequests < MAX_CONCURRENT_REQUESTS) {
140
+ inFlightRequests += 1;
141
+ return Promise.resolve();
142
+ }
143
+ return new Promise((resolve) => {
144
+ slotWaiters.push(resolve);
145
+ });
146
+ }
147
+
148
+ function releaseSlot() {
149
+ const next = slotWaiters.shift();
150
+ if (next) {
151
+ next(); // hand the slot straight to the next waiter
152
+ } else {
153
+ inFlightRequests -= 1;
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Perform a single HTTPS request (no retries). The body is read as UTF-8.
159
+ * @param {object} options
160
+ * @param {string} options.url
161
+ * @param {string} options.method
162
+ * @param {object} options.headers
163
+ * @param {string|null} [options.body] - Serialised request body
164
+ * @param {number} options.timeoutMs - Socket idle timeout
165
+ * @returns {Promise<{status: number, headers: object, text: string}>}
166
+ * @throws {Error} Network errors (with `.code`); timeouts have code `ETIMEDOUT`
167
+ */
168
+ function sendOnce({ url, method, headers, body = null, timeoutMs }) {
169
+ return new Promise((resolve, reject) => {
170
+ let settled = false;
171
+ const fail = (error) => {
172
+ if (!settled) {
173
+ settled = true;
174
+ reject(error);
175
+ }
176
+ };
177
+
178
+ const req = https.request(
179
+ url,
180
+ { method, headers, timeout: timeoutMs },
181
+ (res) => {
182
+ const chunks = [];
183
+ res.on('data', (chunk) => {
184
+ chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
185
+ });
186
+ res.on('error', fail);
187
+ res.on('end', () => {
188
+ if (settled) return;
189
+ settled = true;
190
+ resolve({
191
+ status: res.statusCode,
192
+ headers: res.headers || {},
193
+ text: Buffer.concat(chunks).toString('utf8'),
194
+ });
195
+ });
196
+ }
197
+ );
198
+
199
+ req.on('timeout', () => {
200
+ const error = new Error(`Request timed out after ${timeoutMs} ms`);
201
+ error.code = 'ETIMEDOUT';
202
+ fail(error);
203
+ if (typeof req.destroy === 'function') {
204
+ req.destroy(error);
205
+ }
206
+ });
207
+ req.on('error', fail);
208
+
209
+ if (body !== null && body !== undefined) {
210
+ req.write(body);
211
+ }
212
+ req.end();
213
+ });
214
+ }
215
+
216
+ const RETRYABLE_STATUSES = new Set([429, 503, 504]);
217
+ const RETRY_STATUS_METHODS = new Set(['GET', 'PUT', 'DELETE', 'PATCH']);
218
+ const RETRYABLE_NETWORK_CODES = new Set(['ETIMEDOUT', 'ECONNRESET']);
219
+ const MAX_RETRIES = 3;
220
+ const BACKOFF_BASE_MS = 1000;
221
+ const BACKOFF_CAP_MS = 30000;
222
+ const MAX_RETRY_AFTER_SECONDS = 60;
223
+ // POST (sendMail, /send, …) must finish well inside an MCP client's ~60 s
224
+ // request timeout: if the client gives up while we sleep and the send later
225
+ // succeeds, the model may send again. So a POST only waits out a short 429.
226
+ const POST_MAX_RETRY_DELAY_MS = 10000;
227
+ const POST_MAX_TOTAL_SLEEP_MS = 20000;
228
+
229
+ /**
230
+ * Can this response be retried for this method? POST (sendMail, /send,
231
+ * createReply, move, $batch) is retried only on 429 — the request was
232
+ * refused, so it cannot have taken effect. 503/504 may have been applied.
233
+ */
234
+ function isRetryableStatus(method, status) {
235
+ if (!RETRYABLE_STATUSES.has(status)) return false;
236
+ return status === 429 || RETRY_STATUS_METHODS.has(method);
237
+ }
238
+
239
+ /** Retry-After in seconds, or null when absent/unparseable. */
240
+ function parseRetryAfterSeconds(headers) {
241
+ const raw = headers['retry-after'];
242
+ if (raw === undefined || raw === null || String(raw).trim() === '') {
243
+ return null;
244
+ }
245
+ const seconds = Number(raw);
246
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
247
+ }
248
+
249
+ /** Exponential backoff with full jitter for retry number `retry` (0-based). */
250
+ function backoffDelayMs(retry) {
251
+ const ceiling = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** retry);
252
+ return Math.floor(Math.random() * ceiling);
253
+ }
254
+
255
+ function sleep(ms) {
256
+ return new Promise((resolve) => {
257
+ setTimeout(resolve, ms);
258
+ });
259
+ }
260
+
261
+ /**
262
+ * Send a request through the concurrency gate, retrying throttled and
263
+ * transient failures: 429 (all methods) and 503/504 (GET/PUT/DELETE/PATCH)
264
+ * up to MAX_RETRIES times, honouring Retry-After; GET also retries once on
265
+ * ETIMEDOUT/ECONNRESET. A POST waits out a 429 only if that delay is
266
+ * ≤ 10 s and its total sleep stays ≤ 20 s. The slot is released before any
267
+ * backoff sleep.
268
+ * @param {object} request - See sendOnce (timeoutMs defaults to config)
269
+ * @returns {Promise<{status: number, headers: object, text: string}>} The
270
+ * final response (2xx, or the last non-retried error status)
271
+ * @throws {Error} The network error of the final attempt
272
+ */
273
+ async function requestWithRetry(request) {
274
+ const method = String(request.method).toUpperCase();
275
+ const timeoutMs = request.timeoutMs || config.REQUEST_TIMEOUT_MS;
276
+ let networkRetryUsed = false;
277
+ let totalSleepMs = 0;
278
+
279
+ for (let retry = 0; ; retry++) {
280
+ await acquireSlot();
281
+ let response;
282
+ let networkError;
283
+ try {
284
+ response = await sendOnce({ ...request, timeoutMs });
285
+ } catch (error) {
286
+ networkError = error;
287
+ } finally {
288
+ releaseSlot();
289
+ }
290
+
291
+ let delayMs;
292
+ if (networkError) {
293
+ const retryable =
294
+ method === 'GET' &&
295
+ !networkRetryUsed &&
296
+ retry < MAX_RETRIES &&
297
+ RETRYABLE_NETWORK_CODES.has(networkError.code);
298
+ if (!retryable) throw networkError;
299
+ networkRetryUsed = true;
300
+ delayMs = backoffDelayMs(retry);
301
+ } else {
302
+ if (retry >= MAX_RETRIES || !isRetryableStatus(method, response.status)) {
303
+ return response;
304
+ }
305
+ const retryAfter = parseRetryAfterSeconds(response.headers);
306
+ if (retryAfter !== null && retryAfter > MAX_RETRY_AFTER_SECONDS) {
307
+ return response; // fail fast rather than block for minutes
308
+ }
309
+ delayMs = retryAfter !== null ? retryAfter * 1000 : backoffDelayMs(retry);
310
+ if (
311
+ method === 'POST' &&
312
+ (delayMs > POST_MAX_RETRY_DELAY_MS ||
313
+ totalSleepMs + delayMs > POST_MAX_TOTAL_SLEEP_MS)
314
+ ) {
315
+ return response; // surface the 429 rather than outlast the client
316
+ }
317
+ }
318
+
319
+ console.error(
320
+ `[GRAPH-API] ${method} ${networkError ? networkError.code : response.status}; ` +
321
+ `retry ${retry + 1}/${MAX_RETRIES} in ${delayMs} ms`
322
+ );
323
+ totalSleepMs += delayMs;
324
+ await sleep(delayMs);
325
+ }
326
+ }
327
+
89
328
  /**
90
329
  * Makes a request to the Microsoft Graph API
91
330
  * In test mode (USE_TEST_MODE=true), routes to mock data instead of the real API.
331
+ * Throttled/transient responses are retried (see requestWithRetry); each
332
+ * attempt times out after OUTLOOK_REQUEST_TIMEOUT_MS.
92
333
  * @param {string} accessToken - The access token for authentication
93
334
  * @param {string} method - HTTP method (GET, POST, etc.)
94
335
  * @param {string} path - API endpoint path
95
336
  * @param {object} data - Data to send for POST/PUT requests
96
- * @param {object} queryParams - Query parameters
337
+ * @param {object} queryParams - Query parameters (never mutated)
97
338
  * @param {object} extraHeaders - Additional headers (e.g. Prefer for immutable IDs)
98
339
  * @returns {Promise<object>} - The API response
99
340
  * @throws {Error} 'UNAUTHORIZED' if the server returns HTTP 401 (token expired or invalid)
@@ -112,135 +353,71 @@ async function callGraphAPI(
112
353
  return mockData.simulateGraphAPIResponse(method, path, data, queryParams);
113
354
  }
114
355
 
356
+ let finalUrl;
115
357
  try {
116
- // Check if path already contains the full URL (from nextLink)
117
- let finalUrl;
118
- if (path.startsWith('http://') || path.startsWith('https://')) {
119
- // Path is already a full URL (from pagination nextLink)
120
- assertGraphUrl(path);
121
- finalUrl = path;
122
- } else {
123
- // Build URL from path and queryParams. Refuse dot segments before
124
- // encoding: encodeURIComponent leaves `..` intact, and the URL parser
125
- // would then resolve it to a different resource.
126
- assertSafeResourcePath(path);
127
- // Encode path segments properly
128
- const encodedPath = path
129
- .split('/')
130
- .map((segment) => encodeURIComponent(segment))
131
- .join('/');
132
-
133
- // Build query string from parameters with special handling for OData filters
134
- let queryString = '';
135
- if (Object.keys(queryParams).length > 0) {
136
- // Handle $filter parameter specially to ensure proper URI encoding
137
- const filter = queryParams.$filter;
138
- if (filter) {
139
- delete queryParams.$filter; // Remove from regular params
140
- }
141
-
142
- // Build query string with proper encoding for regular params
143
- const params = new URLSearchParams();
144
- for (const [key, value] of Object.entries(queryParams)) {
145
- params.append(key, value);
146
- }
147
-
148
- queryString = params.toString();
149
-
150
- // Add filter parameter separately with proper encoding
151
- if (filter) {
152
- if (queryString) {
153
- queryString += `&$filter=${encodeURIComponent(filter)}`;
154
- } else {
155
- queryString = `$filter=${encodeURIComponent(filter)}`;
156
- }
157
- }
158
-
159
- if (queryString) {
160
- queryString = `?${queryString}`;
161
- }
162
- }
163
-
164
- finalUrl = `${config.GRAPH_API_ENDPOINT}${encodedPath}${queryString}`;
165
- }
166
-
167
- return new Promise((resolve, reject) => {
168
- const headers = {
169
- Authorization: `Bearer ${accessToken}`,
170
- 'Content-Type': 'application/json',
171
- };
172
-
173
- // Add immutable IDs header when enabled globally
174
- if (config.USE_IMMUTABLE_IDS) {
175
- headers.Prefer = 'IdType="ImmutableId"';
176
- }
358
+ finalUrl = buildGraphUrl(path, queryParams);
359
+ } catch (error) {
360
+ console.error('Error calling Graph API:', error);
361
+ throw error;
362
+ }
177
363
 
178
- // Merge any extra headers (caller overrides take precedence). `Prefer`
179
- // is multi-valued in HTTP (comma-separated); combine both values rather
180
- // than letting a caller Prefer (e.g. outlook.timezone) clobber the global
181
- // immutable-IDs Prefer, or vice versa.
182
- const combinedPrefer =
183
- headers.Prefer && extraHeaders.Prefer
184
- ? `${headers.Prefer}, ${extraHeaders.Prefer}`
185
- : null;
186
- Object.assign(headers, extraHeaders);
187
- if (combinedPrefer) {
188
- headers.Prefer = combinedPrefer;
189
- }
364
+ const headers = {
365
+ Authorization: `Bearer ${accessToken}`,
366
+ 'Content-Type': 'application/json',
367
+ };
190
368
 
191
- const options = {
192
- method: method,
193
- headers,
194
- };
369
+ // Add immutable IDs header when enabled globally
370
+ if (config.USE_IMMUTABLE_IDS) {
371
+ headers.Prefer = 'IdType="ImmutableId"';
372
+ }
195
373
 
196
- const req = https.request(finalUrl, options, (res) => {
197
- let responseData = '';
374
+ // Merge any extra headers (caller overrides take precedence). `Prefer`
375
+ // is multi-valued in HTTP (comma-separated); combine both values rather
376
+ // than letting a caller Prefer (e.g. outlook.timezone) clobber the global
377
+ // immutable-IDs Prefer, or vice versa.
378
+ const combinedPrefer =
379
+ headers.Prefer && extraHeaders.Prefer
380
+ ? `${headers.Prefer}, ${extraHeaders.Prefer}`
381
+ : null;
382
+ Object.assign(headers, extraHeaders);
383
+ if (combinedPrefer) {
384
+ headers.Prefer = combinedPrefer;
385
+ }
198
386
 
199
- res.on('data', (chunk) => {
200
- responseData += chunk;
201
- });
387
+ const body =
388
+ data && (method === 'POST' || method === 'PATCH' || method === 'PUT')
389
+ ? JSON.stringify(data)
390
+ : null;
202
391
 
203
- res.on('end', () => {
204
- if (res.statusCode >= 200 && res.statusCode < 300) {
205
- try {
206
- responseData = responseData ? responseData : '{}';
207
- const jsonResponse = JSON.parse(responseData);
208
- resolve(jsonResponse);
209
- } catch (error) {
210
- reject(new Error(`Error parsing API response: ${error.message}`));
211
- }
212
- } else if (res.statusCode === 401) {
213
- // Token expired or invalid
214
- reject(new Error('UNAUTHORIZED'));
215
- } else {
216
- // Truncate response to avoid leaking sensitive data in error messages
217
- const safeResponse = responseData.substring(0, 200);
218
- reject(
219
- new Error(
220
- `API call failed with status ${res.statusCode}: ${safeResponse}`
221
- )
222
- );
223
- }
224
- });
225
- });
392
+ let response;
393
+ try {
394
+ response = await requestWithRetry({ url: finalUrl, method, headers, body });
395
+ } catch (error) {
396
+ const wrapped = new Error(
397
+ `Network error during API call: ${error.message}`,
398
+ { cause: error }
399
+ );
400
+ wrapped.code = error.code;
401
+ throw wrapped;
402
+ }
226
403
 
227
- req.on('error', (error) => {
228
- reject(new Error(`Network error during API call: ${error.message}`));
404
+ if (response.status >= 200 && response.status < 300) {
405
+ try {
406
+ return JSON.parse(response.text || '{}');
407
+ } catch (error) {
408
+ throw new Error(`Error parsing API response: ${error.message}`, {
409
+ cause: error,
229
410
  });
230
-
231
- if (
232
- data &&
233
- (method === 'POST' || method === 'PATCH' || method === 'PUT')
234
- ) {
235
- req.write(JSON.stringify(data));
236
- }
237
-
238
- req.end();
239
- });
240
- } catch (error) {
241
- console.error('Error calling Graph API:', error);
242
- throw error;
411
+ }
243
412
  }
413
+ if (response.status === 401) {
414
+ // Token expired or invalid
415
+ throw new Error('UNAUTHORIZED');
416
+ }
417
+ // Truncate response to avoid leaking sensitive data in error messages
418
+ throw new Error(
419
+ `API call failed with status ${response.status}: ${response.text.substring(0, 200)}`
420
+ );
244
421
  }
245
422
 
246
423
  /**
@@ -399,110 +576,40 @@ async function callGraphAPIRaw(accessToken, emailId, mailboxPrefix = 'me') {
399
576
  );
400
577
  }
401
578
 
402
- return new Promise((resolve, reject) => {
403
- const encodedPrefix = mailboxPrefix
404
- .split('/')
405
- .map((segment) => encodeURIComponent(segment))
406
- .join('/');
407
- const path = `${encodedPrefix}/messages/${encodeURIComponent(emailId)}/$value`;
408
- const finalUrl = `${config.GRAPH_API_ENDPOINT}${path}`;
409
-
410
- const options = {
579
+ const encodedPrefix = mailboxPrefix
580
+ .split('/')
581
+ .map((segment) => encodeURIComponent(segment))
582
+ .join('/');
583
+ const path = `${encodedPrefix}/messages/${encodeURIComponent(emailId)}/$value`;
584
+
585
+ let response;
586
+ try {
587
+ response = await requestWithRetry({
588
+ url: `${config.GRAPH_API_ENDPOINT}${path}`,
411
589
  method: 'GET',
412
590
  headers: {
413
591
  Authorization: `Bearer ${accessToken}`,
414
592
  Accept: 'message/rfc822', // Request MIME format
415
593
  },
416
- };
417
-
418
- const req = https.request(finalUrl, options, (res) => {
419
- let responseData = '';
420
-
421
- // Collect data as UTF-8 string
422
- res.setEncoding('utf8');
423
-
424
- res.on('data', (chunk) => {
425
- responseData += chunk;
426
- });
427
-
428
- res.on('end', () => {
429
- if (res.statusCode >= 200 && res.statusCode < 300) {
430
- resolve(responseData);
431
- } else if (res.statusCode === 401) {
432
- reject(new Error('UNAUTHORIZED'));
433
- } else {
434
- reject(
435
- new Error(
436
- `MIME export failed with status ${res.statusCode}: ${responseData.substring(0, 200)}`
437
- )
438
- );
439
- }
440
- });
441
- });
442
-
443
- req.on('error', (error) => {
444
- reject(new Error(`Network error during MIME export: ${error.message}`));
445
594
  });
446
-
447
- req.end();
448
- });
449
- }
450
-
451
- /**
452
- * Calls Graph API with automatic auth and 401 retry.
453
- * Gets token via ensureAuthenticated(), and if a 401 occurs,
454
- * refreshes the token and retries once.
455
- * @param {string} method - HTTP method
456
- * @param {string} path - API endpoint path
457
- * @param {object} data - Request body
458
- * @param {object} queryParams - Query parameters
459
- * @param {object} extraHeaders - Additional headers
460
- * @returns {Promise<object>} - API response
461
- */
462
- async function callGraphAPIWithAuth(
463
- method,
464
- path,
465
- data = null,
466
- queryParams = {},
467
- extraHeaders = {}
468
- ) {
469
- // Lazy require to avoid circular dependency
470
- const { ensureAuthenticated, tokenStorage } = require('../auth');
471
-
472
- const accessToken = await ensureAuthenticated();
473
- try {
474
- return await callGraphAPI(
475
- accessToken,
476
- method,
477
- path,
478
- data,
479
- queryParams,
480
- extraHeaders
481
- );
482
595
  } catch (error) {
483
- if (error.message === 'UNAUTHORIZED' && tokenStorage) {
484
- console.error('[GRAPH-API] 401 received, attempting token refresh...');
485
- try {
486
- const newToken = await tokenStorage.refreshAccessToken();
487
- if (newToken) {
488
- return await callGraphAPI(
489
- newToken,
490
- method,
491
- path,
492
- data,
493
- queryParams,
494
- extraHeaders
495
- );
496
- }
497
- } catch (refreshError) {
498
- console.error(
499
- '[GRAPH-API] Token refresh failed:',
500
- refreshError.message
501
- );
502
- }
503
- }
504
- throw error;
596
+ const wrapped = new Error(
597
+ `Network error during MIME export: ${error.message}`,
598
+ { cause: error }
599
+ );
600
+ wrapped.code = error.code;
601
+ throw wrapped;
505
602
  }
603
+
604
+ if (response.status >= 200 && response.status < 300) {
605
+ return response.text;
606
+ }
607
+ if (response.status === 401) {
608
+ throw new Error('UNAUTHORIZED');
609
+ }
610
+ throw new Error(
611
+ `MIME export failed with status ${response.status}: ${response.text.substring(0, 200)}`
612
+ );
506
613
  }
507
614
 
508
615
  module.exports = {
@@ -511,5 +618,4 @@ module.exports = {
511
618
  callGraphAPIPaginated,
512
619
  callGraphAPIBatch,
513
620
  callGraphAPIRaw,
514
- callGraphAPIWithAuth,
515
621
  };
@@ -47,6 +47,9 @@ function simulateGraphAPIResponse(method, path, _data, _queryParams) {
47
47
  hasAttachments: false,
48
48
  importance: 'normal',
49
49
  isRead: false,
50
+ // A draft, so draft update/send/delete pass the draft guard in
51
+ // test mode (they look the ID up before acting).
52
+ isDraft: true,
50
53
  internetMessageHeaders: [],
51
54
  };
52
55
  } else {
@@ -14,6 +14,28 @@ function escapeODataString(str) {
14
14
  return str.replace(/'/g, "''");
15
15
  }
16
16
 
17
+ /**
18
+ * Escapes text for use inside a double-quoted Graph `$search` phrase.
19
+ * Graph requires `"` and `\` inside the phrase to be backslash-escaped;
20
+ * backslashes go first so the escapes added for quotes are not doubled. (#251)
21
+ * @param {string} str - Raw user text
22
+ * @returns {string} - Text safe to place between the phrase's quotes
23
+ */
24
+ function escapeSearchPhrase(str) {
25
+ if (!str) return str;
26
+ return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
27
+ }
28
+
29
+ /**
30
+ * Wraps user text as a double-quoted Graph `$search` phrase, escaping it.
31
+ * Not for caller-written `$search` expressions, which pass through as-is.
32
+ * @param {string} str - Raw user text
33
+ * @returns {string} - e.g. `Sam "the man" Lee` → `"Sam \"the man\" Lee"`
34
+ */
35
+ function quoteSearchPhrase(str) {
36
+ return `"${escapeSearchPhrase(str)}"`;
37
+ }
38
+
17
39
  /**
18
40
  * Builds an OData filter from filter conditions
19
41
  * @param {Array<string>} conditions - Array of filter conditions
@@ -29,5 +51,7 @@ function buildODataFilter(conditions) {
29
51
 
30
52
  module.exports = {
31
53
  escapeODataString,
54
+ escapeSearchPhrase,
55
+ quoteSearchPhrase,
32
56
  buildODataFilter,
33
57
  };