@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
package/src/email.ts ADDED
@@ -0,0 +1,1108 @@
1
+ /**
2
+ * Email emulator: a fixture-driven, in-memory fake backend for the generic `EmailClient` port.
3
+ *
4
+ * Yolk never speaks IMAP, POP3, or SMTP; hosts implement `EmailClient`. This emulator stands in for
5
+ * such a host at the port level: `call(method, request)` takes one port call as plain JSON (the
6
+ * credential-free request) and answers `{ response }`, `{ failure }`, or a fail-closed
7
+ * `{ notEmulated }`. There is no socket, TLS, MIME, or mail library here, no `node:` builtin, and
8
+ * no SDK import: it is a plain structural object, and
9
+ * `emailClientLayerFromBackend` in `@yolk-sdk/connectors/email/conformance` turns it into the
10
+ * `EmailClient` layer.
11
+ *
12
+ * Response behaviour comes ONLY from the email conformance fixtures (copied as data in
13
+ * `email-fixtures.ts`). The emulator keeps an in-memory mailbox (folders, messages, flags) seeded
14
+ * with the mailbox the fixtures describe; the state only selects which fixture answers (the first
15
+ * matching fixture consistent with the mailbox) and is updated with what that fixture says
16
+ * happened (flags set, a message moved to the destination id the fixture names, a draft appended,
17
+ * a Sent copy saved). It never invents an id, a flag, or a response.
18
+ *
19
+ * Request-shape latitude (the only one): credential fields are never compared or recorded, and
20
+ * `connection.host` is not compared (the practice host comes from seeds). Every other connection
21
+ * field (`protocol`, `port`, `security`) is compared. Everything else must equal a fixture request
22
+ * exactly. Anything that does not
23
+ * match (an unknown method, no matching fixture, or no fixture consistent with the mailbox state)
24
+ * fails closed with a ledgered `notEmulated` answer, the port analogue of HTTP 400.
25
+ *
26
+ * @experimental
27
+ */
28
+ import { Data, Equal, Predicate, Result } from 'effect'
29
+ import * as Schema from 'effect/Schema'
30
+ import {
31
+ emailEmulatorFixtures,
32
+ type EmailEmulatorFailure,
33
+ type EmailEmulatorFixture
34
+ } from './email-fixtures.ts'
35
+ import {
36
+ bindRouteHandlers,
37
+ emulatorRouteKey,
38
+ type EmulatorEvidence,
39
+ type EmulatorRouteEvidence
40
+ } from './route-evidence.ts'
41
+
42
+ export type { EmulatorEvidence, EmulatorRouteEvidence } from './route-evidence.ts'
43
+
44
+ export { emailEmulatorFixtures, type EmailEmulatorFailure, type EmailEmulatorFixture }
45
+
46
+ /** The port every email fixture and manifest route names. */
47
+ export const emailEmulatorPort = 'EmailClient'
48
+
49
+ /** Manifest routes of port emulators use this pseudo method; `path` is `<Port>.<method>`. */
50
+ export const emailEmulatorRouteMethod = 'PORT'
51
+
52
+ const portRoute = (
53
+ method: string,
54
+ write: boolean,
55
+ caseIds: ReadonlyArray<string>
56
+ ): EmulatorRouteEvidence => ({
57
+ method: emailEmulatorRouteMethod,
58
+ path: `${emailEmulatorPort}.${method}`,
59
+ kind: 'connector',
60
+ write,
61
+ caseIds,
62
+ evidence: 'unverified',
63
+ observedAt: undefined
64
+ })
65
+
66
+ const listAndGet = 'email.imap.list-and-get-headers'
67
+
68
+ const filteredList = 'email.imap.filtered-list-no-fallback'
69
+
70
+ const draftsDiscovery = 'email.imap.draft-drafts-discovery'
71
+
72
+ const setReadAndFlag = 'email.imap.set-read-and-flag'
73
+
74
+ const trashUntrash = 'email.imap.trash-untrash-to-inbox'
75
+
76
+ const moveIds = 'email.imap.move-destination-ids'
77
+
78
+ const pop3Rejections = 'email.pop3.rejects-folders-drafts-mutations'
79
+
80
+ const sentCopyStatuses = 'email.smtp.sent-copy-statuses'
81
+
82
+ const legacySentCopy = 'email.smtp.legacy-host-sent-copy'
83
+
84
+ const acceptanceNotDelivery = 'email.smtp.acceptance-not-delivery'
85
+
86
+ /**
87
+ * Route evidence manifest: every emulated `EmailClient` method (`PORT EmailClient.<method>`) and the
88
+ * email conformance cases whose (synthetic, unverified) fixtures it follows. Write methods are
89
+ * unverified connector writes: they need a pending entry in the repo's evidence check until an
90
+ * owner-approved live run against a practice mailbox verifies them.
91
+ */
92
+ export const emailEmulatorRoutes: ReadonlyArray<EmulatorRouteEvidence> = [
93
+ portRoute('listMessages', false, [listAndGet, pop3Rejections]),
94
+ portRoute('listMessagesFiltered', false, [filteredList]),
95
+ portRoute('getMessage', false, [
96
+ listAndGet,
97
+ draftsDiscovery,
98
+ setReadAndFlag,
99
+ trashUntrash,
100
+ moveIds
101
+ ]),
102
+ portRoute('createDraft', true, [draftsDiscovery, trashUntrash, moveIds]),
103
+ portRoute('sendMessage', true, [sentCopyStatuses, legacySentCopy, acceptanceNotDelivery]),
104
+ portRoute('setRead', true, [setReadAndFlag]),
105
+ portRoute('setFlag', true, [setReadAndFlag]),
106
+ portRoute('trash', true, [trashUntrash]),
107
+ portRoute('untrash', true, [trashUntrash]),
108
+ portRoute('move', true, [moveIds]),
109
+ portRoute('deletePermanently', true, [draftsDiscovery, trashUntrash, moveIds])
110
+ ]
111
+
112
+ const NonEmpty = Schema.String.check(Schema.isNonEmpty())
113
+
114
+ /** SPECIAL-USE attributes (RFC 6154) a seeded folder may advertise. */
115
+ export const EmailEmulatorSpecialUse = Schema.Literals([
116
+ '\\All',
117
+ '\\Archive',
118
+ '\\Drafts',
119
+ '\\Flagged',
120
+ '\\Junk',
121
+ '\\Sent',
122
+ '\\Trash'
123
+ ])
124
+
125
+ export type EmailEmulatorSpecialUse = typeof EmailEmulatorSpecialUse.Type
126
+
127
+ /**
128
+ * One message in the emulated mailbox. `id` is the opaque IMAP id (absent for a Sent copy whose
129
+ * id no fixture named), `uidl` the POP3 id of an INBOX message; unknown flags stay absent until a
130
+ * fixture reports them.
131
+ */
132
+ export const EmailEmulatorMessage = Schema.Struct({
133
+ id: Schema.optionalKey(NonEmpty),
134
+ uidl: Schema.optionalKey(NonEmpty),
135
+ subject: Schema.optionalKey(Schema.String),
136
+ isRead: Schema.optionalKey(Schema.Boolean),
137
+ isFlagged: Schema.optionalKey(Schema.Boolean)
138
+ })
139
+
140
+ export type EmailEmulatorMessage = typeof EmailEmulatorMessage.Type
141
+
142
+ export const EmailEmulatorFolder = Schema.Struct({
143
+ name: NonEmpty,
144
+ specialUse: Schema.optionalKey(EmailEmulatorSpecialUse),
145
+ messages: Schema.Array(EmailEmulatorMessage)
146
+ })
147
+
148
+ export type EmailEmulatorFolder = typeof EmailEmulatorFolder.Type
149
+
150
+ /** The emulated mailbox: plain JSON, used both as the seed and as the `state()` snapshot. */
151
+ export const EmailEmulatorSeed = Schema.Struct({
152
+ folders: Schema.Array(EmailEmulatorFolder)
153
+ })
154
+
155
+ export type EmailEmulatorSeed = typeof EmailEmulatorSeed.Type
156
+
157
+ /** The synthetic practice mailbox the email fixtures describe (the default seed). */
158
+ export const emailEmulatorDefaultSeed: EmailEmulatorSeed = {
159
+ folders: [
160
+ {
161
+ name: 'INBOX',
162
+ messages: [
163
+ {
164
+ id: '1700000001:41',
165
+ uidl: 'uidl-synthetic-0041',
166
+ subject: 'Synthetic welcome',
167
+ isRead: false,
168
+ isFlagged: false
169
+ },
170
+ {
171
+ id: '1700000001:42',
172
+ uidl: 'uidl-synthetic-0042',
173
+ subject: 'Synthetic weekly summary',
174
+ isRead: true,
175
+ isFlagged: false
176
+ }
177
+ ]
178
+ },
179
+ { name: 'Saved Drafts', specialUse: '\\Drafts', messages: [] },
180
+ { name: 'Sent Items', specialUse: '\\Sent', messages: [] },
181
+ { name: 'Deleted Items', specialUse: '\\Trash', messages: [] },
182
+ { name: 'Archive', messages: [] }
183
+ ]
184
+ }
185
+
186
+ const Failure = Schema.Struct({
187
+ kind: Schema.Literals(['expected', 'error']),
188
+ code: NonEmpty,
189
+ message: Schema.String,
190
+ status: Schema.optionalKey(Schema.Int)
191
+ })
192
+
193
+ /**
194
+ * A fault: answer the next `count` calls (every one when omitted) of `method` whose credential-free
195
+ * request contains `match` (a deep subset: objects by key, arrays element by element, other values
196
+ * exactly) with `failure` instead of a fixture. Faults change no mailbox state.
197
+ */
198
+ export const EmailEmulatorFault = Schema.Struct({
199
+ kind: Schema.Literal('failure'),
200
+ method: NonEmpty,
201
+ match: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
202
+ count: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
203
+ failure: Failure
204
+ })
205
+
206
+ export type EmailEmulatorFault = typeof EmailEmulatorFault.Type
207
+
208
+ export type EmailEmulatorFaultState = {
209
+ readonly id: number
210
+ readonly fault: EmailEmulatorFault
211
+ /** Remaining matching calls; `undefined` for an unlimited fault. */
212
+ readonly remaining: number | undefined
213
+ readonly applied: number
214
+ }
215
+
216
+ /** Thrown by `makeEmailEmulator` (invalid seed) and `faults.add` (invalid fault). */
217
+ export class EmailEmulatorInputInvalid extends Data.TaggedError('EmailEmulatorInputInvalid')<{
218
+ readonly input: 'seed' | 'fault'
219
+ readonly reason: string
220
+ }> {
221
+ override get message(): string {
222
+ return `Invalid email emulator ${this.input}: ${this.reason}`
223
+ }
224
+ }
225
+
226
+ /** Why a call failed closed. */
227
+ export type EmailEmulatorNotEmulatedReason =
228
+ | 'unknown-method'
229
+ | 'invalid-request'
230
+ | 'no-matching-fixture'
231
+ | 'state-conflict'
232
+
233
+ const notEmulatedText: Record<EmailEmulatorNotEmulatedReason, string> = {
234
+ 'unknown-method': 'unknown-method: the method has no emulated route',
235
+ 'invalid-request': 'invalid-request: the request is not a JSON object',
236
+ 'no-matching-fixture': 'no-matching-fixture: no fixture matches this request',
237
+ 'state-conflict': 'state-conflict: no matching fixture is consistent with the emulated mailbox'
238
+ }
239
+
240
+ /** One emulator answer; structurally the reply of a plain-JSON `EmailBackend`. */
241
+ export type EmailEmulatorReply =
242
+ | { readonly response: Schema.Json }
243
+ | { readonly failure: EmailEmulatorFailure }
244
+ | { readonly notEmulated: { readonly reason: string } }
245
+
246
+ export type EmailEmulatorLedgerEntry = {
247
+ readonly seq: number
248
+ readonly method: string
249
+ /** The request as received, without credential fields. */
250
+ readonly request: Schema.Json
251
+ readonly outcome: 'answered' | 'fault' | 'not-emulated'
252
+ /** `unknown-method` for a method outside the manifest. */
253
+ readonly evidence: EmulatorEvidence | 'unknown-method'
254
+ readonly fixtureId?: string
255
+ readonly faultId?: number
256
+ readonly reason?: EmailEmulatorNotEmulatedReason
257
+ }
258
+
259
+ export type EmailEmulatorRouteCoverage = EmulatorRouteEvidence & {
260
+ /** Ledger calls to this method since the last ledger clear or reset. */
261
+ readonly calls: number
262
+ }
263
+
264
+ export type EmailEmulatorCoverage = {
265
+ readonly routes: ReadonlyArray<EmailEmulatorRouteCoverage>
266
+ /** Ledger calls that failed closed. */
267
+ readonly notEmulatedCalls: number
268
+ /** Fixtures that have not answered a call since the emulator was made or last reset. */
269
+ readonly unusedFixtureIds: ReadonlyArray<string>
270
+ }
271
+
272
+ export type EmailEmulatorOptions = {
273
+ /** The initial mailbox. Defaults to `emailEmulatorDefaultSeed`. */
274
+ readonly seed?: EmailEmulatorSeed
275
+ }
276
+
277
+ export type EmailEmulator = {
278
+ /** Answer one `EmailClient` call; never throws. This makes the emulator an `EmailBackend`. */
279
+ readonly call: (method: string, request: Schema.Json) => EmailEmulatorReply
280
+ /** The current mailbox (a copy). */
281
+ readonly state: () => EmailEmulatorSeed
282
+ /** The seed the emulator started from (a copy). */
283
+ readonly seed: () => EmailEmulatorSeed
284
+ readonly ledger: {
285
+ readonly entries: () => ReadonlyArray<EmailEmulatorLedgerEntry>
286
+ readonly clear: () => void
287
+ }
288
+ /** Clear the ledger and faults, forget fixture use, and restore the seeded mailbox. */
289
+ readonly reset: () => void
290
+ readonly faults: {
291
+ readonly add: (fault: EmailEmulatorFault) => EmailEmulatorFaultState
292
+ readonly list: () => ReadonlyArray<EmailEmulatorFaultState>
293
+ readonly clear: () => void
294
+ }
295
+ readonly coverage: () => EmailEmulatorCoverage
296
+ }
297
+
298
+ // JSON helpers.
299
+
300
+ type JsonObject = Schema.JsonObject
301
+
302
+ const isJsonObject = (value: Schema.Json | undefined): value is JsonObject =>
303
+ value !== undefined && value !== null && Predicate.isObject(value) && !Array.isArray(value)
304
+
305
+ const objectField = (value: Schema.Json | undefined, key: string): JsonObject | undefined => {
306
+ const field = isJsonObject(value) ? value[key] : undefined
307
+
308
+ return isJsonObject(field) ? field : undefined
309
+ }
310
+
311
+ const stringField = (value: Schema.Json | undefined, key: string): string | undefined => {
312
+ const field = isJsonObject(value) ? value[key] : undefined
313
+
314
+ return Predicate.isString(field) ? field : undefined
315
+ }
316
+
317
+ const booleanField = (value: Schema.Json | undefined, key: string): boolean | undefined => {
318
+ const field = isJsonObject(value) ? value[key] : undefined
319
+
320
+ return Predicate.isBoolean(field) ? field : undefined
321
+ }
322
+
323
+ const arrayField = (
324
+ value: Schema.Json | undefined,
325
+ key: string
326
+ ): ReadonlyArray<Schema.Json> | undefined => {
327
+ const field = isJsonObject(value) ? value[key] : undefined
328
+
329
+ return Array.isArray(field) ? field : undefined
330
+ }
331
+
332
+ // The same credential field names the conformance secret scan flags, plus `credential(s)`.
333
+ const credentialKeyPattern =
334
+ /^(?:credentials?|(?:access|refresh|id|auth|api|session|private|bearer|oauth)[_-]?token|token|client[_-]?secret|secret(?:[_-]?key)?|private[_-]?key|password|passwd|api[_-]?key|authorization)$/i
335
+
336
+ /** A copy without credential fields; `ignoreConnectionHost` applies the request-shape latitude. */
337
+ const scrub = (value: Schema.Json, ignoreConnectionHost: boolean): Schema.Json => {
338
+ if (Array.isArray(value)) {
339
+ return value.map(item => scrub(item, ignoreConnectionHost))
340
+ }
341
+
342
+ if (!isJsonObject(value)) {
343
+ return value
344
+ }
345
+
346
+ const copy: Record<string, Schema.Json> = {}
347
+
348
+ for (const [key, item] of Object.entries(value)) {
349
+ if (credentialKeyPattern.test(key)) {
350
+ continue
351
+ }
352
+
353
+ if (ignoreConnectionHost && key === 'connection' && isJsonObject(item)) {
354
+ const { host: _host, ...connection } = item
355
+
356
+ copy[key] = scrub(connection, ignoreConnectionHost)
357
+ continue
358
+ }
359
+
360
+ copy[key] = scrub(item, ignoreConnectionHost)
361
+ }
362
+
363
+ return copy
364
+ }
365
+
366
+ /** Deep subset: objects by key, arrays element by element (same length), other values exactly. */
367
+ const containsSubset = (value: Schema.Json | undefined, pattern: Schema.Json): boolean => {
368
+ if (value === undefined) {
369
+ return false
370
+ }
371
+
372
+ if (Array.isArray(pattern)) {
373
+ return (
374
+ Array.isArray(value) &&
375
+ value.length === pattern.length &&
376
+ pattern.every((item, index) => containsSubset(value[index], item))
377
+ )
378
+ }
379
+
380
+ if (isJsonObject(pattern)) {
381
+ return (
382
+ isJsonObject(value) &&
383
+ Object.entries(pattern).every(([key, item]) => containsSubset(value[key], item))
384
+ )
385
+ }
386
+
387
+ return Equal.equals(value, pattern)
388
+ }
389
+
390
+ // Mailbox state.
391
+
392
+ type MessageState = {
393
+ id?: string
394
+ uidl?: string
395
+ subject?: string
396
+ isRead?: boolean
397
+ isFlagged?: boolean
398
+ }
399
+
400
+ type FolderState = {
401
+ readonly name: string
402
+ readonly specialUse?: EmailEmulatorSpecialUse
403
+ messages: Array<MessageState>
404
+ }
405
+
406
+ type MailboxState = { readonly folders: Array<FolderState> }
407
+
408
+ const copyMessage = (message: EmailEmulatorMessage | MessageState): MessageState => {
409
+ const copy: MessageState = {}
410
+
411
+ if (message.id !== undefined) copy.id = message.id
412
+
413
+ if (message.uidl !== undefined) copy.uidl = message.uidl
414
+
415
+ if (message.subject !== undefined) copy.subject = message.subject
416
+
417
+ if (message.isRead !== undefined) copy.isRead = message.isRead
418
+
419
+ if (message.isFlagged !== undefined) copy.isFlagged = message.isFlagged
420
+
421
+ return copy
422
+ }
423
+
424
+ const mailboxFrom = (seed: EmailEmulatorSeed): MailboxState => ({
425
+ folders: seed.folders.map(folder =>
426
+ folder.specialUse === undefined
427
+ ? { name: folder.name, messages: folder.messages.map(copyMessage) }
428
+ : {
429
+ name: folder.name,
430
+ specialUse: folder.specialUse,
431
+ messages: folder.messages.map(copyMessage)
432
+ }
433
+ )
434
+ })
435
+
436
+ const snapshotOf = (state: MailboxState): EmailEmulatorSeed => ({
437
+ folders: state.folders.map(folder =>
438
+ folder.specialUse === undefined
439
+ ? { name: folder.name, messages: folder.messages.map(copyMessage) }
440
+ : {
441
+ name: folder.name,
442
+ specialUse: folder.specialUse,
443
+ messages: folder.messages.map(copyMessage)
444
+ }
445
+ )
446
+ })
447
+
448
+ const folderNamed = (state: MailboxState, name: string | undefined): FolderState | undefined =>
449
+ name === undefined ? undefined : state.folders.find(folder => folder.name === name)
450
+
451
+ const specialUseFolder = (
452
+ state: MailboxState,
453
+ use: EmailEmulatorSpecialUse
454
+ ): FolderState | undefined => state.folders.find(folder => folder.specialUse === use)
455
+
456
+ const isPop3 = (request: JsonObject): boolean =>
457
+ stringField(objectField(request, 'connection'), 'protocol') === 'pop3'
458
+
459
+ const messageIn = (
460
+ folder: FolderState | undefined,
461
+ id: string | undefined,
462
+ pop3 = false
463
+ ): MessageState | undefined =>
464
+ folder === undefined || id === undefined
465
+ ? undefined
466
+ : folder.messages.find(message => (pop3 ? message.uidl : message.id) === id)
467
+
468
+ const flagKeys = ['isRead', 'isFlagged'] as const
469
+
470
+ /** Reported flags and subject agree with what the mailbox knows (unknown state agrees). */
471
+ const agrees = (message: MessageState, reported: Schema.Json | undefined): boolean => {
472
+ const subject = stringField(reported, 'subject')
473
+
474
+ return (
475
+ flagKeys.every(key => {
476
+ const value = booleanField(reported, key)
477
+
478
+ return value === undefined || message[key] === undefined || message[key] === value
479
+ }) &&
480
+ (subject === undefined || message.subject === undefined || message.subject === subject)
481
+ )
482
+ }
483
+
484
+ /** Remember flags and subject the mailbox did not know yet. */
485
+ const learn = (message: MessageState, reported: Schema.Json | undefined): void => {
486
+ for (const key of flagKeys) {
487
+ const value = booleanField(reported, key)
488
+
489
+ if (message[key] === undefined && value !== undefined) {
490
+ message[key] = value
491
+ }
492
+ }
493
+
494
+ const subject = stringField(reported, 'subject')
495
+
496
+ if (message.subject === undefined && subject !== undefined) {
497
+ message.subject = subject
498
+ }
499
+ }
500
+
501
+ /** A state model for one method: whether a fixture is consistent, and what it changes. */
502
+ type MethodModel = {
503
+ readonly consistent: (
504
+ state: MailboxState,
505
+ request: JsonObject,
506
+ fixture: EmailEmulatorFixture
507
+ ) => boolean
508
+ readonly apply: (state: MailboxState, request: JsonObject, fixture: EmailEmulatorFixture) => void
509
+ }
510
+
511
+ const requestFolder = (state: MailboxState, request: JsonObject) =>
512
+ folderNamed(state, stringField(request, 'folder') ?? 'INBOX')
513
+
514
+ /** Every known message that certainly passes the request's read/flagged filters. */
515
+ const certainlyListed = (message: MessageState, request: JsonObject): boolean =>
516
+ flagKeys.every(key => {
517
+ const filter = booleanField(request, key)
518
+
519
+ return filter === undefined || message[key] === filter
520
+ })
521
+
522
+ const listModel: MethodModel = {
523
+ consistent: (state, request, fixture) => {
524
+ if (fixture.failure !== undefined) {
525
+ return true
526
+ }
527
+
528
+ const folder = requestFolder(state, request)
529
+ const listed = arrayField(fixture.response, 'messages')
530
+ const pop3 = isPop3(request)
531
+
532
+ if (folder === undefined || listed === undefined) {
533
+ return false
534
+ }
535
+
536
+ const everyListedKnown = listed.every(summary => {
537
+ const message = messageIn(folder, stringField(summary, 'id'), pop3)
538
+
539
+ return message !== undefined && agrees(message, summary)
540
+ })
541
+
542
+ if (!everyListedKnown) {
543
+ return false
544
+ }
545
+
546
+ if (stringField(fixture.response, 'nextCursor') !== undefined) {
547
+ return true
548
+ }
549
+
550
+ const listedIds = new Set(listed.map(summary => stringField(summary, 'id')))
551
+
552
+ return folder.messages.every(message => {
553
+ const id = pop3 ? message.uidl : message.id
554
+
555
+ return id === undefined || !certainlyListed(message, request) || listedIds.has(id)
556
+ })
557
+ },
558
+ apply: (state, request, fixture) => {
559
+ const folder = requestFolder(state, request)
560
+ const pop3 = isPop3(request)
561
+
562
+ for (const summary of arrayField(fixture.response, 'messages') ?? []) {
563
+ const message = messageIn(folder, stringField(summary, 'id'), pop3)
564
+
565
+ if (message !== undefined) {
566
+ learn(message, summary)
567
+ }
568
+ }
569
+ }
570
+ }
571
+
572
+ const getModel: MethodModel = {
573
+ consistent: (state, request, fixture) => {
574
+ const id = stringField(request, 'messageId')
575
+ const message = messageIn(requestFolder(state, request), id, isPop3(request))
576
+
577
+ if (fixture.failure !== undefined) {
578
+ // A failed get is consistent only where the mailbox has no such message.
579
+ return message === undefined
580
+ }
581
+
582
+ const reported = objectField(fixture.response, 'message')
583
+
584
+ return message !== undefined && stringField(reported, 'id') === id && agrees(message, reported)
585
+ },
586
+ apply: (state, request, fixture) => {
587
+ const message = messageIn(
588
+ requestFolder(state, request),
589
+ stringField(request, 'messageId'),
590
+ isPop3(request)
591
+ )
592
+
593
+ if (message !== undefined && fixture.failure === undefined) {
594
+ learn(message, objectField(fixture.response, 'message'))
595
+ }
596
+ }
597
+ }
598
+
599
+ const flagModel = (key: 'isRead' | 'isFlagged'): MethodModel => ({
600
+ consistent: (state, request, fixture) => {
601
+ if (fixture.failure !== undefined) {
602
+ return true
603
+ }
604
+
605
+ const id = stringField(request, 'messageId')
606
+
607
+ return (
608
+ messageIn(requestFolder(state, request), id) !== undefined &&
609
+ stringField(fixture.response, 'messageId') === id &&
610
+ booleanField(fixture.response, key) === booleanField(request, key)
611
+ )
612
+ },
613
+ apply: (state, request, fixture) => {
614
+ const message = messageIn(requestFolder(state, request), stringField(request, 'messageId'))
615
+ const value = booleanField(request, key)
616
+
617
+ if (fixture.failure === undefined && message !== undefined && value !== undefined) {
618
+ message[key] = value
619
+ }
620
+ }
621
+ })
622
+
623
+ /** A move-shaped method: where the message comes from and which destination is acceptable. */
624
+ const moveModel = (
625
+ source: (state: MailboxState, request: JsonObject) => FolderState | undefined,
626
+ destinationAllowed: (
627
+ state: MailboxState,
628
+ request: JsonObject,
629
+ destination: FolderState
630
+ ) => boolean
631
+ ): MethodModel => {
632
+ const plan = (state: MailboxState, request: JsonObject, fixture: EmailEmulatorFixture) => {
633
+ const from = source(state, request)
634
+ const message = messageIn(from, stringField(request, 'messageId'))
635
+ const destination = folderNamed(state, stringField(fixture.response, 'folder'))
636
+ const movedId = stringField(fixture.response, 'messageId')
637
+
638
+ return { from, message, destination, movedId }
639
+ }
640
+
641
+ return {
642
+ consistent: (state, request, fixture) => {
643
+ if (fixture.failure !== undefined) {
644
+ return true
645
+ }
646
+
647
+ const { from, message, destination, movedId } = plan(state, request, fixture)
648
+
649
+ return (
650
+ booleanField(fixture.response, 'moved') === true &&
651
+ from !== undefined &&
652
+ message !== undefined &&
653
+ destination !== undefined &&
654
+ destination !== from &&
655
+ destinationAllowed(state, request, destination) &&
656
+ messageIn(destination, movedId) === undefined
657
+ )
658
+ },
659
+ apply: (state, request, fixture) => {
660
+ if (fixture.failure !== undefined) {
661
+ return
662
+ }
663
+
664
+ const { from, message, destination, movedId } = plan(state, request, fixture)
665
+
666
+ if (from === undefined || message === undefined || destination === undefined) {
667
+ return
668
+ }
669
+
670
+ from.messages = from.messages.filter(candidate => candidate !== message)
671
+
672
+ const moved: MessageState = { ...message }
673
+
674
+ delete moved.uidl
675
+
676
+ if (movedId === undefined) {
677
+ delete moved.id
678
+ } else {
679
+ moved.id = movedId
680
+ }
681
+
682
+ destination.messages.push(moved)
683
+ }
684
+ }
685
+ }
686
+
687
+ const createDraftModel: MethodModel = {
688
+ consistent: (state, request, fixture) => {
689
+ if (fixture.failure !== undefined) {
690
+ return true
691
+ }
692
+
693
+ const destination = folderNamed(state, stringField(fixture.response, 'folder'))
694
+ const requested = stringField(request, 'folder')
695
+
696
+ return (
697
+ booleanField(fixture.response, 'saved') === true &&
698
+ destination !== undefined &&
699
+ (requested === undefined
700
+ ? destination.specialUse === '\\Drafts'
701
+ : destination.name === requested) &&
702
+ messageIn(destination, stringField(fixture.response, 'draftId')) === undefined
703
+ )
704
+ },
705
+ apply: (state, request, fixture) => {
706
+ const destination = folderNamed(state, stringField(fixture.response, 'folder'))
707
+
708
+ if (fixture.failure !== undefined || destination === undefined) {
709
+ return
710
+ }
711
+
712
+ const draft: MessageState = {}
713
+ const draftId = stringField(fixture.response, 'draftId')
714
+ const subject = stringField(objectField(request, 'message'), 'subject')
715
+
716
+ if (draftId !== undefined) draft.id = draftId
717
+
718
+ if (subject !== undefined) draft.subject = subject
719
+
720
+ destination.messages.push(draft)
721
+ }
722
+ }
723
+
724
+ const deletePermanentlyModel: MethodModel = {
725
+ consistent: (state, request, fixture) => {
726
+ if (fixture.failure !== undefined) {
727
+ return true
728
+ }
729
+
730
+ const folder = requestFolder(state, request)
731
+ const results = arrayField(fixture.response, 'results')
732
+
733
+ return (
734
+ folder !== undefined &&
735
+ results !== undefined &&
736
+ results.every(result => {
737
+ const present = messageIn(folder, stringField(result, 'messageId')) !== undefined
738
+ const status = stringField(result, 'status')
739
+
740
+ return status === 'succeeded'
741
+ ? present
742
+ : status !== 'failed' || stringField(result, 'code') !== 'not_found' || !present
743
+ })
744
+ )
745
+ },
746
+ apply: (state, request, fixture) => {
747
+ const folder = requestFolder(state, request)
748
+
749
+ if (fixture.failure !== undefined || folder === undefined) {
750
+ return
751
+ }
752
+
753
+ const deleted = new Set(
754
+ (arrayField(fixture.response, 'results') ?? []).flatMap(result =>
755
+ stringField(result, 'status') === 'succeeded' ? [stringField(result, 'messageId')] : []
756
+ )
757
+ )
758
+
759
+ folder.messages = folder.messages.filter(
760
+ message => message.id === undefined || !deleted.has(message.id)
761
+ )
762
+ }
763
+ }
764
+
765
+ /** The mailbox a saved Sent copy lands in, or `undefined` when none is consistent. */
766
+ const savedCopyFolder = (
767
+ state: MailboxState,
768
+ request: JsonObject,
769
+ fixture: EmailEmulatorFixture
770
+ ): FolderState | undefined => {
771
+ const requestedCopy = objectField(request, 'sentCopy')
772
+
773
+ if (requestedCopy === undefined) {
774
+ return undefined
775
+ }
776
+
777
+ const requestedFolder = stringField(requestedCopy, 'folder')
778
+ const reportedFolder = stringField(objectField(fixture.response, 'sentCopy'), 'folder')
779
+
780
+ const folder =
781
+ requestedFolder === undefined
782
+ ? reportedFolder === undefined
783
+ ? specialUseFolder(state, '\\Sent')
784
+ : folderNamed(state, reportedFolder)
785
+ : folderNamed(state, requestedFolder)
786
+
787
+ if (folder === undefined || (reportedFolder !== undefined && reportedFolder !== folder.name)) {
788
+ return undefined
789
+ }
790
+
791
+ return requestedFolder !== undefined || folder.specialUse === '\\Sent' ? folder : undefined
792
+ }
793
+
794
+ const sentCopyStatus = (fixture: EmailEmulatorFixture) =>
795
+ stringField(objectField(fixture.response, 'sentCopy'), 'status')
796
+
797
+ const sendMessageModel: MethodModel = {
798
+ consistent: (state, request, fixture) => {
799
+ if (fixture.failure !== undefined) {
800
+ return true
801
+ }
802
+
803
+ const status = sentCopyStatus(fixture)
804
+ const requestedCopy = objectField(request, 'sentCopy')
805
+ const requestedFolder = stringField(requestedCopy, 'folder')
806
+
807
+ // A failed copy needs a requested copy; when the request names a folder, that folder must be
808
+ // missing from the mailbox (the only failure the fixtures record).
809
+ const failedConsistent =
810
+ requestedCopy !== undefined &&
811
+ (requestedFolder === undefined || folderNamed(state, requestedFolder) === undefined)
812
+
813
+ return (
814
+ booleanField(fixture.response, 'accepted') === true &&
815
+ (status === 'saved'
816
+ ? savedCopyFolder(state, request, fixture) !== undefined
817
+ : status !== 'failed' || failedConsistent)
818
+ )
819
+ },
820
+ apply: (state, request, fixture) => {
821
+ if (fixture.failure !== undefined || sentCopyStatus(fixture) !== 'saved') {
822
+ return
823
+ }
824
+
825
+ const folder = savedCopyFolder(state, request, fixture)
826
+ const subject = stringField(objectField(request, 'message'), 'subject')
827
+
828
+ folder?.messages.push(subject === undefined ? {} : { subject })
829
+ }
830
+ }
831
+
832
+ const routeKey = (method: string) =>
833
+ emulatorRouteKey(emailEmulatorRouteMethod, `${emailEmulatorPort}.${method}`)
834
+
835
+ const methodModels: ReadonlyMap<string, MethodModel> = new Map([
836
+ [routeKey('listMessages'), listModel],
837
+ [routeKey('listMessagesFiltered'), listModel],
838
+ [routeKey('getMessage'), getModel],
839
+ [routeKey('createDraft'), createDraftModel],
840
+ [routeKey('sendMessage'), sendMessageModel],
841
+ [routeKey('setRead'), flagModel('isRead')],
842
+ [routeKey('setFlag'), flagModel('isFlagged')],
843
+ [
844
+ routeKey('trash'),
845
+ moveModel(
846
+ (state, request) => requestFolder(state, request),
847
+ (_state, request, destination) => {
848
+ const trashFolder = stringField(request, 'trashFolder')
849
+
850
+ return trashFolder === undefined
851
+ ? destination.specialUse === '\\Trash'
852
+ : destination.name === trashFolder
853
+ }
854
+ )
855
+ ],
856
+ [
857
+ routeKey('untrash'),
858
+ moveModel(
859
+ (state, request) => {
860
+ const folder = stringField(request, 'folder')
861
+
862
+ return folder === undefined
863
+ ? specialUseFolder(state, '\\Trash')
864
+ : folderNamed(state, folder)
865
+ },
866
+ (_state, request, destination) =>
867
+ destination.name === stringField(request, 'destinationFolder')
868
+ )
869
+ ],
870
+ [
871
+ routeKey('move'),
872
+ moveModel(
873
+ (state, request) => requestFolder(state, request),
874
+ (_state, request, destination) =>
875
+ destination.name === stringField(request, 'destinationFolder')
876
+ )
877
+ ],
878
+ [routeKey('deletePermanently'), deletePermanentlyModel]
879
+ ])
880
+
881
+ const strict = { onExcessProperty: 'error' } as const
882
+
883
+ const decodeSeed = Schema.decodeUnknownResult(EmailEmulatorSeed, strict)
884
+
885
+ const decodeFault = Schema.decodeUnknownResult(EmailEmulatorFault, strict)
886
+
887
+ const issueText = (error: Schema.SchemaError): string => error.message
888
+
889
+ /** Folder names and message ids (and POP3 ids) must be unique so fixtures address one message. */
890
+ const seedProblem = (seed: EmailEmulatorSeed): string | undefined => {
891
+ const names = seed.folders.map(folder => folder.name)
892
+
893
+ if (new Set(names).size !== names.length) {
894
+ return 'folder names must be unique'
895
+ }
896
+
897
+ for (const folder of seed.folders) {
898
+ const ids = folder.messages.flatMap(message => (message.id === undefined ? [] : [message.id]))
899
+
900
+ const uidls = folder.messages.flatMap(message =>
901
+ message.uidl === undefined ? [] : [message.uidl]
902
+ )
903
+
904
+ if (new Set(ids).size !== ids.length || new Set(uidls).size !== uidls.length) {
905
+ return `message ids in ${folder.name} must be unique`
906
+ }
907
+ }
908
+
909
+ return undefined
910
+ }
911
+
912
+ /**
913
+ * Create an email emulator. Each call has its own mailbox, ledger, faults, and fixture use.
914
+ *
915
+ * `call(method, request)` answers in this order: an unknown method or a non-object request fails
916
+ * closed; the first active matching fault answers its failure; otherwise the fixtures whose method
917
+ * and request match (after the request-shape latitude) are checked against the mailbox, and the
918
+ * first consistent one (preferring one not used since the last reset) answers and updates the
919
+ * mailbox; no match (`no-matching-fixture`) or no consistent match (`state-conflict`) fails closed.
920
+ * Every call is written to the ledger without credential fields.
921
+ *
922
+ * Throws `EmailEmulatorInputInvalid` for an invalid seed.
923
+ */
924
+ export const makeEmailEmulator = (options: EmailEmulatorOptions = {}): EmailEmulator => {
925
+ const seedResult = decodeSeed(options.seed ?? emailEmulatorDefaultSeed)
926
+
927
+ if (Result.isFailure(seedResult)) {
928
+ throw new EmailEmulatorInputInvalid({ input: 'seed', reason: issueText(seedResult.failure) })
929
+ }
930
+
931
+ const seed = seedResult.success
932
+ const problem = seedProblem(seed)
933
+
934
+ if (problem !== undefined) {
935
+ throw new EmailEmulatorInputInvalid({ input: 'seed', reason: problem })
936
+ }
937
+
938
+ const bound = new Map(
939
+ bindRouteHandlers(emailEmulatorRoutes, methodModels).map(({ route, handler }) => [
940
+ route.path.slice(emailEmulatorPort.length + 1),
941
+ { route, model: handler }
942
+ ])
943
+ )
944
+
945
+ let state = mailboxFrom(seed)
946
+ let entries: Array<EmailEmulatorLedgerEntry> = []
947
+
948
+ let faults: Array<{
949
+ id: number
950
+ fault: EmailEmulatorFault
951
+ remaining: number | undefined
952
+ applied: number
953
+ }> = []
954
+
955
+ let nextFaultId = 1
956
+ let nextSeq = 1
957
+ const used = new Set<string>()
958
+
959
+ const record = (entry: Omit<EmailEmulatorLedgerEntry, 'seq'>) => {
960
+ entries.push({ seq: nextSeq, ...entry })
961
+ nextSeq += 1
962
+ }
963
+
964
+ const refuse = (
965
+ method: string,
966
+ request: Schema.Json,
967
+ reason: EmailEmulatorNotEmulatedReason,
968
+ evidence: EmailEmulatorLedgerEntry['evidence']
969
+ ): EmailEmulatorReply => {
970
+ record({ method, request, outcome: 'not-emulated', evidence, reason })
971
+
972
+ return { notEmulated: { reason: notEmulatedText[reason] } }
973
+ }
974
+
975
+ const call = (method: string, rawRequest: Schema.Json): EmailEmulatorReply => {
976
+ const request = scrub(rawRequest, false)
977
+ const target = bound.get(method)
978
+
979
+ if (target === undefined) {
980
+ return refuse(method, request, 'unknown-method', 'unknown-method')
981
+ }
982
+
983
+ const evidence = target.route.evidence
984
+
985
+ if (!isJsonObject(request)) {
986
+ return refuse(method, request, 'invalid-request', evidence)
987
+ }
988
+
989
+ const comparable = scrub(request, true)
990
+
991
+ const matching = emailEmulatorFixtures.filter(
992
+ fixture =>
993
+ fixture.port === emailEmulatorPort &&
994
+ fixture.method === method &&
995
+ Equal.equals(scrub(fixture.request, true), comparable)
996
+ )
997
+
998
+ if (matching.length === 0) {
999
+ return refuse(method, request, 'no-matching-fixture', evidence)
1000
+ }
1001
+
1002
+ const consistent = matching.filter(fixture => target.model.consistent(state, request, fixture))
1003
+ const chosen = consistent.find(fixture => !used.has(fixture.id)) ?? consistent[0]
1004
+
1005
+ if (chosen === undefined) {
1006
+ return refuse(method, request, 'state-conflict', evidence)
1007
+ }
1008
+
1009
+ // Faults apply only to a call a fixture would answer: an unmatched or state-conflicting call
1010
+ // stays refused above and leaves every fault untouched. A fault changes no mailbox state.
1011
+ const fault = faults.find(
1012
+ candidate =>
1013
+ candidate.fault.method === method &&
1014
+ (candidate.remaining === undefined || candidate.remaining > 0) &&
1015
+ (candidate.fault.match === undefined || containsSubset(request, candidate.fault.match))
1016
+ )
1017
+
1018
+ if (fault !== undefined) {
1019
+ fault.applied += 1
1020
+
1021
+ if (fault.remaining !== undefined) {
1022
+ fault.remaining -= 1
1023
+ }
1024
+
1025
+ record({ method, request, outcome: 'fault', evidence, faultId: fault.id })
1026
+
1027
+ return { failure: fault.fault.failure }
1028
+ }
1029
+
1030
+ target.model.apply(state, request, chosen)
1031
+ used.add(chosen.id)
1032
+ record({ method, request, outcome: 'answered', evidence, fixtureId: chosen.id })
1033
+
1034
+ return chosen.failure === undefined
1035
+ ? { response: chosen.response }
1036
+ : { failure: chosen.failure }
1037
+ }
1038
+
1039
+ const faultState = (fault: (typeof faults)[number]): EmailEmulatorFaultState => ({
1040
+ id: fault.id,
1041
+ fault: fault.fault,
1042
+ remaining: fault.remaining,
1043
+ applied: fault.applied
1044
+ })
1045
+
1046
+ return {
1047
+ call,
1048
+ state: () => snapshotOf(state),
1049
+ seed: () => snapshotOf(mailboxFrom(seed)),
1050
+ ledger: {
1051
+ entries: () => entries.map(entry => ({ ...entry })),
1052
+ clear: () => {
1053
+ entries = []
1054
+ }
1055
+ },
1056
+ reset: () => {
1057
+ state = mailboxFrom(seed)
1058
+ entries = []
1059
+ faults = []
1060
+ used.clear()
1061
+ },
1062
+ faults: {
1063
+ add: input => {
1064
+ const decoded = decodeFault(input)
1065
+
1066
+ if (Result.isFailure(decoded)) {
1067
+ throw new EmailEmulatorInputInvalid({
1068
+ input: 'fault',
1069
+ reason: issueText(decoded.failure)
1070
+ })
1071
+ }
1072
+
1073
+ if (!bound.has(decoded.success.method)) {
1074
+ throw new EmailEmulatorInputInvalid({
1075
+ input: 'fault',
1076
+ reason: `method ${decoded.success.method} has no emulated route`
1077
+ })
1078
+ }
1079
+
1080
+ const added = {
1081
+ id: nextFaultId,
1082
+ fault: decoded.success,
1083
+ remaining: decoded.success.count,
1084
+ applied: 0
1085
+ }
1086
+
1087
+ nextFaultId += 1
1088
+ faults.push(added)
1089
+
1090
+ return faultState(added)
1091
+ },
1092
+ list: () => faults.map(faultState),
1093
+ clear: () => {
1094
+ faults = []
1095
+ }
1096
+ },
1097
+ coverage: () => ({
1098
+ routes: emailEmulatorRoutes.map(route => ({
1099
+ ...route,
1100
+ calls: entries.filter(entry => `${emailEmulatorPort}.${entry.method}` === route.path).length
1101
+ })),
1102
+ notEmulatedCalls: entries.filter(entry => entry.outcome === 'not-emulated').length,
1103
+ unusedFixtureIds: emailEmulatorFixtures.flatMap(fixture =>
1104
+ used.has(fixture.id) ? [] : [fixture.id]
1105
+ )
1106
+ })
1107
+ }
1108
+ }