@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.
- package/LICENSE +21 -0
- package/README.md +2135 -0
- package/dist/anthropic.d.mts +269 -0
- package/dist/anthropic.d.mts.map +1 -0
- package/dist/anthropic.mjs +177 -0
- package/dist/anthropic.mjs.map +1 -0
- package/dist/chat-completions.d.mts +288 -0
- package/dist/chat-completions.d.mts.map +1 -0
- package/dist/chat-completions.mjs +451 -0
- package/dist/chat-completions.mjs.map +1 -0
- package/dist/codex.d.mts +265 -0
- package/dist/codex.d.mts.map +1 -0
- package/dist/codex.mjs +199 -0
- package/dist/codex.mjs.map +1 -0
- package/dist/dropbox/api.d.mts +63 -0
- package/dist/dropbox/api.d.mts.map +1 -0
- package/dist/dropbox/api.mjs +565 -0
- package/dist/dropbox/api.mjs.map +1 -0
- package/dist/dropbox/state.d.mts +136 -0
- package/dist/dropbox/state.d.mts.map +1 -0
- package/dist/dropbox/state.mjs +209 -0
- package/dist/dropbox/state.mjs.map +1 -0
- package/dist/dropbox.d.mts +79 -0
- package/dist/dropbox.d.mts.map +1 -0
- package/dist/dropbox.mjs +124 -0
- package/dist/dropbox.mjs.map +1 -0
- package/dist/email-fixtures.d.mts +36 -0
- package/dist/email-fixtures.d.mts.map +1 -0
- package/dist/email-fixtures.mjs +1080 -0
- package/dist/email-fixtures.mjs.map +1 -0
- package/dist/email.d.mts +160 -0
- package/dist/email.d.mts.map +1 -0
- package/dist/email.mjs +608 -0
- package/dist/email.mjs.map +1 -0
- package/dist/emulator-compose.d.mts +40 -0
- package/dist/emulator-compose.d.mts.map +1 -0
- package/dist/emulator-compose.mjs +91 -0
- package/dist/emulator-compose.mjs.map +1 -0
- package/dist/emulator-http.d.mts +53 -0
- package/dist/emulator-http.d.mts.map +1 -0
- package/dist/emulator-http.mjs +117 -0
- package/dist/emulator-http.mjs.map +1 -0
- package/dist/emulator-kernel.d.mts +176 -0
- package/dist/emulator-kernel.d.mts.map +1 -0
- package/dist/emulator-kernel.mjs +413 -0
- package/dist/emulator-kernel.mjs.map +1 -0
- package/dist/fixture-route.d.mts +129 -0
- package/dist/fixture-route.d.mts.map +1 -0
- package/dist/fixture-route.mjs +257 -0
- package/dist/fixture-route.mjs.map +1 -0
- package/dist/fortnox/api.d.mts +92 -0
- package/dist/fortnox/api.d.mts.map +1 -0
- package/dist/fortnox/api.mjs +750 -0
- package/dist/fortnox/api.mjs.map +1 -0
- package/dist/fortnox/state.d.mts +533 -0
- package/dist/fortnox/state.d.mts.map +1 -0
- package/dist/fortnox/state.mjs +612 -0
- package/dist/fortnox/state.mjs.map +1 -0
- package/dist/fortnox.d.mts +128 -0
- package/dist/fortnox.d.mts.map +1 -0
- package/dist/fortnox.mjs +403 -0
- package/dist/fortnox.mjs.map +1 -0
- package/dist/gateway-evaluate-recordings.d.mts +12 -0
- package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
- package/dist/gateway-evaluate-recordings.mjs +128 -0
- package/dist/gateway-evaluate-recordings.mjs.map +1 -0
- package/dist/gateway.d.mts +272 -0
- package/dist/gateway.d.mts.map +1 -0
- package/dist/gateway.mjs +400 -0
- package/dist/gateway.mjs.map +1 -0
- package/dist/github/api.d.mts +63 -0
- package/dist/github/api.d.mts.map +1 -0
- package/dist/github/api.mjs +527 -0
- package/dist/github/api.mjs.map +1 -0
- package/dist/github/state.d.mts +272 -0
- package/dist/github/state.d.mts.map +1 -0
- package/dist/github/state.mjs +303 -0
- package/dist/github/state.mjs.map +1 -0
- package/dist/github.d.mts +77 -0
- package/dist/github.d.mts.map +1 -0
- package/dist/github.mjs +180 -0
- package/dist/github.mjs.map +1 -0
- package/dist/google/calendar.d.mts +10 -0
- package/dist/google/calendar.d.mts.map +1 -0
- package/dist/google/calendar.mjs +252 -0
- package/dist/google/calendar.mjs.map +1 -0
- package/dist/google/drive.d.mts +8 -0
- package/dist/google/drive.d.mts.map +1 -0
- package/dist/google/drive.mjs +252 -0
- package/dist/google/drive.mjs.map +1 -0
- package/dist/google/gmail.d.mts +10 -0
- package/dist/google/gmail.d.mts.map +1 -0
- package/dist/google/gmail.mjs +591 -0
- package/dist/google/gmail.mjs.map +1 -0
- package/dist/google/shared.d.mts +107 -0
- package/dist/google/shared.d.mts.map +1 -0
- package/dist/google/shared.mjs +146 -0
- package/dist/google/shared.mjs.map +1 -0
- package/dist/google/state.d.mts +385 -0
- package/dist/google/state.d.mts.map +1 -0
- package/dist/google/state.mjs +496 -0
- package/dist/google/state.mjs.map +1 -0
- package/dist/google.d.mts +75 -0
- package/dist/google.d.mts.map +1 -0
- package/dist/google.mjs +210 -0
- package/dist/google.mjs.map +1 -0
- package/dist/linkedin-search/api.d.mts +45 -0
- package/dist/linkedin-search/api.d.mts.map +1 -0
- package/dist/linkedin-search/api.mjs +181 -0
- package/dist/linkedin-search/api.mjs.map +1 -0
- package/dist/linkedin-search/state.d.mts +123 -0
- package/dist/linkedin-search/state.d.mts.map +1 -0
- package/dist/linkedin-search/state.mjs +261 -0
- package/dist/linkedin-search/state.mjs.map +1 -0
- package/dist/linkedin-search.d.mts +69 -0
- package/dist/linkedin-search.d.mts.map +1 -0
- package/dist/linkedin-search.mjs +158 -0
- package/dist/linkedin-search.mjs.map +1 -0
- package/dist/mcp/api.d.mts +44 -0
- package/dist/mcp/api.d.mts.map +1 -0
- package/dist/mcp/api.mjs +557 -0
- package/dist/mcp/api.mjs.map +1 -0
- package/dist/mcp/recordings.d.mts +40 -0
- package/dist/mcp/recordings.d.mts.map +1 -0
- package/dist/mcp/recordings.mjs +2390 -0
- package/dist/mcp/recordings.mjs.map +1 -0
- package/dist/mcp/state.d.mts +71 -0
- package/dist/mcp/state.d.mts.map +1 -0
- package/dist/mcp/state.mjs +83 -0
- package/dist/mcp/state.mjs.map +1 -0
- package/dist/mcp.d.mts +84 -0
- package/dist/mcp.d.mts.map +1 -0
- package/dist/mcp.mjs +241 -0
- package/dist/mcp.mjs.map +1 -0
- package/dist/messages.d.mts +183 -0
- package/dist/messages.d.mts.map +1 -0
- package/dist/messages.mjs +532 -0
- package/dist/messages.mjs.map +1 -0
- package/dist/microsoft/api.d.mts +47 -0
- package/dist/microsoft/api.d.mts.map +1 -0
- package/dist/microsoft/api.mjs +178 -0
- package/dist/microsoft/api.mjs.map +1 -0
- package/dist/microsoft/calendar.d.mts +30 -0
- package/dist/microsoft/calendar.d.mts.map +1 -0
- package/dist/microsoft/calendar.mjs +271 -0
- package/dist/microsoft/calendar.mjs.map +1 -0
- package/dist/microsoft/drive.d.mts +36 -0
- package/dist/microsoft/drive.d.mts.map +1 -0
- package/dist/microsoft/drive.mjs +298 -0
- package/dist/microsoft/drive.mjs.map +1 -0
- package/dist/microsoft/graph.d.mts +198 -0
- package/dist/microsoft/graph.d.mts.map +1 -0
- package/dist/microsoft/graph.mjs +264 -0
- package/dist/microsoft/graph.mjs.map +1 -0
- package/dist/microsoft/mail.d.mts +48 -0
- package/dist/microsoft/mail.d.mts.map +1 -0
- package/dist/microsoft/mail.mjs +488 -0
- package/dist/microsoft/mail.mjs.map +1 -0
- package/dist/microsoft/state.d.mts +480 -0
- package/dist/microsoft/state.d.mts.map +1 -0
- package/dist/microsoft/state.mjs +625 -0
- package/dist/microsoft/state.mjs.map +1 -0
- package/dist/microsoft.d.mts +168 -0
- package/dist/microsoft.d.mts.map +1 -0
- package/dist/microsoft.mjs +483 -0
- package/dist/microsoft.mjs.map +1 -0
- package/dist/node.d.mts +47 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +178 -0
- package/dist/node.mjs.map +1 -0
- package/dist/notion/api.d.mts +51 -0
- package/dist/notion/api.d.mts.map +1 -0
- package/dist/notion/api.mjs +507 -0
- package/dist/notion/api.mjs.map +1 -0
- package/dist/notion/state.d.mts +329 -0
- package/dist/notion/state.d.mts.map +1 -0
- package/dist/notion/state.mjs +432 -0
- package/dist/notion/state.mjs.map +1 -0
- package/dist/notion.d.mts +72 -0
- package/dist/notion.d.mts.map +1 -0
- package/dist/notion.mjs +127 -0
- package/dist/notion.mjs.map +1 -0
- package/dist/openai.d.mts +165 -0
- package/dist/openai.d.mts.map +1 -0
- package/dist/openai.mjs +141 -0
- package/dist/openai.mjs.map +1 -0
- package/dist/opencode-recordings.d.mts +7 -0
- package/dist/opencode-recordings.d.mts.map +1 -0
- package/dist/opencode-recordings.mjs +222 -0
- package/dist/opencode-recordings.mjs.map +1 -0
- package/dist/opencode.d.mts +70 -0
- package/dist/opencode.d.mts.map +1 -0
- package/dist/opencode.mjs +217 -0
- package/dist/opencode.mjs.map +1 -0
- package/dist/r2-fixtures.d.mts +36 -0
- package/dist/r2-fixtures.d.mts.map +1 -0
- package/dist/r2-fixtures.mjs +245 -0
- package/dist/r2-fixtures.mjs.map +1 -0
- package/dist/r2-guard.d.mts +43 -0
- package/dist/r2-guard.d.mts.map +1 -0
- package/dist/r2-guard.mjs +215 -0
- package/dist/r2-guard.mjs.map +1 -0
- package/dist/r2.d.mts +166 -0
- package/dist/r2.d.mts.map +1 -0
- package/dist/r2.mjs +517 -0
- package/dist/r2.mjs.map +1 -0
- package/dist/responses.d.mts +231 -0
- package/dist/responses.d.mts.map +1 -0
- package/dist/responses.mjs +556 -0
- package/dist/responses.mjs.map +1 -0
- package/dist/route-evidence.d.mts +45 -0
- package/dist/route-evidence.d.mts.map +1 -0
- package/dist/route-evidence.mjs +56 -0
- package/dist/route-evidence.mjs.map +1 -0
- package/dist/router.d.mts +93 -0
- package/dist/router.d.mts.map +1 -0
- package/dist/router.mjs +259 -0
- package/dist/router.mjs.map +1 -0
- package/dist/stateful-core.d.mts +17 -0
- package/dist/stateful-core.d.mts.map +1 -0
- package/dist/stateful-core.mjs +41 -0
- package/dist/stateful-core.mjs.map +1 -0
- package/dist/stateful-emulator.d.mts +565 -0
- package/dist/stateful-emulator.d.mts.map +1 -0
- package/dist/stateful-emulator.mjs +1228 -0
- package/dist/stateful-emulator.mjs.map +1 -0
- package/dist/stateful-secrets.d.mts +84 -0
- package/dist/stateful-secrets.d.mts.map +1 -0
- package/dist/stateful-secrets.mjs +216 -0
- package/dist/stateful-secrets.mjs.map +1 -0
- package/dist/subscription-usage-recordings.d.mts +9 -0
- package/dist/subscription-usage-recordings.d.mts.map +1 -0
- package/dist/subscription-usage-recordings.mjs +53 -0
- package/dist/subscription-usage-recordings.mjs.map +1 -0
- package/dist/subscription-usage.d.mts +53 -0
- package/dist/subscription-usage.d.mts.map +1 -0
- package/dist/subscription-usage.mjs +21 -0
- package/dist/subscription-usage.mjs.map +1 -0
- package/dist/telegram/api.d.mts +34 -0
- package/dist/telegram/api.d.mts.map +1 -0
- package/dist/telegram/api.mjs +257 -0
- package/dist/telegram/api.mjs.map +1 -0
- package/dist/telegram/state.d.mts +128 -0
- package/dist/telegram/state.d.mts.map +1 -0
- package/dist/telegram/state.mjs +154 -0
- package/dist/telegram/state.mjs.map +1 -0
- package/dist/telegram.d.mts +62 -0
- package/dist/telegram.d.mts.map +1 -0
- package/dist/telegram.mjs +127 -0
- package/dist/telegram.mjs.map +1 -0
- package/dist/todoist/api.d.mts +55 -0
- package/dist/todoist/api.d.mts.map +1 -0
- package/dist/todoist/api.mjs +415 -0
- package/dist/todoist/api.mjs.map +1 -0
- package/dist/todoist/state.d.mts +275 -0
- package/dist/todoist/state.d.mts.map +1 -0
- package/dist/todoist/state.mjs +345 -0
- package/dist/todoist/state.mjs.map +1 -0
- package/dist/todoist.d.mts +68 -0
- package/dist/todoist.d.mts.map +1 -0
- package/dist/todoist.mjs +181 -0
- package/dist/todoist.mjs.map +1 -0
- package/dist/xai.d.mts +267 -0
- package/dist/xai.d.mts.map +1 -0
- package/dist/xai.mjs +252 -0
- package/dist/xai.mjs.map +1 -0
- package/package.json +157 -0
- package/src/anthropic.ts +289 -0
- package/src/chat-completions.ts +924 -0
- package/src/codex.ts +296 -0
- package/src/dropbox/api.ts +1084 -0
- package/src/dropbox/state.ts +364 -0
- package/src/dropbox.ts +203 -0
- package/src/email-fixtures.ts +1297 -0
- package/src/email.ts +1108 -0
- package/src/emulator-compose.ts +147 -0
- package/src/emulator-http.ts +184 -0
- package/src/emulator-kernel.ts +844 -0
- package/src/fixture-route.ts +561 -0
- package/src/fortnox/api.ts +1352 -0
- package/src/fortnox/state.ts +798 -0
- package/src/fortnox.ts +801 -0
- package/src/gateway-evaluate-recordings.ts +153 -0
- package/src/gateway.ts +530 -0
- package/src/github/api.ts +986 -0
- package/src/github/state.ts +439 -0
- package/src/github.ts +271 -0
- package/src/google/calendar.ts +486 -0
- package/src/google/drive.ts +471 -0
- package/src/google/gmail.ts +1011 -0
- package/src/google/shared.ts +321 -0
- package/src/google/state.ts +686 -0
- package/src/google.ts +298 -0
- package/src/linkedin-search/api.ts +363 -0
- package/src/linkedin-search/state.ts +373 -0
- package/src/linkedin-search.ts +241 -0
- package/src/mcp/api.ts +995 -0
- package/src/mcp/recordings.ts +2665 -0
- package/src/mcp/state.ts +125 -0
- package/src/mcp.ts +319 -0
- package/src/messages.ts +844 -0
- package/src/microsoft/api.ts +426 -0
- package/src/microsoft/calendar.ts +491 -0
- package/src/microsoft/drive.ts +541 -0
- package/src/microsoft/graph.ts +550 -0
- package/src/microsoft/mail.ts +833 -0
- package/src/microsoft/state.ts +822 -0
- package/src/microsoft.ts +982 -0
- package/src/node.ts +293 -0
- package/src/notion/api.ts +976 -0
- package/src/notion/state.ts +512 -0
- package/src/notion.ts +210 -0
- package/src/openai.ts +198 -0
- package/src/opencode-recordings.ts +257 -0
- package/src/opencode.ts +271 -0
- package/src/r2-fixtures.ts +295 -0
- package/src/r2-guard.ts +323 -0
- package/src/r2.ts +890 -0
- package/src/responses.ts +901 -0
- package/src/route-evidence.ts +90 -0
- package/src/router.ts +490 -0
- package/src/stateful-core.ts +70 -0
- package/src/stateful-emulator.ts +2571 -0
- package/src/stateful-secrets.ts +299 -0
- package/src/subscription-usage-recordings.ts +77 -0
- package/src/subscription-usage.ts +68 -0
- package/src/telegram/api.ts +437 -0
- package/src/telegram/state.ts +229 -0
- package/src/telegram.ts +227 -0
- package/src/todoist/api.ts +736 -0
- package/src/todoist/state.ts +456 -0
- package/src/todoist.ts +316 -0
- package/src/xai.ts +341 -0
package/dist/email.mjs
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
1
|
+
import { bindRouteHandlers, emulatorRouteKey } from "./route-evidence.mjs";
|
|
2
|
+
import { emailEmulatorFixtures } from "./email-fixtures.mjs";
|
|
3
|
+
import { Data, Equal, Predicate, Result } from "effect";
|
|
4
|
+
import * as Schema from "effect/Schema";
|
|
5
|
+
//#region src/email.ts
|
|
6
|
+
/**
|
|
7
|
+
* Email emulator: a fixture-driven, in-memory fake backend for the generic `EmailClient` port.
|
|
8
|
+
*
|
|
9
|
+
* Yolk never speaks IMAP, POP3, or SMTP; hosts implement `EmailClient`. This emulator stands in for
|
|
10
|
+
* such a host at the port level: `call(method, request)` takes one port call as plain JSON (the
|
|
11
|
+
* credential-free request) and answers `{ response }`, `{ failure }`, or a fail-closed
|
|
12
|
+
* `{ notEmulated }`. There is no socket, TLS, MIME, or mail library here, no `node:` builtin, and
|
|
13
|
+
* no SDK import: it is a plain structural object, and
|
|
14
|
+
* `emailClientLayerFromBackend` in `@yolk-sdk/connectors/email/conformance` turns it into the
|
|
15
|
+
* `EmailClient` layer.
|
|
16
|
+
*
|
|
17
|
+
* Response behaviour comes ONLY from the email conformance fixtures (copied as data in
|
|
18
|
+
* `email-fixtures.ts`). The emulator keeps an in-memory mailbox (folders, messages, flags) seeded
|
|
19
|
+
* with the mailbox the fixtures describe; the state only selects which fixture answers (the first
|
|
20
|
+
* matching fixture consistent with the mailbox) and is updated with what that fixture says
|
|
21
|
+
* happened (flags set, a message moved to the destination id the fixture names, a draft appended,
|
|
22
|
+
* a Sent copy saved). It never invents an id, a flag, or a response.
|
|
23
|
+
*
|
|
24
|
+
* Request-shape latitude (the only one): credential fields are never compared or recorded, and
|
|
25
|
+
* `connection.host` is not compared (the practice host comes from seeds). Every other connection
|
|
26
|
+
* field (`protocol`, `port`, `security`) is compared. Everything else must equal a fixture request
|
|
27
|
+
* exactly. Anything that does not
|
|
28
|
+
* match (an unknown method, no matching fixture, or no fixture consistent with the mailbox state)
|
|
29
|
+
* fails closed with a ledgered `notEmulated` answer, the port analogue of HTTP 400.
|
|
30
|
+
*
|
|
31
|
+
* @experimental
|
|
32
|
+
*/
|
|
33
|
+
/** The port every email fixture and manifest route names. */
|
|
34
|
+
const emailEmulatorPort = "EmailClient";
|
|
35
|
+
/** Manifest routes of port emulators use this pseudo method; `path` is `<Port>.<method>`. */
|
|
36
|
+
const emailEmulatorRouteMethod = "PORT";
|
|
37
|
+
const portRoute = (method, write, caseIds) => ({
|
|
38
|
+
method: emailEmulatorRouteMethod,
|
|
39
|
+
path: `${emailEmulatorPort}.${method}`,
|
|
40
|
+
kind: "connector",
|
|
41
|
+
write,
|
|
42
|
+
caseIds,
|
|
43
|
+
evidence: "unverified",
|
|
44
|
+
observedAt: void 0
|
|
45
|
+
});
|
|
46
|
+
const listAndGet = "email.imap.list-and-get-headers";
|
|
47
|
+
const filteredList = "email.imap.filtered-list-no-fallback";
|
|
48
|
+
const draftsDiscovery = "email.imap.draft-drafts-discovery";
|
|
49
|
+
const setReadAndFlag = "email.imap.set-read-and-flag";
|
|
50
|
+
const trashUntrash = "email.imap.trash-untrash-to-inbox";
|
|
51
|
+
const moveIds = "email.imap.move-destination-ids";
|
|
52
|
+
/**
|
|
53
|
+
* Route evidence manifest: every emulated `EmailClient` method (`PORT EmailClient.<method>`) and the
|
|
54
|
+
* email conformance cases whose (synthetic, unverified) fixtures it follows. Write methods are
|
|
55
|
+
* unverified connector writes: they need a pending entry in the repo's evidence check until an
|
|
56
|
+
* owner-approved live run against a practice mailbox verifies them.
|
|
57
|
+
*/
|
|
58
|
+
const emailEmulatorRoutes = [
|
|
59
|
+
portRoute("listMessages", false, [listAndGet, "email.pop3.rejects-folders-drafts-mutations"]),
|
|
60
|
+
portRoute("listMessagesFiltered", false, [filteredList]),
|
|
61
|
+
portRoute("getMessage", false, [
|
|
62
|
+
listAndGet,
|
|
63
|
+
draftsDiscovery,
|
|
64
|
+
setReadAndFlag,
|
|
65
|
+
trashUntrash,
|
|
66
|
+
moveIds
|
|
67
|
+
]),
|
|
68
|
+
portRoute("createDraft", true, [
|
|
69
|
+
draftsDiscovery,
|
|
70
|
+
trashUntrash,
|
|
71
|
+
moveIds
|
|
72
|
+
]),
|
|
73
|
+
portRoute("sendMessage", true, [
|
|
74
|
+
"email.smtp.sent-copy-statuses",
|
|
75
|
+
"email.smtp.legacy-host-sent-copy",
|
|
76
|
+
"email.smtp.acceptance-not-delivery"
|
|
77
|
+
]),
|
|
78
|
+
portRoute("setRead", true, [setReadAndFlag]),
|
|
79
|
+
portRoute("setFlag", true, [setReadAndFlag]),
|
|
80
|
+
portRoute("trash", true, [trashUntrash]),
|
|
81
|
+
portRoute("untrash", true, [trashUntrash]),
|
|
82
|
+
portRoute("move", true, [moveIds]),
|
|
83
|
+
portRoute("deletePermanently", true, [
|
|
84
|
+
draftsDiscovery,
|
|
85
|
+
trashUntrash,
|
|
86
|
+
moveIds
|
|
87
|
+
])
|
|
88
|
+
];
|
|
89
|
+
const NonEmpty = Schema.String.check(Schema.isNonEmpty());
|
|
90
|
+
/** SPECIAL-USE attributes (RFC 6154) a seeded folder may advertise. */
|
|
91
|
+
const EmailEmulatorSpecialUse = Schema.Literals([
|
|
92
|
+
"\\All",
|
|
93
|
+
"\\Archive",
|
|
94
|
+
"\\Drafts",
|
|
95
|
+
"\\Flagged",
|
|
96
|
+
"\\Junk",
|
|
97
|
+
"\\Sent",
|
|
98
|
+
"\\Trash"
|
|
99
|
+
]);
|
|
100
|
+
/**
|
|
101
|
+
* One message in the emulated mailbox. `id` is the opaque IMAP id (absent for a Sent copy whose
|
|
102
|
+
* id no fixture named), `uidl` the POP3 id of an INBOX message; unknown flags stay absent until a
|
|
103
|
+
* fixture reports them.
|
|
104
|
+
*/
|
|
105
|
+
const EmailEmulatorMessage = Schema.Struct({
|
|
106
|
+
id: Schema.optionalKey(NonEmpty),
|
|
107
|
+
uidl: Schema.optionalKey(NonEmpty),
|
|
108
|
+
subject: Schema.optionalKey(Schema.String),
|
|
109
|
+
isRead: Schema.optionalKey(Schema.Boolean),
|
|
110
|
+
isFlagged: Schema.optionalKey(Schema.Boolean)
|
|
111
|
+
});
|
|
112
|
+
const EmailEmulatorFolder = Schema.Struct({
|
|
113
|
+
name: NonEmpty,
|
|
114
|
+
specialUse: Schema.optionalKey(EmailEmulatorSpecialUse),
|
|
115
|
+
messages: Schema.Array(EmailEmulatorMessage)
|
|
116
|
+
});
|
|
117
|
+
/** The emulated mailbox: plain JSON, used both as the seed and as the `state()` snapshot. */
|
|
118
|
+
const EmailEmulatorSeed = Schema.Struct({ folders: Schema.Array(EmailEmulatorFolder) });
|
|
119
|
+
/** The synthetic practice mailbox the email fixtures describe (the default seed). */
|
|
120
|
+
const emailEmulatorDefaultSeed = { folders: [
|
|
121
|
+
{
|
|
122
|
+
name: "INBOX",
|
|
123
|
+
messages: [{
|
|
124
|
+
id: "1700000001:41",
|
|
125
|
+
uidl: "uidl-synthetic-0041",
|
|
126
|
+
subject: "Synthetic welcome",
|
|
127
|
+
isRead: false,
|
|
128
|
+
isFlagged: false
|
|
129
|
+
}, {
|
|
130
|
+
id: "1700000001:42",
|
|
131
|
+
uidl: "uidl-synthetic-0042",
|
|
132
|
+
subject: "Synthetic weekly summary",
|
|
133
|
+
isRead: true,
|
|
134
|
+
isFlagged: false
|
|
135
|
+
}]
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: "Saved Drafts",
|
|
139
|
+
specialUse: "\\Drafts",
|
|
140
|
+
messages: []
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: "Sent Items",
|
|
144
|
+
specialUse: "\\Sent",
|
|
145
|
+
messages: []
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: "Deleted Items",
|
|
149
|
+
specialUse: "\\Trash",
|
|
150
|
+
messages: []
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "Archive",
|
|
154
|
+
messages: []
|
|
155
|
+
}
|
|
156
|
+
] };
|
|
157
|
+
const Failure = Schema.Struct({
|
|
158
|
+
kind: Schema.Literals(["expected", "error"]),
|
|
159
|
+
code: NonEmpty,
|
|
160
|
+
message: Schema.String,
|
|
161
|
+
status: Schema.optionalKey(Schema.Int)
|
|
162
|
+
});
|
|
163
|
+
/**
|
|
164
|
+
* A fault: answer the next `count` calls (every one when omitted) of `method` whose credential-free
|
|
165
|
+
* request contains `match` (a deep subset: objects by key, arrays element by element, other values
|
|
166
|
+
* exactly) with `failure` instead of a fixture. Faults change no mailbox state.
|
|
167
|
+
*/
|
|
168
|
+
const EmailEmulatorFault = Schema.Struct({
|
|
169
|
+
kind: Schema.Literal("failure"),
|
|
170
|
+
method: NonEmpty,
|
|
171
|
+
match: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
|
|
172
|
+
count: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
|
|
173
|
+
failure: Failure
|
|
174
|
+
});
|
|
175
|
+
/** Thrown by `makeEmailEmulator` (invalid seed) and `faults.add` (invalid fault). */
|
|
176
|
+
var EmailEmulatorInputInvalid = class extends Data.TaggedError("EmailEmulatorInputInvalid") {
|
|
177
|
+
get message() {
|
|
178
|
+
return `Invalid email emulator ${this.input}: ${this.reason}`;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
const notEmulatedText = {
|
|
182
|
+
"unknown-method": "unknown-method: the method has no emulated route",
|
|
183
|
+
"invalid-request": "invalid-request: the request is not a JSON object",
|
|
184
|
+
"no-matching-fixture": "no-matching-fixture: no fixture matches this request",
|
|
185
|
+
"state-conflict": "state-conflict: no matching fixture is consistent with the emulated mailbox"
|
|
186
|
+
};
|
|
187
|
+
const isJsonObject = (value) => value !== void 0 && value !== null && Predicate.isObject(value) && !Array.isArray(value);
|
|
188
|
+
const objectField = (value, key) => {
|
|
189
|
+
const field = isJsonObject(value) ? value[key] : void 0;
|
|
190
|
+
return isJsonObject(field) ? field : void 0;
|
|
191
|
+
};
|
|
192
|
+
const stringField = (value, key) => {
|
|
193
|
+
const field = isJsonObject(value) ? value[key] : void 0;
|
|
194
|
+
return Predicate.isString(field) ? field : void 0;
|
|
195
|
+
};
|
|
196
|
+
const booleanField = (value, key) => {
|
|
197
|
+
const field = isJsonObject(value) ? value[key] : void 0;
|
|
198
|
+
return Predicate.isBoolean(field) ? field : void 0;
|
|
199
|
+
};
|
|
200
|
+
const arrayField = (value, key) => {
|
|
201
|
+
const field = isJsonObject(value) ? value[key] : void 0;
|
|
202
|
+
return Array.isArray(field) ? field : void 0;
|
|
203
|
+
};
|
|
204
|
+
const credentialKeyPattern = /^(?:credentials?|(?:access|refresh|id|auth|api|session|private|bearer|oauth)[_-]?token|token|client[_-]?secret|secret(?:[_-]?key)?|private[_-]?key|password|passwd|api[_-]?key|authorization)$/i;
|
|
205
|
+
/** A copy without credential fields; `ignoreConnectionHost` applies the request-shape latitude. */
|
|
206
|
+
const scrub = (value, ignoreConnectionHost) => {
|
|
207
|
+
if (Array.isArray(value)) return value.map((item) => scrub(item, ignoreConnectionHost));
|
|
208
|
+
if (!isJsonObject(value)) return value;
|
|
209
|
+
const copy = {};
|
|
210
|
+
for (const [key, item] of Object.entries(value)) {
|
|
211
|
+
if (credentialKeyPattern.test(key)) continue;
|
|
212
|
+
if (ignoreConnectionHost && key === "connection" && isJsonObject(item)) {
|
|
213
|
+
const { host: _host, ...connection } = item;
|
|
214
|
+
copy[key] = scrub(connection, ignoreConnectionHost);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
copy[key] = scrub(item, ignoreConnectionHost);
|
|
218
|
+
}
|
|
219
|
+
return copy;
|
|
220
|
+
};
|
|
221
|
+
/** Deep subset: objects by key, arrays element by element (same length), other values exactly. */
|
|
222
|
+
const containsSubset = (value, pattern) => {
|
|
223
|
+
if (value === void 0) return false;
|
|
224
|
+
if (Array.isArray(pattern)) return Array.isArray(value) && value.length === pattern.length && pattern.every((item, index) => containsSubset(value[index], item));
|
|
225
|
+
if (isJsonObject(pattern)) return isJsonObject(value) && Object.entries(pattern).every(([key, item]) => containsSubset(value[key], item));
|
|
226
|
+
return Equal.equals(value, pattern);
|
|
227
|
+
};
|
|
228
|
+
const copyMessage = (message) => {
|
|
229
|
+
const copy = {};
|
|
230
|
+
if (message.id !== void 0) copy.id = message.id;
|
|
231
|
+
if (message.uidl !== void 0) copy.uidl = message.uidl;
|
|
232
|
+
if (message.subject !== void 0) copy.subject = message.subject;
|
|
233
|
+
if (message.isRead !== void 0) copy.isRead = message.isRead;
|
|
234
|
+
if (message.isFlagged !== void 0) copy.isFlagged = message.isFlagged;
|
|
235
|
+
return copy;
|
|
236
|
+
};
|
|
237
|
+
const mailboxFrom = (seed) => ({ folders: seed.folders.map((folder) => folder.specialUse === void 0 ? {
|
|
238
|
+
name: folder.name,
|
|
239
|
+
messages: folder.messages.map(copyMessage)
|
|
240
|
+
} : {
|
|
241
|
+
name: folder.name,
|
|
242
|
+
specialUse: folder.specialUse,
|
|
243
|
+
messages: folder.messages.map(copyMessage)
|
|
244
|
+
}) });
|
|
245
|
+
const snapshotOf = (state) => ({ folders: state.folders.map((folder) => folder.specialUse === void 0 ? {
|
|
246
|
+
name: folder.name,
|
|
247
|
+
messages: folder.messages.map(copyMessage)
|
|
248
|
+
} : {
|
|
249
|
+
name: folder.name,
|
|
250
|
+
specialUse: folder.specialUse,
|
|
251
|
+
messages: folder.messages.map(copyMessage)
|
|
252
|
+
}) });
|
|
253
|
+
const folderNamed = (state, name) => name === void 0 ? void 0 : state.folders.find((folder) => folder.name === name);
|
|
254
|
+
const specialUseFolder = (state, use) => state.folders.find((folder) => folder.specialUse === use);
|
|
255
|
+
const isPop3 = (request) => stringField(objectField(request, "connection"), "protocol") === "pop3";
|
|
256
|
+
const messageIn = (folder, id, pop3 = false) => folder === void 0 || id === void 0 ? void 0 : folder.messages.find((message) => (pop3 ? message.uidl : message.id) === id);
|
|
257
|
+
const flagKeys = ["isRead", "isFlagged"];
|
|
258
|
+
/** Reported flags and subject agree with what the mailbox knows (unknown state agrees). */
|
|
259
|
+
const agrees = (message, reported) => {
|
|
260
|
+
const subject = stringField(reported, "subject");
|
|
261
|
+
return flagKeys.every((key) => {
|
|
262
|
+
const value = booleanField(reported, key);
|
|
263
|
+
return value === void 0 || message[key] === void 0 || message[key] === value;
|
|
264
|
+
}) && (subject === void 0 || message.subject === void 0 || message.subject === subject);
|
|
265
|
+
};
|
|
266
|
+
/** Remember flags and subject the mailbox did not know yet. */
|
|
267
|
+
const learn = (message, reported) => {
|
|
268
|
+
for (const key of flagKeys) {
|
|
269
|
+
const value = booleanField(reported, key);
|
|
270
|
+
if (message[key] === void 0 && value !== void 0) message[key] = value;
|
|
271
|
+
}
|
|
272
|
+
const subject = stringField(reported, "subject");
|
|
273
|
+
if (message.subject === void 0 && subject !== void 0) message.subject = subject;
|
|
274
|
+
};
|
|
275
|
+
const requestFolder = (state, request) => folderNamed(state, stringField(request, "folder") ?? "INBOX");
|
|
276
|
+
/** Every known message that certainly passes the request's read/flagged filters. */
|
|
277
|
+
const certainlyListed = (message, request) => flagKeys.every((key) => {
|
|
278
|
+
const filter = booleanField(request, key);
|
|
279
|
+
return filter === void 0 || message[key] === filter;
|
|
280
|
+
});
|
|
281
|
+
const listModel = {
|
|
282
|
+
consistent: (state, request, fixture) => {
|
|
283
|
+
if (fixture.failure !== void 0) return true;
|
|
284
|
+
const folder = requestFolder(state, request);
|
|
285
|
+
const listed = arrayField(fixture.response, "messages");
|
|
286
|
+
const pop3 = isPop3(request);
|
|
287
|
+
if (folder === void 0 || listed === void 0) return false;
|
|
288
|
+
if (!listed.every((summary) => {
|
|
289
|
+
const message = messageIn(folder, stringField(summary, "id"), pop3);
|
|
290
|
+
return message !== void 0 && agrees(message, summary);
|
|
291
|
+
})) return false;
|
|
292
|
+
if (stringField(fixture.response, "nextCursor") !== void 0) return true;
|
|
293
|
+
const listedIds = new Set(listed.map((summary) => stringField(summary, "id")));
|
|
294
|
+
return folder.messages.every((message) => {
|
|
295
|
+
const id = pop3 ? message.uidl : message.id;
|
|
296
|
+
return id === void 0 || !certainlyListed(message, request) || listedIds.has(id);
|
|
297
|
+
});
|
|
298
|
+
},
|
|
299
|
+
apply: (state, request, fixture) => {
|
|
300
|
+
const folder = requestFolder(state, request);
|
|
301
|
+
const pop3 = isPop3(request);
|
|
302
|
+
for (const summary of arrayField(fixture.response, "messages") ?? []) {
|
|
303
|
+
const message = messageIn(folder, stringField(summary, "id"), pop3);
|
|
304
|
+
if (message !== void 0) learn(message, summary);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
const getModel = {
|
|
309
|
+
consistent: (state, request, fixture) => {
|
|
310
|
+
const id = stringField(request, "messageId");
|
|
311
|
+
const message = messageIn(requestFolder(state, request), id, isPop3(request));
|
|
312
|
+
if (fixture.failure !== void 0) return message === void 0;
|
|
313
|
+
const reported = objectField(fixture.response, "message");
|
|
314
|
+
return message !== void 0 && stringField(reported, "id") === id && agrees(message, reported);
|
|
315
|
+
},
|
|
316
|
+
apply: (state, request, fixture) => {
|
|
317
|
+
const message = messageIn(requestFolder(state, request), stringField(request, "messageId"), isPop3(request));
|
|
318
|
+
if (message !== void 0 && fixture.failure === void 0) learn(message, objectField(fixture.response, "message"));
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
const flagModel = (key) => ({
|
|
322
|
+
consistent: (state, request, fixture) => {
|
|
323
|
+
if (fixture.failure !== void 0) return true;
|
|
324
|
+
const id = stringField(request, "messageId");
|
|
325
|
+
return messageIn(requestFolder(state, request), id) !== void 0 && stringField(fixture.response, "messageId") === id && booleanField(fixture.response, key) === booleanField(request, key);
|
|
326
|
+
},
|
|
327
|
+
apply: (state, request, fixture) => {
|
|
328
|
+
const message = messageIn(requestFolder(state, request), stringField(request, "messageId"));
|
|
329
|
+
const value = booleanField(request, key);
|
|
330
|
+
if (fixture.failure === void 0 && message !== void 0 && value !== void 0) message[key] = value;
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
/** A move-shaped method: where the message comes from and which destination is acceptable. */
|
|
334
|
+
const moveModel = (source, destinationAllowed) => {
|
|
335
|
+
const plan = (state, request, fixture) => {
|
|
336
|
+
const from = source(state, request);
|
|
337
|
+
return {
|
|
338
|
+
from,
|
|
339
|
+
message: messageIn(from, stringField(request, "messageId")),
|
|
340
|
+
destination: folderNamed(state, stringField(fixture.response, "folder")),
|
|
341
|
+
movedId: stringField(fixture.response, "messageId")
|
|
342
|
+
};
|
|
343
|
+
};
|
|
344
|
+
return {
|
|
345
|
+
consistent: (state, request, fixture) => {
|
|
346
|
+
if (fixture.failure !== void 0) return true;
|
|
347
|
+
const { from, message, destination, movedId } = plan(state, request, fixture);
|
|
348
|
+
return booleanField(fixture.response, "moved") === true && from !== void 0 && message !== void 0 && destination !== void 0 && destination !== from && destinationAllowed(state, request, destination) && messageIn(destination, movedId) === void 0;
|
|
349
|
+
},
|
|
350
|
+
apply: (state, request, fixture) => {
|
|
351
|
+
if (fixture.failure !== void 0) return;
|
|
352
|
+
const { from, message, destination, movedId } = plan(state, request, fixture);
|
|
353
|
+
if (from === void 0 || message === void 0 || destination === void 0) return;
|
|
354
|
+
from.messages = from.messages.filter((candidate) => candidate !== message);
|
|
355
|
+
const moved = { ...message };
|
|
356
|
+
delete moved.uidl;
|
|
357
|
+
if (movedId === void 0) delete moved.id;
|
|
358
|
+
else moved.id = movedId;
|
|
359
|
+
destination.messages.push(moved);
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
};
|
|
363
|
+
const createDraftModel = {
|
|
364
|
+
consistent: (state, request, fixture) => {
|
|
365
|
+
if (fixture.failure !== void 0) return true;
|
|
366
|
+
const destination = folderNamed(state, stringField(fixture.response, "folder"));
|
|
367
|
+
const requested = stringField(request, "folder");
|
|
368
|
+
return booleanField(fixture.response, "saved") === true && destination !== void 0 && (requested === void 0 ? destination.specialUse === "\\Drafts" : destination.name === requested) && messageIn(destination, stringField(fixture.response, "draftId")) === void 0;
|
|
369
|
+
},
|
|
370
|
+
apply: (state, request, fixture) => {
|
|
371
|
+
const destination = folderNamed(state, stringField(fixture.response, "folder"));
|
|
372
|
+
if (fixture.failure !== void 0 || destination === void 0) return;
|
|
373
|
+
const draft = {};
|
|
374
|
+
const draftId = stringField(fixture.response, "draftId");
|
|
375
|
+
const subject = stringField(objectField(request, "message"), "subject");
|
|
376
|
+
if (draftId !== void 0) draft.id = draftId;
|
|
377
|
+
if (subject !== void 0) draft.subject = subject;
|
|
378
|
+
destination.messages.push(draft);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
const deletePermanentlyModel = {
|
|
382
|
+
consistent: (state, request, fixture) => {
|
|
383
|
+
if (fixture.failure !== void 0) return true;
|
|
384
|
+
const folder = requestFolder(state, request);
|
|
385
|
+
const results = arrayField(fixture.response, "results");
|
|
386
|
+
return folder !== void 0 && results !== void 0 && results.every((result) => {
|
|
387
|
+
const present = messageIn(folder, stringField(result, "messageId")) !== void 0;
|
|
388
|
+
const status = stringField(result, "status");
|
|
389
|
+
return status === "succeeded" ? present : status !== "failed" || stringField(result, "code") !== "not_found" || !present;
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
apply: (state, request, fixture) => {
|
|
393
|
+
const folder = requestFolder(state, request);
|
|
394
|
+
if (fixture.failure !== void 0 || folder === void 0) return;
|
|
395
|
+
const deleted = new Set((arrayField(fixture.response, "results") ?? []).flatMap((result) => stringField(result, "status") === "succeeded" ? [stringField(result, "messageId")] : []));
|
|
396
|
+
folder.messages = folder.messages.filter((message) => message.id === void 0 || !deleted.has(message.id));
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
/** The mailbox a saved Sent copy lands in, or `undefined` when none is consistent. */
|
|
400
|
+
const savedCopyFolder = (state, request, fixture) => {
|
|
401
|
+
const requestedCopy = objectField(request, "sentCopy");
|
|
402
|
+
if (requestedCopy === void 0) return;
|
|
403
|
+
const requestedFolder = stringField(requestedCopy, "folder");
|
|
404
|
+
const reportedFolder = stringField(objectField(fixture.response, "sentCopy"), "folder");
|
|
405
|
+
const folder = requestedFolder === void 0 ? reportedFolder === void 0 ? specialUseFolder(state, "\\Sent") : folderNamed(state, reportedFolder) : folderNamed(state, requestedFolder);
|
|
406
|
+
if (folder === void 0 || reportedFolder !== void 0 && reportedFolder !== folder.name) return;
|
|
407
|
+
return requestedFolder !== void 0 || folder.specialUse === "\\Sent" ? folder : void 0;
|
|
408
|
+
};
|
|
409
|
+
const sentCopyStatus = (fixture) => stringField(objectField(fixture.response, "sentCopy"), "status");
|
|
410
|
+
const sendMessageModel = {
|
|
411
|
+
consistent: (state, request, fixture) => {
|
|
412
|
+
if (fixture.failure !== void 0) return true;
|
|
413
|
+
const status = sentCopyStatus(fixture);
|
|
414
|
+
const requestedCopy = objectField(request, "sentCopy");
|
|
415
|
+
const requestedFolder = stringField(requestedCopy, "folder");
|
|
416
|
+
const failedConsistent = requestedCopy !== void 0 && (requestedFolder === void 0 || folderNamed(state, requestedFolder) === void 0);
|
|
417
|
+
return booleanField(fixture.response, "accepted") === true && (status === "saved" ? savedCopyFolder(state, request, fixture) !== void 0 : status !== "failed" || failedConsistent);
|
|
418
|
+
},
|
|
419
|
+
apply: (state, request, fixture) => {
|
|
420
|
+
if (fixture.failure !== void 0 || sentCopyStatus(fixture) !== "saved") return;
|
|
421
|
+
const folder = savedCopyFolder(state, request, fixture);
|
|
422
|
+
const subject = stringField(objectField(request, "message"), "subject");
|
|
423
|
+
folder?.messages.push(subject === void 0 ? {} : { subject });
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
const routeKey = (method) => emulatorRouteKey(emailEmulatorRouteMethod, `${emailEmulatorPort}.${method}`);
|
|
427
|
+
const methodModels = new Map([
|
|
428
|
+
[routeKey("listMessages"), listModel],
|
|
429
|
+
[routeKey("listMessagesFiltered"), listModel],
|
|
430
|
+
[routeKey("getMessage"), getModel],
|
|
431
|
+
[routeKey("createDraft"), createDraftModel],
|
|
432
|
+
[routeKey("sendMessage"), sendMessageModel],
|
|
433
|
+
[routeKey("setRead"), flagModel("isRead")],
|
|
434
|
+
[routeKey("setFlag"), flagModel("isFlagged")],
|
|
435
|
+
[routeKey("trash"), moveModel((state, request) => requestFolder(state, request), (_state, request, destination) => {
|
|
436
|
+
const trashFolder = stringField(request, "trashFolder");
|
|
437
|
+
return trashFolder === void 0 ? destination.specialUse === "\\Trash" : destination.name === trashFolder;
|
|
438
|
+
})],
|
|
439
|
+
[routeKey("untrash"), moveModel((state, request) => {
|
|
440
|
+
const folder = stringField(request, "folder");
|
|
441
|
+
return folder === void 0 ? specialUseFolder(state, "\\Trash") : folderNamed(state, folder);
|
|
442
|
+
}, (_state, request, destination) => destination.name === stringField(request, "destinationFolder"))],
|
|
443
|
+
[routeKey("move"), moveModel((state, request) => requestFolder(state, request), (_state, request, destination) => destination.name === stringField(request, "destinationFolder"))],
|
|
444
|
+
[routeKey("deletePermanently"), deletePermanentlyModel]
|
|
445
|
+
]);
|
|
446
|
+
const strict = { onExcessProperty: "error" };
|
|
447
|
+
const decodeSeed = Schema.decodeUnknownResult(EmailEmulatorSeed, strict);
|
|
448
|
+
const decodeFault = Schema.decodeUnknownResult(EmailEmulatorFault, strict);
|
|
449
|
+
const issueText = (error) => error.message;
|
|
450
|
+
/** Folder names and message ids (and POP3 ids) must be unique so fixtures address one message. */
|
|
451
|
+
const seedProblem = (seed) => {
|
|
452
|
+
const names = seed.folders.map((folder) => folder.name);
|
|
453
|
+
if (new Set(names).size !== names.length) return "folder names must be unique";
|
|
454
|
+
for (const folder of seed.folders) {
|
|
455
|
+
const ids = folder.messages.flatMap((message) => message.id === void 0 ? [] : [message.id]);
|
|
456
|
+
const uidls = folder.messages.flatMap((message) => message.uidl === void 0 ? [] : [message.uidl]);
|
|
457
|
+
if (new Set(ids).size !== ids.length || new Set(uidls).size !== uidls.length) return `message ids in ${folder.name} must be unique`;
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
/**
|
|
461
|
+
* Create an email emulator. Each call has its own mailbox, ledger, faults, and fixture use.
|
|
462
|
+
*
|
|
463
|
+
* `call(method, request)` answers in this order: an unknown method or a non-object request fails
|
|
464
|
+
* closed; the first active matching fault answers its failure; otherwise the fixtures whose method
|
|
465
|
+
* and request match (after the request-shape latitude) are checked against the mailbox, and the
|
|
466
|
+
* first consistent one (preferring one not used since the last reset) answers and updates the
|
|
467
|
+
* mailbox; no match (`no-matching-fixture`) or no consistent match (`state-conflict`) fails closed.
|
|
468
|
+
* Every call is written to the ledger without credential fields.
|
|
469
|
+
*
|
|
470
|
+
* Throws `EmailEmulatorInputInvalid` for an invalid seed.
|
|
471
|
+
*/
|
|
472
|
+
const makeEmailEmulator = (options = {}) => {
|
|
473
|
+
const seedResult = decodeSeed(options.seed ?? emailEmulatorDefaultSeed);
|
|
474
|
+
if (Result.isFailure(seedResult)) throw new EmailEmulatorInputInvalid({
|
|
475
|
+
input: "seed",
|
|
476
|
+
reason: issueText(seedResult.failure)
|
|
477
|
+
});
|
|
478
|
+
const seed = seedResult.success;
|
|
479
|
+
const problem = seedProblem(seed);
|
|
480
|
+
if (problem !== void 0) throw new EmailEmulatorInputInvalid({
|
|
481
|
+
input: "seed",
|
|
482
|
+
reason: problem
|
|
483
|
+
});
|
|
484
|
+
const bound = new Map(bindRouteHandlers(emailEmulatorRoutes, methodModels).map(({ route, handler }) => [route.path.slice(12), {
|
|
485
|
+
route,
|
|
486
|
+
model: handler
|
|
487
|
+
}]));
|
|
488
|
+
let state = mailboxFrom(seed);
|
|
489
|
+
let entries = [];
|
|
490
|
+
let faults = [];
|
|
491
|
+
let nextFaultId = 1;
|
|
492
|
+
let nextSeq = 1;
|
|
493
|
+
const used = /* @__PURE__ */ new Set();
|
|
494
|
+
const record = (entry) => {
|
|
495
|
+
entries.push({
|
|
496
|
+
seq: nextSeq,
|
|
497
|
+
...entry
|
|
498
|
+
});
|
|
499
|
+
nextSeq += 1;
|
|
500
|
+
};
|
|
501
|
+
const refuse = (method, request, reason, evidence) => {
|
|
502
|
+
record({
|
|
503
|
+
method,
|
|
504
|
+
request,
|
|
505
|
+
outcome: "not-emulated",
|
|
506
|
+
evidence,
|
|
507
|
+
reason
|
|
508
|
+
});
|
|
509
|
+
return { notEmulated: { reason: notEmulatedText[reason] } };
|
|
510
|
+
};
|
|
511
|
+
const call = (method, rawRequest) => {
|
|
512
|
+
const request = scrub(rawRequest, false);
|
|
513
|
+
const target = bound.get(method);
|
|
514
|
+
if (target === void 0) return refuse(method, request, "unknown-method", "unknown-method");
|
|
515
|
+
const evidence = target.route.evidence;
|
|
516
|
+
if (!isJsonObject(request)) return refuse(method, request, "invalid-request", evidence);
|
|
517
|
+
const comparable = scrub(request, true);
|
|
518
|
+
const matching = emailEmulatorFixtures.filter((fixture) => fixture.port === "EmailClient" && fixture.method === method && Equal.equals(scrub(fixture.request, true), comparable));
|
|
519
|
+
if (matching.length === 0) return refuse(method, request, "no-matching-fixture", evidence);
|
|
520
|
+
const consistent = matching.filter((fixture) => target.model.consistent(state, request, fixture));
|
|
521
|
+
const chosen = consistent.find((fixture) => !used.has(fixture.id)) ?? consistent[0];
|
|
522
|
+
if (chosen === void 0) return refuse(method, request, "state-conflict", evidence);
|
|
523
|
+
const fault = faults.find((candidate) => candidate.fault.method === method && (candidate.remaining === void 0 || candidate.remaining > 0) && (candidate.fault.match === void 0 || containsSubset(request, candidate.fault.match)));
|
|
524
|
+
if (fault !== void 0) {
|
|
525
|
+
fault.applied += 1;
|
|
526
|
+
if (fault.remaining !== void 0) fault.remaining -= 1;
|
|
527
|
+
record({
|
|
528
|
+
method,
|
|
529
|
+
request,
|
|
530
|
+
outcome: "fault",
|
|
531
|
+
evidence,
|
|
532
|
+
faultId: fault.id
|
|
533
|
+
});
|
|
534
|
+
return { failure: fault.fault.failure };
|
|
535
|
+
}
|
|
536
|
+
target.model.apply(state, request, chosen);
|
|
537
|
+
used.add(chosen.id);
|
|
538
|
+
record({
|
|
539
|
+
method,
|
|
540
|
+
request,
|
|
541
|
+
outcome: "answered",
|
|
542
|
+
evidence,
|
|
543
|
+
fixtureId: chosen.id
|
|
544
|
+
});
|
|
545
|
+
return chosen.failure === void 0 ? { response: chosen.response } : { failure: chosen.failure };
|
|
546
|
+
};
|
|
547
|
+
const faultState = (fault) => ({
|
|
548
|
+
id: fault.id,
|
|
549
|
+
fault: fault.fault,
|
|
550
|
+
remaining: fault.remaining,
|
|
551
|
+
applied: fault.applied
|
|
552
|
+
});
|
|
553
|
+
return {
|
|
554
|
+
call,
|
|
555
|
+
state: () => snapshotOf(state),
|
|
556
|
+
seed: () => snapshotOf(mailboxFrom(seed)),
|
|
557
|
+
ledger: {
|
|
558
|
+
entries: () => entries.map((entry) => ({ ...entry })),
|
|
559
|
+
clear: () => {
|
|
560
|
+
entries = [];
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
reset: () => {
|
|
564
|
+
state = mailboxFrom(seed);
|
|
565
|
+
entries = [];
|
|
566
|
+
faults = [];
|
|
567
|
+
used.clear();
|
|
568
|
+
},
|
|
569
|
+
faults: {
|
|
570
|
+
add: (input) => {
|
|
571
|
+
const decoded = decodeFault(input);
|
|
572
|
+
if (Result.isFailure(decoded)) throw new EmailEmulatorInputInvalid({
|
|
573
|
+
input: "fault",
|
|
574
|
+
reason: issueText(decoded.failure)
|
|
575
|
+
});
|
|
576
|
+
if (!bound.has(decoded.success.method)) throw new EmailEmulatorInputInvalid({
|
|
577
|
+
input: "fault",
|
|
578
|
+
reason: `method ${decoded.success.method} has no emulated route`
|
|
579
|
+
});
|
|
580
|
+
const added = {
|
|
581
|
+
id: nextFaultId,
|
|
582
|
+
fault: decoded.success,
|
|
583
|
+
remaining: decoded.success.count,
|
|
584
|
+
applied: 0
|
|
585
|
+
};
|
|
586
|
+
nextFaultId += 1;
|
|
587
|
+
faults.push(added);
|
|
588
|
+
return faultState(added);
|
|
589
|
+
},
|
|
590
|
+
list: () => faults.map(faultState),
|
|
591
|
+
clear: () => {
|
|
592
|
+
faults = [];
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
coverage: () => ({
|
|
596
|
+
routes: emailEmulatorRoutes.map((route) => ({
|
|
597
|
+
...route,
|
|
598
|
+
calls: entries.filter((entry) => `${emailEmulatorPort}.${entry.method}` === route.path).length
|
|
599
|
+
})),
|
|
600
|
+
notEmulatedCalls: entries.filter((entry) => entry.outcome === "not-emulated").length,
|
|
601
|
+
unusedFixtureIds: emailEmulatorFixtures.flatMap((fixture) => used.has(fixture.id) ? [] : [fixture.id])
|
|
602
|
+
})
|
|
603
|
+
};
|
|
604
|
+
};
|
|
605
|
+
//#endregion
|
|
606
|
+
export { EmailEmulatorFault, EmailEmulatorFolder, EmailEmulatorInputInvalid, EmailEmulatorMessage, EmailEmulatorSeed, EmailEmulatorSpecialUse, emailEmulatorDefaultSeed, emailEmulatorFixtures, emailEmulatorPort, emailEmulatorRouteMethod, emailEmulatorRoutes, makeEmailEmulator };
|
|
607
|
+
|
|
608
|
+
//# sourceMappingURL=email.mjs.map
|