@yolk-sdk/emulators 0.1.0-canary.96

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 (333) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2135 -0
  3. package/dist/anthropic.d.mts +269 -0
  4. package/dist/anthropic.d.mts.map +1 -0
  5. package/dist/anthropic.mjs +177 -0
  6. package/dist/anthropic.mjs.map +1 -0
  7. package/dist/chat-completions.d.mts +288 -0
  8. package/dist/chat-completions.d.mts.map +1 -0
  9. package/dist/chat-completions.mjs +451 -0
  10. package/dist/chat-completions.mjs.map +1 -0
  11. package/dist/codex.d.mts +265 -0
  12. package/dist/codex.d.mts.map +1 -0
  13. package/dist/codex.mjs +199 -0
  14. package/dist/codex.mjs.map +1 -0
  15. package/dist/dropbox/api.d.mts +63 -0
  16. package/dist/dropbox/api.d.mts.map +1 -0
  17. package/dist/dropbox/api.mjs +565 -0
  18. package/dist/dropbox/api.mjs.map +1 -0
  19. package/dist/dropbox/state.d.mts +136 -0
  20. package/dist/dropbox/state.d.mts.map +1 -0
  21. package/dist/dropbox/state.mjs +209 -0
  22. package/dist/dropbox/state.mjs.map +1 -0
  23. package/dist/dropbox.d.mts +79 -0
  24. package/dist/dropbox.d.mts.map +1 -0
  25. package/dist/dropbox.mjs +124 -0
  26. package/dist/dropbox.mjs.map +1 -0
  27. package/dist/email-fixtures.d.mts +36 -0
  28. package/dist/email-fixtures.d.mts.map +1 -0
  29. package/dist/email-fixtures.mjs +1080 -0
  30. package/dist/email-fixtures.mjs.map +1 -0
  31. package/dist/email.d.mts +160 -0
  32. package/dist/email.d.mts.map +1 -0
  33. package/dist/email.mjs +608 -0
  34. package/dist/email.mjs.map +1 -0
  35. package/dist/emulator-compose.d.mts +40 -0
  36. package/dist/emulator-compose.d.mts.map +1 -0
  37. package/dist/emulator-compose.mjs +91 -0
  38. package/dist/emulator-compose.mjs.map +1 -0
  39. package/dist/emulator-http.d.mts +53 -0
  40. package/dist/emulator-http.d.mts.map +1 -0
  41. package/dist/emulator-http.mjs +117 -0
  42. package/dist/emulator-http.mjs.map +1 -0
  43. package/dist/emulator-kernel.d.mts +176 -0
  44. package/dist/emulator-kernel.d.mts.map +1 -0
  45. package/dist/emulator-kernel.mjs +413 -0
  46. package/dist/emulator-kernel.mjs.map +1 -0
  47. package/dist/fixture-route.d.mts +129 -0
  48. package/dist/fixture-route.d.mts.map +1 -0
  49. package/dist/fixture-route.mjs +257 -0
  50. package/dist/fixture-route.mjs.map +1 -0
  51. package/dist/fortnox/api.d.mts +92 -0
  52. package/dist/fortnox/api.d.mts.map +1 -0
  53. package/dist/fortnox/api.mjs +750 -0
  54. package/dist/fortnox/api.mjs.map +1 -0
  55. package/dist/fortnox/state.d.mts +533 -0
  56. package/dist/fortnox/state.d.mts.map +1 -0
  57. package/dist/fortnox/state.mjs +612 -0
  58. package/dist/fortnox/state.mjs.map +1 -0
  59. package/dist/fortnox.d.mts +128 -0
  60. package/dist/fortnox.d.mts.map +1 -0
  61. package/dist/fortnox.mjs +403 -0
  62. package/dist/fortnox.mjs.map +1 -0
  63. package/dist/gateway-evaluate-recordings.d.mts +12 -0
  64. package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
  65. package/dist/gateway-evaluate-recordings.mjs +128 -0
  66. package/dist/gateway-evaluate-recordings.mjs.map +1 -0
  67. package/dist/gateway.d.mts +272 -0
  68. package/dist/gateway.d.mts.map +1 -0
  69. package/dist/gateway.mjs +400 -0
  70. package/dist/gateway.mjs.map +1 -0
  71. package/dist/github/api.d.mts +63 -0
  72. package/dist/github/api.d.mts.map +1 -0
  73. package/dist/github/api.mjs +527 -0
  74. package/dist/github/api.mjs.map +1 -0
  75. package/dist/github/state.d.mts +272 -0
  76. package/dist/github/state.d.mts.map +1 -0
  77. package/dist/github/state.mjs +303 -0
  78. package/dist/github/state.mjs.map +1 -0
  79. package/dist/github.d.mts +77 -0
  80. package/dist/github.d.mts.map +1 -0
  81. package/dist/github.mjs +180 -0
  82. package/dist/github.mjs.map +1 -0
  83. package/dist/google/calendar.d.mts +10 -0
  84. package/dist/google/calendar.d.mts.map +1 -0
  85. package/dist/google/calendar.mjs +252 -0
  86. package/dist/google/calendar.mjs.map +1 -0
  87. package/dist/google/drive.d.mts +8 -0
  88. package/dist/google/drive.d.mts.map +1 -0
  89. package/dist/google/drive.mjs +252 -0
  90. package/dist/google/drive.mjs.map +1 -0
  91. package/dist/google/gmail.d.mts +10 -0
  92. package/dist/google/gmail.d.mts.map +1 -0
  93. package/dist/google/gmail.mjs +591 -0
  94. package/dist/google/gmail.mjs.map +1 -0
  95. package/dist/google/shared.d.mts +107 -0
  96. package/dist/google/shared.d.mts.map +1 -0
  97. package/dist/google/shared.mjs +146 -0
  98. package/dist/google/shared.mjs.map +1 -0
  99. package/dist/google/state.d.mts +385 -0
  100. package/dist/google/state.d.mts.map +1 -0
  101. package/dist/google/state.mjs +496 -0
  102. package/dist/google/state.mjs.map +1 -0
  103. package/dist/google.d.mts +75 -0
  104. package/dist/google.d.mts.map +1 -0
  105. package/dist/google.mjs +210 -0
  106. package/dist/google.mjs.map +1 -0
  107. package/dist/linkedin-search/api.d.mts +45 -0
  108. package/dist/linkedin-search/api.d.mts.map +1 -0
  109. package/dist/linkedin-search/api.mjs +181 -0
  110. package/dist/linkedin-search/api.mjs.map +1 -0
  111. package/dist/linkedin-search/state.d.mts +123 -0
  112. package/dist/linkedin-search/state.d.mts.map +1 -0
  113. package/dist/linkedin-search/state.mjs +261 -0
  114. package/dist/linkedin-search/state.mjs.map +1 -0
  115. package/dist/linkedin-search.d.mts +69 -0
  116. package/dist/linkedin-search.d.mts.map +1 -0
  117. package/dist/linkedin-search.mjs +158 -0
  118. package/dist/linkedin-search.mjs.map +1 -0
  119. package/dist/mcp/api.d.mts +44 -0
  120. package/dist/mcp/api.d.mts.map +1 -0
  121. package/dist/mcp/api.mjs +557 -0
  122. package/dist/mcp/api.mjs.map +1 -0
  123. package/dist/mcp/recordings.d.mts +40 -0
  124. package/dist/mcp/recordings.d.mts.map +1 -0
  125. package/dist/mcp/recordings.mjs +2390 -0
  126. package/dist/mcp/recordings.mjs.map +1 -0
  127. package/dist/mcp/state.d.mts +71 -0
  128. package/dist/mcp/state.d.mts.map +1 -0
  129. package/dist/mcp/state.mjs +83 -0
  130. package/dist/mcp/state.mjs.map +1 -0
  131. package/dist/mcp.d.mts +84 -0
  132. package/dist/mcp.d.mts.map +1 -0
  133. package/dist/mcp.mjs +241 -0
  134. package/dist/mcp.mjs.map +1 -0
  135. package/dist/messages.d.mts +183 -0
  136. package/dist/messages.d.mts.map +1 -0
  137. package/dist/messages.mjs +532 -0
  138. package/dist/messages.mjs.map +1 -0
  139. package/dist/microsoft/api.d.mts +47 -0
  140. package/dist/microsoft/api.d.mts.map +1 -0
  141. package/dist/microsoft/api.mjs +178 -0
  142. package/dist/microsoft/api.mjs.map +1 -0
  143. package/dist/microsoft/calendar.d.mts +30 -0
  144. package/dist/microsoft/calendar.d.mts.map +1 -0
  145. package/dist/microsoft/calendar.mjs +271 -0
  146. package/dist/microsoft/calendar.mjs.map +1 -0
  147. package/dist/microsoft/drive.d.mts +36 -0
  148. package/dist/microsoft/drive.d.mts.map +1 -0
  149. package/dist/microsoft/drive.mjs +298 -0
  150. package/dist/microsoft/drive.mjs.map +1 -0
  151. package/dist/microsoft/graph.d.mts +198 -0
  152. package/dist/microsoft/graph.d.mts.map +1 -0
  153. package/dist/microsoft/graph.mjs +264 -0
  154. package/dist/microsoft/graph.mjs.map +1 -0
  155. package/dist/microsoft/mail.d.mts +48 -0
  156. package/dist/microsoft/mail.d.mts.map +1 -0
  157. package/dist/microsoft/mail.mjs +488 -0
  158. package/dist/microsoft/mail.mjs.map +1 -0
  159. package/dist/microsoft/state.d.mts +480 -0
  160. package/dist/microsoft/state.d.mts.map +1 -0
  161. package/dist/microsoft/state.mjs +625 -0
  162. package/dist/microsoft/state.mjs.map +1 -0
  163. package/dist/microsoft.d.mts +168 -0
  164. package/dist/microsoft.d.mts.map +1 -0
  165. package/dist/microsoft.mjs +483 -0
  166. package/dist/microsoft.mjs.map +1 -0
  167. package/dist/node.d.mts +47 -0
  168. package/dist/node.d.mts.map +1 -0
  169. package/dist/node.mjs +178 -0
  170. package/dist/node.mjs.map +1 -0
  171. package/dist/notion/api.d.mts +51 -0
  172. package/dist/notion/api.d.mts.map +1 -0
  173. package/dist/notion/api.mjs +507 -0
  174. package/dist/notion/api.mjs.map +1 -0
  175. package/dist/notion/state.d.mts +329 -0
  176. package/dist/notion/state.d.mts.map +1 -0
  177. package/dist/notion/state.mjs +432 -0
  178. package/dist/notion/state.mjs.map +1 -0
  179. package/dist/notion.d.mts +72 -0
  180. package/dist/notion.d.mts.map +1 -0
  181. package/dist/notion.mjs +127 -0
  182. package/dist/notion.mjs.map +1 -0
  183. package/dist/openai.d.mts +165 -0
  184. package/dist/openai.d.mts.map +1 -0
  185. package/dist/openai.mjs +141 -0
  186. package/dist/openai.mjs.map +1 -0
  187. package/dist/opencode-recordings.d.mts +7 -0
  188. package/dist/opencode-recordings.d.mts.map +1 -0
  189. package/dist/opencode-recordings.mjs +222 -0
  190. package/dist/opencode-recordings.mjs.map +1 -0
  191. package/dist/opencode.d.mts +70 -0
  192. package/dist/opencode.d.mts.map +1 -0
  193. package/dist/opencode.mjs +217 -0
  194. package/dist/opencode.mjs.map +1 -0
  195. package/dist/r2-fixtures.d.mts +36 -0
  196. package/dist/r2-fixtures.d.mts.map +1 -0
  197. package/dist/r2-fixtures.mjs +245 -0
  198. package/dist/r2-fixtures.mjs.map +1 -0
  199. package/dist/r2-guard.d.mts +43 -0
  200. package/dist/r2-guard.d.mts.map +1 -0
  201. package/dist/r2-guard.mjs +215 -0
  202. package/dist/r2-guard.mjs.map +1 -0
  203. package/dist/r2.d.mts +166 -0
  204. package/dist/r2.d.mts.map +1 -0
  205. package/dist/r2.mjs +517 -0
  206. package/dist/r2.mjs.map +1 -0
  207. package/dist/responses.d.mts +231 -0
  208. package/dist/responses.d.mts.map +1 -0
  209. package/dist/responses.mjs +556 -0
  210. package/dist/responses.mjs.map +1 -0
  211. package/dist/route-evidence.d.mts +45 -0
  212. package/dist/route-evidence.d.mts.map +1 -0
  213. package/dist/route-evidence.mjs +56 -0
  214. package/dist/route-evidence.mjs.map +1 -0
  215. package/dist/router.d.mts +93 -0
  216. package/dist/router.d.mts.map +1 -0
  217. package/dist/router.mjs +259 -0
  218. package/dist/router.mjs.map +1 -0
  219. package/dist/stateful-core.d.mts +17 -0
  220. package/dist/stateful-core.d.mts.map +1 -0
  221. package/dist/stateful-core.mjs +41 -0
  222. package/dist/stateful-core.mjs.map +1 -0
  223. package/dist/stateful-emulator.d.mts +565 -0
  224. package/dist/stateful-emulator.d.mts.map +1 -0
  225. package/dist/stateful-emulator.mjs +1228 -0
  226. package/dist/stateful-emulator.mjs.map +1 -0
  227. package/dist/stateful-secrets.d.mts +84 -0
  228. package/dist/stateful-secrets.d.mts.map +1 -0
  229. package/dist/stateful-secrets.mjs +216 -0
  230. package/dist/stateful-secrets.mjs.map +1 -0
  231. package/dist/subscription-usage-recordings.d.mts +9 -0
  232. package/dist/subscription-usage-recordings.d.mts.map +1 -0
  233. package/dist/subscription-usage-recordings.mjs +53 -0
  234. package/dist/subscription-usage-recordings.mjs.map +1 -0
  235. package/dist/subscription-usage.d.mts +53 -0
  236. package/dist/subscription-usage.d.mts.map +1 -0
  237. package/dist/subscription-usage.mjs +21 -0
  238. package/dist/subscription-usage.mjs.map +1 -0
  239. package/dist/telegram/api.d.mts +34 -0
  240. package/dist/telegram/api.d.mts.map +1 -0
  241. package/dist/telegram/api.mjs +257 -0
  242. package/dist/telegram/api.mjs.map +1 -0
  243. package/dist/telegram/state.d.mts +128 -0
  244. package/dist/telegram/state.d.mts.map +1 -0
  245. package/dist/telegram/state.mjs +154 -0
  246. package/dist/telegram/state.mjs.map +1 -0
  247. package/dist/telegram.d.mts +62 -0
  248. package/dist/telegram.d.mts.map +1 -0
  249. package/dist/telegram.mjs +127 -0
  250. package/dist/telegram.mjs.map +1 -0
  251. package/dist/todoist/api.d.mts +55 -0
  252. package/dist/todoist/api.d.mts.map +1 -0
  253. package/dist/todoist/api.mjs +415 -0
  254. package/dist/todoist/api.mjs.map +1 -0
  255. package/dist/todoist/state.d.mts +275 -0
  256. package/dist/todoist/state.d.mts.map +1 -0
  257. package/dist/todoist/state.mjs +345 -0
  258. package/dist/todoist/state.mjs.map +1 -0
  259. package/dist/todoist.d.mts +68 -0
  260. package/dist/todoist.d.mts.map +1 -0
  261. package/dist/todoist.mjs +181 -0
  262. package/dist/todoist.mjs.map +1 -0
  263. package/dist/xai.d.mts +267 -0
  264. package/dist/xai.d.mts.map +1 -0
  265. package/dist/xai.mjs +252 -0
  266. package/dist/xai.mjs.map +1 -0
  267. package/package.json +157 -0
  268. package/src/anthropic.ts +289 -0
  269. package/src/chat-completions.ts +924 -0
  270. package/src/codex.ts +296 -0
  271. package/src/dropbox/api.ts +1084 -0
  272. package/src/dropbox/state.ts +364 -0
  273. package/src/dropbox.ts +203 -0
  274. package/src/email-fixtures.ts +1297 -0
  275. package/src/email.ts +1108 -0
  276. package/src/emulator-compose.ts +147 -0
  277. package/src/emulator-http.ts +184 -0
  278. package/src/emulator-kernel.ts +844 -0
  279. package/src/fixture-route.ts +561 -0
  280. package/src/fortnox/api.ts +1352 -0
  281. package/src/fortnox/state.ts +798 -0
  282. package/src/fortnox.ts +801 -0
  283. package/src/gateway-evaluate-recordings.ts +153 -0
  284. package/src/gateway.ts +530 -0
  285. package/src/github/api.ts +986 -0
  286. package/src/github/state.ts +439 -0
  287. package/src/github.ts +271 -0
  288. package/src/google/calendar.ts +486 -0
  289. package/src/google/drive.ts +471 -0
  290. package/src/google/gmail.ts +1011 -0
  291. package/src/google/shared.ts +321 -0
  292. package/src/google/state.ts +686 -0
  293. package/src/google.ts +298 -0
  294. package/src/linkedin-search/api.ts +363 -0
  295. package/src/linkedin-search/state.ts +373 -0
  296. package/src/linkedin-search.ts +241 -0
  297. package/src/mcp/api.ts +995 -0
  298. package/src/mcp/recordings.ts +2665 -0
  299. package/src/mcp/state.ts +125 -0
  300. package/src/mcp.ts +319 -0
  301. package/src/messages.ts +844 -0
  302. package/src/microsoft/api.ts +426 -0
  303. package/src/microsoft/calendar.ts +491 -0
  304. package/src/microsoft/drive.ts +541 -0
  305. package/src/microsoft/graph.ts +550 -0
  306. package/src/microsoft/mail.ts +833 -0
  307. package/src/microsoft/state.ts +822 -0
  308. package/src/microsoft.ts +982 -0
  309. package/src/node.ts +293 -0
  310. package/src/notion/api.ts +976 -0
  311. package/src/notion/state.ts +512 -0
  312. package/src/notion.ts +210 -0
  313. package/src/openai.ts +198 -0
  314. package/src/opencode-recordings.ts +257 -0
  315. package/src/opencode.ts +271 -0
  316. package/src/r2-fixtures.ts +295 -0
  317. package/src/r2-guard.ts +323 -0
  318. package/src/r2.ts +890 -0
  319. package/src/responses.ts +901 -0
  320. package/src/route-evidence.ts +90 -0
  321. package/src/router.ts +490 -0
  322. package/src/stateful-core.ts +70 -0
  323. package/src/stateful-emulator.ts +2571 -0
  324. package/src/stateful-secrets.ts +299 -0
  325. package/src/subscription-usage-recordings.ts +77 -0
  326. package/src/subscription-usage.ts +68 -0
  327. package/src/telegram/api.ts +437 -0
  328. package/src/telegram/state.ts +229 -0
  329. package/src/telegram.ts +227 -0
  330. package/src/todoist/api.ts +736 -0
  331. package/src/todoist/state.ts +456 -0
  332. package/src/todoist.ts +316 -0
  333. package/src/xai.ts +341 -0
@@ -0,0 +1,833 @@
1
+ /**
2
+ * Microsoft Graph emulator Outlook routes (internal): folder message listing with paging, draft
3
+ * create, message update and move with immutable ids, attachment listing and retrieval, and the
4
+ * JSON `$batch` permanent delete. Wire shapes follow the synthetic Outlook conformance fixtures.
5
+ *
6
+ * Ids: every Outlook fixture request sends `Prefer: IdType="ImmutableId"`, so every Outlook route
7
+ * (and every `$batch` subrequest) needs it; without it the request fails closed (400). Messages
8
+ * have one id, the immutable id, which a move keeps.
9
+ *
10
+ * Writes accept only the fields the fixtures send: a draft create takes `subject`, a text `body`,
11
+ * `toRecipients`, and the owner as `from`; an update takes `subject` and `isRead`; a move takes
12
+ * `destinationId`, which must be `deleteditems`. Anything else, including unknown nested keys,
13
+ * fails closed before any write.
14
+ * The fixtures update, move, and permanently delete only drafts the case created, so those writes
15
+ * to a message that is not a draft are not emulated (400), and neither is permanently deleting a
16
+ * message with attachments (no fixture creates a draft with attachments).
17
+ *
18
+ * Concurrent writes: the first committed write (update or move) holds its message for
19
+ * `conflictWindowMs` before answering (a refused or faulted write commits nothing and holds
20
+ * nothing); an overlapping write to that message gets 409 `ErrorIrresolvableConflict` and changes
21
+ * nothing. Non-overlapping writes both apply (an emulator extrapolation).
22
+ *
23
+ * @experimental
24
+ */
25
+ import { Predicate } from 'effect'
26
+ import type * as Schema from 'effect/Schema'
27
+ import {
28
+ bodyObject,
29
+ codes,
30
+ collection,
31
+ decodeSegment,
32
+ entity,
33
+ invalidValue,
34
+ isJsonObject,
35
+ jsonResponse,
36
+ metadataContext,
37
+ nestedObject,
38
+ nextChangeKey,
39
+ nextLinkOf,
40
+ notEmulated,
41
+ nowTimestamp,
42
+ odataKey,
43
+ padded,
44
+ pageOf,
45
+ parsePreferences,
46
+ project,
47
+ resolveUser,
48
+ selectSuffix,
49
+ selectedFields,
50
+ textBody,
51
+ type HeldAnswer,
52
+ type MicrosoftApiEnv,
53
+ type RouteHandler,
54
+ type RouteRequest
55
+ } from './graph.ts'
56
+ import type {
57
+ MicrosoftEmulatorAttachment,
58
+ MicrosoftEmulatorMailFolder,
59
+ MicrosoftEmulatorMessage,
60
+ MicrosoftEmulatorRecipient,
61
+ MicrosoftEmulatorState,
62
+ MicrosoftEmulatorUser
63
+ } from './state.ts'
64
+
65
+ const messageTagPrefix = 'CQAAABYAAAAsynthetic'
66
+
67
+ const messageFields: ReadonlyArray<string> = [
68
+ 'id',
69
+ 'createdDateTime',
70
+ 'lastModifiedDateTime',
71
+ 'changeKey',
72
+ 'categories',
73
+ 'receivedDateTime',
74
+ 'sentDateTime',
75
+ 'hasAttachments',
76
+ 'internetMessageId',
77
+ 'subject',
78
+ 'bodyPreview',
79
+ 'importance',
80
+ 'parentFolderId',
81
+ 'conversationId',
82
+ 'isDeliveryReceiptRequested',
83
+ 'isReadReceiptRequested',
84
+ 'isRead',
85
+ 'isDraft',
86
+ 'webLink',
87
+ 'body',
88
+ 'sender',
89
+ 'from',
90
+ 'toRecipients',
91
+ 'ccRecipients',
92
+ 'bccRecipients',
93
+ 'replyTo',
94
+ 'flag'
95
+ ]
96
+
97
+ const attachmentListFields: ReadonlyArray<string> = [
98
+ 'id',
99
+ 'name',
100
+ 'contentType',
101
+ 'size',
102
+ 'isInline',
103
+ 'lastModifiedDateTime'
104
+ ]
105
+
106
+ const notFound = (request: RouteRequest): Response =>
107
+ request.error(404, codes.mailItemNotFound, 'The specified object was not found in the store.')
108
+
109
+ const conflict = (request: RouteRequest): Response =>
110
+ request.error(
111
+ 409,
112
+ codes.conflict,
113
+ 'The send or update operation could not be performed because the change key passed in the request does not match the current change key for the item.'
114
+ )
115
+
116
+ const recipient = (value: MicrosoftEmulatorRecipient) => ({
117
+ emailAddress: { name: value.name ?? value.address, address: value.address }
118
+ })
119
+
120
+ /** Plain-text preview: tags stripped, whitespace collapsed, at most 255 characters. */
121
+ const bodyPreview = (message: MicrosoftEmulatorMessage): string =>
122
+ (message.bodyContentType === 'html'
123
+ ? message.bodyContent.replace(/<[^>]*>/g, ' ')
124
+ : message.bodyContent
125
+ )
126
+ .replace(/\s+/g, ' ')
127
+ .trim()
128
+ .slice(0, 255)
129
+
130
+ /** 400 for an Outlook request without `Prefer: IdType="ImmutableId"` (every fixture sends it). */
131
+ const immutableIdProblem = (request: RouteRequest): Response | undefined =>
132
+ request.prefer.immutableId
133
+ ? undefined
134
+ : notEmulated(
135
+ request,
136
+ 'Outlook requests without Prefer: IdType="ImmutableId" are not emulated.'
137
+ )
138
+
139
+ /** `users('{userId}')`, the context prefix of every Outlook route. */
140
+ const userContext = (request: RouteRequest): string =>
141
+ `users${odataKey(request.params.userId ?? '')}`
142
+
143
+ /** `@odata.context` of a single message (create, update, move), as the fixtures record it. */
144
+ const messageEntityContext = (env: MicrosoftApiEnv, request: RouteRequest): string =>
145
+ metadataContext(env, `${userContext(request)}/messages/$entity`)
146
+
147
+ /** The full message, in the draft fixture's key order. */
148
+ const renderMessage = (message: MicrosoftEmulatorMessage): Schema.JsonObject => {
149
+ const id = message.id
150
+
151
+ return {
152
+ '@odata.etag': `W/"${message.changeKey}"`,
153
+ id,
154
+ createdDateTime: message.createdDateTime,
155
+ lastModifiedDateTime: message.lastModifiedDateTime,
156
+ changeKey: message.changeKey,
157
+ categories: message.categories,
158
+ receivedDateTime: message.receivedDateTime,
159
+ sentDateTime: message.sentDateTime,
160
+ hasAttachments: message.hasAttachments,
161
+ internetMessageId: message.internetMessageId,
162
+ subject: message.subject,
163
+ bodyPreview: bodyPreview(message),
164
+ importance: message.importance,
165
+ parentFolderId: message.parentFolderId,
166
+ conversationId: message.conversationId,
167
+ isDeliveryReceiptRequested: false,
168
+ isReadReceiptRequested: false,
169
+ isRead: message.isRead,
170
+ isDraft: message.isDraft,
171
+ webLink: `https://outlook.office365.com/owa/?ItemID=${encodeURIComponent(id)}&exvsurl=1&viewmodel=ReadMessageItem`,
172
+ body: { contentType: message.bodyContentType, content: message.bodyContent },
173
+ sender: message.sender === null ? null : recipient(message.sender),
174
+ from: message.from === null ? null : recipient(message.from),
175
+ toRecipients: message.toRecipients.map(recipient),
176
+ ccRecipients: message.ccRecipients.map(recipient),
177
+ bccRecipients: message.bccRecipients.map(recipient),
178
+ replyTo: message.replyTo.map(recipient),
179
+ flag: { flagStatus: message.flagStatus }
180
+ }
181
+ }
182
+
183
+ /** A folder by id (the paging fixture lists a folder by id, never by well-known name). */
184
+ const folderById = (
185
+ state: MicrosoftEmulatorState,
186
+ id: string
187
+ ): MicrosoftEmulatorMailFolder | undefined => state.mailFolders.find(folder => folder.id === id)
188
+
189
+ /** The seeded folder with a well-known name (internal lookups and the move destination). */
190
+ const wellKnownFolder = (
191
+ state: MicrosoftEmulatorState,
192
+ name: MicrosoftEmulatorMailFolder['wellKnownName']
193
+ ): MicrosoftEmulatorMailFolder | undefined =>
194
+ state.mailFolders.find(folder => folder.wellKnownName === name)
195
+
196
+ /** The only move destination a fixture sends (`{ destinationId: 'deleteditems' }`). */
197
+ const moveDestination = 'deleteditems'
198
+
199
+ /** The largest folder message `$top` a fixture sends (`$top=2`, the paging case). */
200
+ const folderMessagesMaxTop = 2
201
+
202
+ /** A message by its (immutable) id. */
203
+ const findMessage = (
204
+ state: MicrosoftEmulatorState,
205
+ id: string
206
+ ): MicrosoftEmulatorMessage | undefined => state.messages.find(message => message.id === id)
207
+
208
+ /** 400 for a write to a message that is not a draft (the fixtures write only case drafts). */
209
+ const notDraftProblem = (
210
+ request: RouteRequest,
211
+ message: MicrosoftEmulatorMessage
212
+ ): Response | undefined =>
213
+ message.isDraft
214
+ ? undefined
215
+ : notEmulated(
216
+ request,
217
+ 'writes to a message that is not a draft are not emulated (the fixtures write only drafts).'
218
+ )
219
+
220
+ const replaceMessage = (state: MicrosoftEmulatorState, updated: MicrosoftEmulatorMessage) => {
221
+ state.messages = state.messages.map(message => (message.id === updated.id ? updated : message))
222
+ }
223
+
224
+ /**
225
+ * `GET /users/{userId}/mailFolders/{folderId}/messages`: newest `receivedDateTime` first, one
226
+ * `$top`/`$skip` page with an opaque `@odata.nextLink` on the configured Graph origin. The folder
227
+ * is named by id (a well-known name or an unknown id is not emulated), and `$top` is required, at
228
+ * most `folderMessagesMaxTop`, as the paging fixture sends it.
229
+ */
230
+ export const listFolderMessages: RouteHandler = (state, request, env) => {
231
+ const user = resolveUser(state, request)
232
+
233
+ if (user instanceof Response) return user
234
+
235
+ const immutable = immutableIdProblem(request)
236
+
237
+ if (immutable !== undefined) return immutable
238
+
239
+ const folder = folderById(state, request.params.folderId ?? '')
240
+
241
+ if (folder === undefined) {
242
+ return notEmulated(
243
+ request,
244
+ 'listing a folder by well-known name or an unknown folder id is not emulated (the paging fixture lists a folder by id).'
245
+ )
246
+ }
247
+
248
+ const fields = selectedFields(request, messageFields)
249
+
250
+ if (fields instanceof Response) return fields
251
+
252
+ const messages = state.messages
253
+ .filter(message => message.parentFolderId === folder.id)
254
+ .sort((left, right) =>
255
+ left.receivedDateTime === right.receivedDateTime
256
+ ? left.id.localeCompare(right.id)
257
+ : left.receivedDateTime < right.receivedDateTime
258
+ ? 1
259
+ : -1
260
+ )
261
+
262
+ const page = pageOf(messages, request, folderMessagesMaxTop)
263
+
264
+ if (page instanceof Response) return page
265
+
266
+ const context = metadataContext(
267
+ env,
268
+ `${userContext(request)}/mailFolders${odataKey(request.params.folderId ?? '')}/messages${selectSuffix(fields)}`
269
+ )
270
+
271
+ return jsonResponse(
272
+ 200,
273
+ collection(
274
+ context,
275
+ page.items.map(message => project(renderMessage(message), fields)),
276
+ nextLinkOf(env, request, page, ['$select'])
277
+ )
278
+ )
279
+ }
280
+
281
+ /**
282
+ * A `recipient` write value, `{ emailAddress: { address, name? } }` with only those keys (the
283
+ * shape the fixture responses show); anything else fails closed.
284
+ */
285
+ const recipientOf = (
286
+ request: RouteRequest,
287
+ key: string,
288
+ value: Schema.Json
289
+ ): MicrosoftEmulatorRecipient | Response => {
290
+ const expected = `${key} { emailAddress: { address, name? } }`
291
+ const recipient = nestedObject(request, value, ['emailAddress'], expected)
292
+
293
+ if (recipient instanceof Response) return recipient
294
+
295
+ const email = nestedObject(request, recipient.emailAddress, ['address', 'name'], expected)
296
+
297
+ if (email instanceof Response) return email
298
+
299
+ const { address, name } = email
300
+
301
+ if (!Predicate.isString(address) || (name !== undefined && !Predicate.isString(name))) {
302
+ return invalidValue(request, `${expected} needs a string address (and name).`)
303
+ }
304
+
305
+ return { name: name ?? null, address }
306
+ }
307
+
308
+ /** `[recipient]` as stored recipients. */
309
+ const recipientsOf = (
310
+ request: RouteRequest,
311
+ key: string,
312
+ value: Schema.Json
313
+ ): ReadonlyArray<MicrosoftEmulatorRecipient> | Response => {
314
+ if (!Array.isArray(value)) return invalidValue(request, `${key} must be an array.`)
315
+
316
+ const recipients: Array<MicrosoftEmulatorRecipient> = []
317
+
318
+ for (const entry of value) {
319
+ const recipient = recipientOf(request, key, entry)
320
+
321
+ if (recipient instanceof Response) return recipient
322
+
323
+ recipients.push(recipient)
324
+ }
325
+
326
+ return recipients
327
+ }
328
+
329
+ type MessageWrite = {
330
+ subject?: string
331
+ bodyContent?: string
332
+ toRecipients?: ReadonlyArray<MicrosoftEmulatorRecipient>
333
+ isRead?: boolean
334
+ }
335
+
336
+ /** Draft create keys, as the Outlook fixtures send them. */
337
+ const draftKeys: ReadonlyArray<string> = ['subject', 'body', 'toRecipients', 'from']
338
+
339
+ /** Message update keys, as the Outlook fixtures send them. */
340
+ const updateKeys: ReadonlyArray<string> = ['subject', 'isRead']
341
+
342
+ /** The emulated message fields of a create or update body (keys already allowlisted). */
343
+ const messageWrite = (
344
+ request: RouteRequest,
345
+ fields: Schema.JsonObject,
346
+ user: MicrosoftEmulatorUser
347
+ ): MessageWrite | Response => {
348
+ const write: MessageWrite = {}
349
+
350
+ for (const [key, value] of Object.entries(fields)) {
351
+ switch (key) {
352
+ case 'subject':
353
+ if (!Predicate.isString(value)) return invalidValue(request, 'subject must be a string.')
354
+
355
+ write.subject = value
356
+ break
357
+ case 'body': {
358
+ const content = textBody(request, value)
359
+
360
+ if (content instanceof Response) return content
361
+
362
+ write.bodyContent = content
363
+ break
364
+ }
365
+
366
+ case 'toRecipients': {
367
+ const recipients = recipientsOf(request, key, value)
368
+
369
+ if (recipients instanceof Response) return recipients
370
+
371
+ write.toRecipients = recipients
372
+ break
373
+ }
374
+
375
+ case 'from': {
376
+ const from = recipientOf(request, key, value)
377
+
378
+ if (from instanceof Response) return from
379
+
380
+ if (from.address.toLowerCase() !== user.mail.toLowerCase()) {
381
+ return notEmulated(request, 'from must be the mailbox owner (send-as is not emulated).')
382
+ }
383
+
384
+ break
385
+ }
386
+
387
+ case 'isRead':
388
+ if (!Predicate.isBoolean(value)) return invalidValue(request, 'isRead must be a boolean.')
389
+
390
+ write.isRead = value
391
+ break
392
+ }
393
+ }
394
+
395
+ return write
396
+ }
397
+
398
+ /**
399
+ * `POST /users/{userId}/messages`: a new draft in Drafts (201 with the message). Never sent. New
400
+ * drafts have no attachments (`hasAttachments: false`, as the fixtures record).
401
+ */
402
+ export const createDraft: RouteHandler = (state, request, env) => {
403
+ const user = resolveUser(state, request)
404
+
405
+ if (user instanceof Response) return user
406
+
407
+ const immutable = immutableIdProblem(request)
408
+
409
+ if (immutable !== undefined) return immutable
410
+
411
+ const fields = bodyObject(request, draftKeys)
412
+
413
+ if (fields instanceof Response) return fields
414
+
415
+ const write = messageWrite(request, fields, user)
416
+
417
+ if (write instanceof Response) return write
418
+
419
+ const drafts = wellKnownFolder(state, 'drafts')
420
+
421
+ if (drafts === undefined) return notFound(request)
422
+
423
+ let number = state.counters.nextMessageNumber
424
+ let id = `AAkALgAAAAAAHYQDEapmEc2byACqAC-EWg0A-synthetic-immutable-${padded(number, 4)}=`
425
+
426
+ while (findMessage(state, id) !== undefined) {
427
+ number += 1
428
+ id = `AAkALgAAAAAAHYQDEapmEc2byACqAC-EWg0A-synthetic-immutable-${padded(number, 4)}=`
429
+ }
430
+
431
+ state.counters = { ...state.counters, nextMessageNumber: number + 1 }
432
+
433
+ const now = nowTimestamp(env)
434
+ const owner: MicrosoftEmulatorRecipient = { name: user.displayName, address: user.mail }
435
+
436
+ const message: MicrosoftEmulatorMessage = {
437
+ id,
438
+ parentFolderId: drafts.id,
439
+ subject: write.subject ?? '',
440
+ bodyContentType: 'text',
441
+ bodyContent: write.bodyContent ?? '',
442
+ from: owner,
443
+ sender: owner,
444
+ toRecipients: write.toRecipients ?? [],
445
+ ccRecipients: [],
446
+ bccRecipients: [],
447
+ replyTo: [],
448
+ isRead: true,
449
+ isDraft: true,
450
+ importance: 'normal',
451
+ flagStatus: 'notFlagged',
452
+ categories: [],
453
+ hasAttachments: false,
454
+ createdDateTime: now,
455
+ lastModifiedDateTime: now,
456
+ receivedDateTime: now,
457
+ sentDateTime: now,
458
+ conversationId: `AAQkAGI2-synthetic-conversation-draft-${padded(number, 4)}=`,
459
+ internetMessageId: `<synthetic-draft-${padded(number, 4)}@example.test>`,
460
+ changeKey: nextChangeKey(state, messageTagPrefix)
461
+ }
462
+
463
+ state.messages = [...state.messages, message]
464
+
465
+ return jsonResponse(201, entity(messageEntityContext(env, request), renderMessage(message)))
466
+ }
467
+
468
+ /**
469
+ * A message write that will hold its message: 409 (nothing changes) when the message is held by
470
+ * another write; otherwise the write runs (on the draft) and its answer names the message to hold.
471
+ * The route registration takes the hold only once the write is committed, then keeps it for
472
+ * `conflictWindowMs` before answering, so a refused or faulted write holds nothing.
473
+ */
474
+ const heldWrite = (
475
+ env: MicrosoftApiEnv,
476
+ request: RouteRequest,
477
+ message: MicrosoftEmulatorMessage,
478
+ commit: () => Response
479
+ ): Response | HeldAnswer =>
480
+ env.messageLocks.has(message.id) ? conflict(request) : { response: commit(), holds: message.id }
481
+
482
+ /**
483
+ * `PATCH /users/{userId}/messages/{messageId}`: update a draft's `subject` or `isRead`; 200 with
484
+ * the message.
485
+ */
486
+ export const updateMessage: RouteHandler = (state, request, env) => {
487
+ const user = resolveUser(state, request)
488
+
489
+ if (user instanceof Response) return user
490
+
491
+ const immutable = immutableIdProblem(request)
492
+
493
+ if (immutable !== undefined) return immutable
494
+
495
+ const message = findMessage(state, request.params.messageId ?? '')
496
+
497
+ if (message === undefined) return notFound(request)
498
+
499
+ const notDraft = notDraftProblem(request, message)
500
+
501
+ if (notDraft !== undefined) return notDraft
502
+
503
+ const fields = bodyObject(request, updateKeys)
504
+
505
+ if (fields instanceof Response) return fields
506
+
507
+ const write = messageWrite(request, fields, user)
508
+
509
+ if (write instanceof Response) return write
510
+
511
+ return heldWrite(env, request, message, () => {
512
+ const updated: MicrosoftEmulatorMessage = {
513
+ ...message,
514
+ ...write,
515
+ lastModifiedDateTime: nowTimestamp(env),
516
+ changeKey: nextChangeKey(state, messageTagPrefix)
517
+ }
518
+
519
+ replaceMessage(state, updated)
520
+
521
+ return jsonResponse(200, entity(messageEntityContext(env, request), renderMessage(updated)))
522
+ })
523
+ }
524
+
525
+ /**
526
+ * `POST /users/{userId}/messages/{messageId}/move` with `{ destinationId: 'deleteditems' }`, the
527
+ * only destination a fixture sends: 201 with the moved draft, which keeps its (immutable) id. Any
528
+ * other destination (another well-known name such as `inbox` or `drafts`, or a folder id) is not
529
+ * emulated (400) and moves nothing.
530
+ */
531
+ export const moveMessage: RouteHandler = (state, request, env) => {
532
+ const user = resolveUser(state, request)
533
+
534
+ if (user instanceof Response) return user
535
+
536
+ const immutable = immutableIdProblem(request)
537
+
538
+ if (immutable !== undefined) return immutable
539
+
540
+ const message = findMessage(state, request.params.messageId ?? '')
541
+
542
+ if (message === undefined) return notFound(request)
543
+
544
+ const notDraft = notDraftProblem(request, message)
545
+
546
+ if (notDraft !== undefined) return notDraft
547
+
548
+ const fields = bodyObject(request, ['destinationId'])
549
+
550
+ if (fields instanceof Response) return fields
551
+
552
+ if (!Predicate.isString(fields.destinationId)) {
553
+ return invalidValue(request, 'destinationId must be a string (only deleteditems is emulated).')
554
+ }
555
+
556
+ if (fields.destinationId !== moveDestination) {
557
+ return notEmulated(
558
+ request,
559
+ `moves to destinations other than ${moveDestination} are not emulated (the fixtures move only to Deleted Items).`
560
+ )
561
+ }
562
+
563
+ // Every valid seed has a Deleted Items folder (the seed check requires it).
564
+ const destination = wellKnownFolder(state, moveDestination)
565
+
566
+ if (destination === undefined) return notFound(request)
567
+
568
+ return heldWrite(env, request, message, () => {
569
+ const moved: MicrosoftEmulatorMessage = {
570
+ ...message,
571
+ parentFolderId: destination.id,
572
+ lastModifiedDateTime: nowTimestamp(env),
573
+ changeKey: nextChangeKey(state, messageTagPrefix)
574
+ }
575
+
576
+ replaceMessage(state, moved)
577
+
578
+ return jsonResponse(201, entity(messageEntityContext(env, request), renderMessage(moved)))
579
+ })
580
+ }
581
+
582
+ const attachmentBase = (attachment: MicrosoftEmulatorAttachment) => ({
583
+ '@odata.type': '#microsoft.graph.fileAttachment',
584
+ '@odata.mediaContentType': attachment.contentType,
585
+ id: attachment.id
586
+ })
587
+
588
+ /** A listed attachment: base `attachment` properties only (no `contentId`), as the fixture shows. */
589
+ const renderListedAttachment = (attachment: MicrosoftEmulatorAttachment) => ({
590
+ ...attachmentBase(attachment),
591
+ name: attachment.name,
592
+ contentType: attachment.contentType,
593
+ size: attachment.size,
594
+ isInline: attachment.isInline,
595
+ lastModifiedDateTime: attachment.lastModifiedDateTime
596
+ })
597
+
598
+ /** A retrieved file attachment, with `contentId` and `contentBytes`, in the fixture key order. */
599
+ const renderAttachment = (attachment: MicrosoftEmulatorAttachment) => ({
600
+ ...attachmentBase(attachment),
601
+ lastModifiedDateTime: attachment.lastModifiedDateTime,
602
+ name: attachment.name,
603
+ contentType: attachment.contentType,
604
+ size: attachment.size,
605
+ isInline: attachment.isInline,
606
+ contentId: attachment.contentId,
607
+ contentLocation: attachment.contentLocation,
608
+ contentBytes: attachment.contentBytes
609
+ })
610
+
611
+ /**
612
+ * `GET /users/{userId}/messages/{messageId}/attachments`: every attachment, inline ones included,
613
+ * in one response (the fixtures send only `$select`: no `$top`, `$skip`, or `@odata.nextLink`).
614
+ * `$select` accepts only base `attachment` properties, so a listing never carries `contentId`.
615
+ */
616
+ export const listAttachments: RouteHandler = (state, request, env) => {
617
+ const user = resolveUser(state, request)
618
+
619
+ if (user instanceof Response) return user
620
+
621
+ const immutable = immutableIdProblem(request)
622
+
623
+ if (immutable !== undefined) return immutable
624
+
625
+ const message = findMessage(state, request.params.messageId ?? '')
626
+
627
+ if (message === undefined) return notFound(request)
628
+
629
+ const fields = selectedFields(request, attachmentListFields)
630
+
631
+ if (fields instanceof Response) return fields
632
+
633
+ const attachments = state.attachments.filter(attachment => attachment.messageId === message.id)
634
+
635
+ const context = metadataContext(
636
+ env,
637
+ `${userContext(request)}/messages${odataKey(request.params.messageId ?? '')}/attachments${selectSuffix(fields)}`
638
+ )
639
+
640
+ return jsonResponse(
641
+ 200,
642
+ collection(
643
+ context,
644
+ attachments.map(attachment => project(renderListedAttachment(attachment), fields)),
645
+ undefined
646
+ )
647
+ )
648
+ }
649
+
650
+ /** `GET /users/{userId}/messages/{messageId}/attachments/{attachmentId}`: the file attachment. */
651
+ export const getAttachment: RouteHandler = (state, request, env) => {
652
+ const user = resolveUser(state, request)
653
+
654
+ if (user instanceof Response) return user
655
+
656
+ const immutable = immutableIdProblem(request)
657
+
658
+ if (immutable !== undefined) return immutable
659
+
660
+ const message = findMessage(state, request.params.messageId ?? '')
661
+
662
+ const attachment =
663
+ message === undefined
664
+ ? undefined
665
+ : state.attachments.find(
666
+ candidate =>
667
+ candidate.messageId === message.id && candidate.id === request.params.attachmentId
668
+ )
669
+
670
+ const context = metadataContext(
671
+ env,
672
+ `${userContext(request)}/messages${odataKey(request.params.messageId ?? '')}/attachments/$entity`
673
+ )
674
+
675
+ return attachment === undefined
676
+ ? notFound(request)
677
+ : jsonResponse(200, entity(context, renderAttachment(attachment)))
678
+ }
679
+
680
+ // JSON batching: only permanent-delete subrequests are emulated.
681
+
682
+ const maxBatchRequests = 20
683
+
684
+ const permanentDeletePattern = /^\/?users\/([^/?#]+)\/messages\/([^/?#]+)\/permanentDelete$/
685
+
686
+ /** Subrequest headers the fixtures send (`Prefer`, which must ask for immutable ids). */
687
+ const allowedSubrequestHeaders: ReadonlyArray<string> = ['prefer']
688
+
689
+ type PermanentDelete = {
690
+ readonly id: string
691
+ readonly userId: string
692
+ readonly messageId: string
693
+ }
694
+
695
+ /** A validated subrequest, or why the whole batch is refused. */
696
+ const permanentDeleteOf = (entry: Schema.Json): PermanentDelete | string => {
697
+ if (!isJsonObject(entry)) return 'each batch request must be an object.'
698
+
699
+ const unknown = Object.keys(entry).find(key => !['id', 'method', 'url', 'headers'].includes(key))
700
+
701
+ if (unknown !== undefined) {
702
+ return `batch request property '${unknown}' is not emulated (no dependsOn or bodies).`
703
+ }
704
+
705
+ const { id, method, url, headers } = entry
706
+
707
+ if (!Predicate.isString(id) || id.trim() === '') return 'each batch request needs an id.'
708
+
709
+ if (!Predicate.isString(method) || !Predicate.isString(url)) {
710
+ return 'each batch request needs a method and a url.'
711
+ }
712
+
713
+ const match = permanentDeletePattern.exec(url)
714
+
715
+ if (method.toUpperCase() !== 'POST' || match === null) {
716
+ return 'only POST /users/{userId}/messages/{messageId}/permanentDelete subrequests are emulated.'
717
+ }
718
+
719
+ const userId = decodeSegment(match[1] ?? '')
720
+ const messageId = decodeSegment(match[2] ?? '')
721
+
722
+ if (userId === undefined || messageId === undefined) return 'a batch url is not decodable.'
723
+
724
+ if (!isJsonObject(headers)) {
725
+ return 'batch requests without headers { Prefer: IdType="ImmutableId" } are not emulated.'
726
+ }
727
+
728
+ let prefer: string | undefined
729
+
730
+ for (const [name, value] of Object.entries(headers)) {
731
+ if (!allowedSubrequestHeaders.includes(name.toLowerCase()) || !Predicate.isString(value)) {
732
+ return `batch request header '${name}' is not emulated.`
733
+ }
734
+
735
+ prefer = value
736
+ }
737
+
738
+ if (!parsePreferences(prefer ?? null).immutableId) {
739
+ return 'batch requests without Prefer: IdType="ImmutableId" are not emulated.'
740
+ }
741
+
742
+ return { id, userId, messageId }
743
+ }
744
+
745
+ /**
746
+ * `POST /$batch`: at most 20 subrequests with unique ids, each a message `permanentDelete` with
747
+ * `Prefer: IdType="ImmutableId"`, answered `{ responses: [{ id, status: 204, headers: {} }] }` as
748
+ * the fixtures record. Every subrequest is checked before any runs: anything else (another
749
+ * route, an unknown user or message, a message named twice, a message with a write in flight, a
750
+ * message that is not a draft, or one with attachments) refuses the whole batch with 400 and
751
+ * deletes nothing, because no fixture records a failed subrequest or deletes anything but a
752
+ * case-created draft (which has no attachments).
753
+ */
754
+ export const batch: RouteHandler = (state, request, env) => {
755
+ const fields = bodyObject(request, ['requests'])
756
+
757
+ if (fields instanceof Response) return fields
758
+
759
+ const requests = fields.requests
760
+
761
+ if (!Array.isArray(requests) || requests.length === 0 || requests.length > maxBatchRequests) {
762
+ return invalidValue(request, `requests must hold 1 to ${maxBatchRequests} subrequests.`)
763
+ }
764
+
765
+ const deletes: Array<PermanentDelete> = []
766
+
767
+ for (const entry of requests) {
768
+ const parsed = permanentDeleteOf(entry)
769
+
770
+ if (Predicate.isString(parsed)) {
771
+ return request.error(400, codes.unknownRoute, `Synthetic: ${parsed}`)
772
+ }
773
+
774
+ if (deletes.some(existing => existing.id === parsed.id)) {
775
+ return invalidValue(request, `batch request id ${parsed.id} is used twice.`)
776
+ }
777
+
778
+ deletes.push(parsed)
779
+ }
780
+
781
+ const messages: Array<MicrosoftEmulatorMessage> = []
782
+
783
+ for (const entry of deletes) {
784
+ const itemRequest: RouteRequest = {
785
+ ...request,
786
+ params: { userId: entry.userId, messageId: entry.messageId }
787
+ }
788
+
789
+ const message = findMessage(state, entry.messageId)
790
+
791
+ if (resolveUser(state, itemRequest) instanceof Response) {
792
+ return notEmulated(request, `batch request ${entry.id} names a user that is not emulated.`)
793
+ }
794
+
795
+ if (message === undefined || messages.includes(message)) {
796
+ return notEmulated(
797
+ request,
798
+ `batch request ${entry.id} names a missing or repeated message (failed subrequests are not emulated).`
799
+ )
800
+ }
801
+
802
+ if (!message.isDraft) {
803
+ return notEmulated(
804
+ request,
805
+ `batch request ${entry.id} names a message that is not a draft (the fixtures delete only drafts).`
806
+ )
807
+ }
808
+
809
+ if (state.attachments.some(attachment => attachment.messageId === message.id)) {
810
+ return notEmulated(
811
+ request,
812
+ `batch request ${entry.id} names a message with attachments (not emulated: no fixture deletes one).`
813
+ )
814
+ }
815
+
816
+ if (env.messageLocks.has(message.id)) {
817
+ return notEmulated(
818
+ request,
819
+ `batch request ${entry.id} names a message with a write in flight (failed subrequests are not emulated).`
820
+ )
821
+ }
822
+
823
+ messages.push(message)
824
+ }
825
+
826
+ const removed = new Set(messages.map(message => message.id))
827
+
828
+ state.messages = state.messages.filter(message => !removed.has(message.id))
829
+
830
+ return jsonResponse(200, {
831
+ responses: deletes.map(entry => ({ id: entry.id, status: 204, headers: {} }))
832
+ })
833
+ }