@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
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gmail routes of the Google emulator (internal): message listing by label with page tokens,
|
|
3
|
+
* message reads in the recorded formats, attachments, the label lifecycle, label changes, trash and
|
|
4
|
+
* untrash, the draft lifecycle with its thread, and the multipart practice send.
|
|
5
|
+
*
|
|
6
|
+
* Every answer comes from the Gmail fixtures, through the seed or the request, or is minted. The
|
|
7
|
+
* practice send is irreversible on the real service: here it only records the sent message in the
|
|
8
|
+
* state (nothing is delivered anywhere), and only a message whose sole recipient is the seeded
|
|
9
|
+
* practice address, exactly as the send fixture records it, is accepted.
|
|
10
|
+
*
|
|
11
|
+
* @experimental
|
|
12
|
+
*/
|
|
13
|
+
import { Predicate } from 'effect'
|
|
14
|
+
import type * as Schema from 'effect/Schema'
|
|
15
|
+
import {
|
|
16
|
+
DecodedViewRefusal,
|
|
17
|
+
exactBodyKeys,
|
|
18
|
+
exactQuery,
|
|
19
|
+
isJsonObject,
|
|
20
|
+
isNotEmulated,
|
|
21
|
+
mediaType,
|
|
22
|
+
notEmulated,
|
|
23
|
+
parseJsonText,
|
|
24
|
+
statefulRoute,
|
|
25
|
+
type NotEmulated
|
|
26
|
+
} from '../stateful-emulator.ts'
|
|
27
|
+
import {
|
|
28
|
+
answer,
|
|
29
|
+
evidence,
|
|
30
|
+
gmailIdSegment,
|
|
31
|
+
googleEmulatorGmailOrigin,
|
|
32
|
+
googleErrorBody,
|
|
33
|
+
googleJson,
|
|
34
|
+
googleNoContent,
|
|
35
|
+
isRunText,
|
|
36
|
+
listPage,
|
|
37
|
+
pageSize,
|
|
38
|
+
param,
|
|
39
|
+
recordedInternalDate,
|
|
40
|
+
recordedRunIdLength,
|
|
41
|
+
recordedValue,
|
|
42
|
+
runIdOf,
|
|
43
|
+
withoutQuery,
|
|
44
|
+
type GoogleApiEnv,
|
|
45
|
+
type GoogleRoute
|
|
46
|
+
} from './shared.ts'
|
|
47
|
+
import {
|
|
48
|
+
base64UrlOfText,
|
|
49
|
+
lastLabelNumber,
|
|
50
|
+
mintedLabelIdPattern,
|
|
51
|
+
mintedDraftId,
|
|
52
|
+
mintedMessageId,
|
|
53
|
+
mintedMessageIdPattern,
|
|
54
|
+
pad,
|
|
55
|
+
type GoogleEmulatorGmailMessage,
|
|
56
|
+
type GoogleEmulatorImpliedMessage,
|
|
57
|
+
type GoogleEmulatorState
|
|
58
|
+
} from './state.ts'
|
|
59
|
+
|
|
60
|
+
/** Path prefix of every Gmail API route (`/gmail/v1/users/me`). */
|
|
61
|
+
export const gmailApiPath = '/gmail/v1/users/me'
|
|
62
|
+
|
|
63
|
+
const listCase = 'google.gmail.list-page-token'
|
|
64
|
+
|
|
65
|
+
const attachmentCase = 'google.gmail.attachment-base64url'
|
|
66
|
+
|
|
67
|
+
const notFoundCase = 'google.gmail.not-found-envelope'
|
|
68
|
+
|
|
69
|
+
const labelCase = 'google.gmail.label-create-apply-delete'
|
|
70
|
+
|
|
71
|
+
const draftCase = 'google.gmail.draft-compose-update-delete'
|
|
72
|
+
|
|
73
|
+
const trashCase = 'google.gmail.trash-untrash'
|
|
74
|
+
|
|
75
|
+
const sendCase = 'google.gmail.send-practice-address'
|
|
76
|
+
|
|
77
|
+
const gmail = (
|
|
78
|
+
method: string,
|
|
79
|
+
path: string,
|
|
80
|
+
write: boolean,
|
|
81
|
+
caseIds: ReadonlyArray<string>,
|
|
82
|
+
params: ReadonlyArray<string> = []
|
|
83
|
+
) =>
|
|
84
|
+
evidence(
|
|
85
|
+
googleEmulatorGmailOrigin,
|
|
86
|
+
method,
|
|
87
|
+
`${gmailApiPath}${path}`,
|
|
88
|
+
write,
|
|
89
|
+
caseIds,
|
|
90
|
+
Object.fromEntries(params.map(name => [name, gmailIdSegment]))
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
// Recorded constants and renderings.
|
|
94
|
+
|
|
95
|
+
/** The Gmail not-found envelope (`messages`, `labels`, and `drafts` reads and deletes). */
|
|
96
|
+
const notFound = (env: GoogleApiEnv): Response =>
|
|
97
|
+
googleJson(
|
|
98
|
+
404,
|
|
99
|
+
env.drills.notFoundWithoutMessage
|
|
100
|
+
? { error: { code: 404, status: 'NOT_FOUND' } }
|
|
101
|
+
: googleErrorBody(404, 'Requested entity was not found.', 'notFound', 'NOT_FOUND')
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
/** A message id of the recorded absent form (16 lower-case hex digits). */
|
|
105
|
+
const absentMessageIdPattern = /^[0-9a-f]{16}$/
|
|
106
|
+
|
|
107
|
+
const draftIdPattern = /^r-\d{1,20}$/
|
|
108
|
+
|
|
109
|
+
/** The draft texts the draft fixture records (non-ASCII on purpose). */
|
|
110
|
+
const draftText = 'Synthetic conformance draft, safe to delete: grüße ✓'
|
|
111
|
+
|
|
112
|
+
const updatedDraftText = 'Updated synthetic conformance draft: ¡hola! ✓'
|
|
113
|
+
|
|
114
|
+
const draftSubjectRest = 'synthetic conformance draft, safe to delete'
|
|
115
|
+
|
|
116
|
+
const sendSubjectRest = 'synthetic conformance message, safe to delete'
|
|
117
|
+
|
|
118
|
+
/** The practice address the send fixture records (the sender and the only recipient). */
|
|
119
|
+
const recordedPracticeAddress = 'practice@example.test'
|
|
120
|
+
|
|
121
|
+
/** Why a draft or send subject with a run id of another length is not emulated. */
|
|
122
|
+
const sizedRunIdReason = (what: 'draft' | 'send'): string =>
|
|
123
|
+
`a ${what} subject whose run id is not ${recordedRunIdLength} characters long (the fixtures' run-synthetic) is not emulated: the recorded sizeEstimate covers the subject`
|
|
124
|
+
|
|
125
|
+
/** The 7-bit MIME the send fixture records for `practiceAddress` and `subject`. */
|
|
126
|
+
const practiceMime = (practiceAddress: string, subject: string): string =>
|
|
127
|
+
[
|
|
128
|
+
`To: ${practiceAddress}`,
|
|
129
|
+
`Subject: ${subject}`,
|
|
130
|
+
'MIME-Version: 1.0',
|
|
131
|
+
'Content-Type: text/plain; charset=us-ascii',
|
|
132
|
+
'',
|
|
133
|
+
'Synthetic conformance message sent to the seeded practice address only. Safe to delete.',
|
|
134
|
+
''
|
|
135
|
+
].join('\r\n')
|
|
136
|
+
|
|
137
|
+
/** The `multipart/related` upload body the send fixture records. */
|
|
138
|
+
const uploadBody = (boundary: string, mime: string): string =>
|
|
139
|
+
[
|
|
140
|
+
`--${boundary}`,
|
|
141
|
+
'Content-Type: application/json; charset=UTF-8',
|
|
142
|
+
'',
|
|
143
|
+
'{}',
|
|
144
|
+
`--${boundary}`,
|
|
145
|
+
'Content-Type: message/rfc822',
|
|
146
|
+
'',
|
|
147
|
+
mime,
|
|
148
|
+
`--${boundary}--`
|
|
149
|
+
].join('\r\n')
|
|
150
|
+
|
|
151
|
+
type MessageFormat = 'minimal' | 'metadata' | 'full'
|
|
152
|
+
|
|
153
|
+
const head = (message: GoogleEmulatorGmailMessage): Schema.JsonObject => ({
|
|
154
|
+
id: message.id,
|
|
155
|
+
threadId: message.threadId,
|
|
156
|
+
labelIds: [...message.labelIds],
|
|
157
|
+
snippet: message.snippet,
|
|
158
|
+
sizeEstimate: message.sizeEstimate,
|
|
159
|
+
historyId: message.historyId,
|
|
160
|
+
internalDate: message.internalDate
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
/** The recorded rendering of `message` in `format`, or `undefined` when none is recorded. */
|
|
164
|
+
const renderMessage = (
|
|
165
|
+
message: GoogleEmulatorGmailMessage,
|
|
166
|
+
format: MessageFormat
|
|
167
|
+
): Schema.JsonObject | undefined => {
|
|
168
|
+
switch (format) {
|
|
169
|
+
case 'minimal':
|
|
170
|
+
return message.minimal ? head(message) : undefined
|
|
171
|
+
case 'metadata':
|
|
172
|
+
return message.metadataPayload === null
|
|
173
|
+
? undefined
|
|
174
|
+
: { ...head(message), payload: message.metadataPayload }
|
|
175
|
+
case 'full':
|
|
176
|
+
return message.fullPayload === null
|
|
177
|
+
? undefined
|
|
178
|
+
: { ...head(message), payload: message.fullPayload }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The `{ id, threadId, labelIds }` answer of a label change, trash, or untrash. */
|
|
183
|
+
const labelsAnswer = (
|
|
184
|
+
message: GoogleEmulatorGmailMessage,
|
|
185
|
+
labelIds: ReadonlyArray<string>
|
|
186
|
+
): Response =>
|
|
187
|
+
googleJson(200, { id: message.id, threadId: message.threadId, labelIds: [...labelIds] })
|
|
188
|
+
|
|
189
|
+
const findMessage = (state: GoogleEmulatorState, id: string) =>
|
|
190
|
+
state.messages.find(message => message.id === id)
|
|
191
|
+
|
|
192
|
+
const isImpliedMessage = (state: GoogleEmulatorState, id: string): boolean =>
|
|
193
|
+
state.impliedMessages.some(message => message.id === id)
|
|
194
|
+
|
|
195
|
+
const isDraftMessage = (message: GoogleEmulatorGmailMessage): boolean =>
|
|
196
|
+
message.labelIds.includes('DRAFT')
|
|
197
|
+
|
|
198
|
+
const replaceMessage = (
|
|
199
|
+
state: GoogleEmulatorState,
|
|
200
|
+
next: GoogleEmulatorGmailMessage
|
|
201
|
+
): ReadonlyArray<GoogleEmulatorGmailMessage> =>
|
|
202
|
+
state.messages.map(message => (message.id === next.id ? next : message))
|
|
203
|
+
|
|
204
|
+
// Listing by label.
|
|
205
|
+
|
|
206
|
+
type MessagesPageHead = {
|
|
207
|
+
readonly messages: ReadonlyArray<Schema.Json>
|
|
208
|
+
nextPageToken?: string
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
type ListInput = {
|
|
212
|
+
readonly labelId: string
|
|
213
|
+
readonly maxResults: number
|
|
214
|
+
readonly pageToken: string | undefined
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const listMessages: GoogleRoute = statefulRoute(
|
|
218
|
+
gmail('GET', '/messages', false, [listCase]),
|
|
219
|
+
'none',
|
|
220
|
+
(request): ListInput | NotEmulated => {
|
|
221
|
+
const query = exactQuery(request, ['labelIds', 'maxResults'], ['pageToken'])
|
|
222
|
+
|
|
223
|
+
if (isNotEmulated(query)) return query
|
|
224
|
+
|
|
225
|
+
const labelId = query['labelIds'] ?? ''
|
|
226
|
+
|
|
227
|
+
if (!gmailIdSegment.test(labelId)) return notEmulated('labelIds must be one label id')
|
|
228
|
+
|
|
229
|
+
const maxResults = pageSize(query['maxResults'], 'maxResults', 1, 500)
|
|
230
|
+
|
|
231
|
+
if (isNotEmulated(maxResults)) return maxResults
|
|
232
|
+
|
|
233
|
+
const pageToken = query['pageToken']
|
|
234
|
+
|
|
235
|
+
return pageToken === ''
|
|
236
|
+
? notEmulated('an empty pageToken is not emulated')
|
|
237
|
+
: { labelId, maxResults, pageToken }
|
|
238
|
+
},
|
|
239
|
+
(state, input, { env }) => {
|
|
240
|
+
const known =
|
|
241
|
+
state.impliedLabelIds.includes(input.labelId) ||
|
|
242
|
+
state.labels.some(label => label.id === input.labelId)
|
|
243
|
+
|
|
244
|
+
if (!known) return notEmulated('listing a label the state does not hold is not emulated')
|
|
245
|
+
|
|
246
|
+
const matches: ReadonlyArray<GoogleEmulatorGmailMessage | GoogleEmulatorImpliedMessage> = [
|
|
247
|
+
...state.messages,
|
|
248
|
+
...state.impliedMessages
|
|
249
|
+
].filter(message => message.labelIds.includes(input.labelId))
|
|
250
|
+
|
|
251
|
+
// Every recorded listing has results; an empty one is no fixture's answer.
|
|
252
|
+
if (matches.length === 0) {
|
|
253
|
+
return notEmulated('a listing without messages is not emulated (no fixture records one)')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (matches.some(message => message.labelIds.some(id => id === 'TRASH' || id === 'SPAM'))) {
|
|
257
|
+
return notEmulated('a listing that would leave out messages in Trash or Spam is not emulated')
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return listPage(
|
|
261
|
+
env,
|
|
262
|
+
'gmail',
|
|
263
|
+
`gmail\u0000${input.labelId}\u0000${input.maxResults}`,
|
|
264
|
+
matches.map(message => ({ id: message.id, threadId: message.threadId })),
|
|
265
|
+
input.maxResults,
|
|
266
|
+
input.pageToken,
|
|
267
|
+
env.drills.gmailPageRepeats,
|
|
268
|
+
(page, nextPageToken) => {
|
|
269
|
+
const head: MessagesPageHead = { messages: [...page] }
|
|
270
|
+
|
|
271
|
+
// The last page carries no `nextPageToken`.
|
|
272
|
+
if (nextPageToken !== undefined) head.nextPageToken = nextPageToken
|
|
273
|
+
|
|
274
|
+
return googleJson(200, { ...head, resultSizeEstimate: matches.length })
|
|
275
|
+
}
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
// Message reads.
|
|
281
|
+
|
|
282
|
+
type MessageRead = { readonly id: string; readonly format: MessageFormat }
|
|
283
|
+
|
|
284
|
+
const formats: ReadonlyArray<string> = ['minimal', 'metadata', 'full']
|
|
285
|
+
|
|
286
|
+
const isFormat = (value: string | undefined): value is MessageFormat =>
|
|
287
|
+
value !== undefined && formats.includes(value)
|
|
288
|
+
|
|
289
|
+
const getMessage: GoogleRoute = statefulRoute(
|
|
290
|
+
gmail(
|
|
291
|
+
'GET',
|
|
292
|
+
'/messages/{messageId}',
|
|
293
|
+
false,
|
|
294
|
+
[attachmentCase, notFoundCase, labelCase, draftCase, trashCase, sendCase],
|
|
295
|
+
['messageId']
|
|
296
|
+
),
|
|
297
|
+
'none',
|
|
298
|
+
(request): MessageRead | NotEmulated => {
|
|
299
|
+
const query = exactQuery(request, ['format'])
|
|
300
|
+
|
|
301
|
+
if (isNotEmulated(query)) return query
|
|
302
|
+
|
|
303
|
+
const format = query['format']
|
|
304
|
+
|
|
305
|
+
return isFormat(format)
|
|
306
|
+
? { id: param(request, 'messageId'), format }
|
|
307
|
+
: notEmulated('format must be minimal, metadata, or full')
|
|
308
|
+
},
|
|
309
|
+
(state, input, { env }) => {
|
|
310
|
+
const message = findMessage(state, input.id)
|
|
311
|
+
|
|
312
|
+
if (message !== undefined) {
|
|
313
|
+
const rendered = renderMessage(message, input.format)
|
|
314
|
+
|
|
315
|
+
return rendered === undefined
|
|
316
|
+
? notEmulated(`no fixture records this message with format=${input.format}`)
|
|
317
|
+
: answer(() => googleJson(200, rendered))
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (isImpliedMessage(state, input.id)) {
|
|
321
|
+
return notEmulated('reading a message a fixture only names in a listing is not emulated')
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// The recorded not-found answer: a minimal read of an id the mailbox does not hold.
|
|
325
|
+
return input.format === 'minimal' && absentMessageIdPattern.test(input.id)
|
|
326
|
+
? answer(() => notFound(env))
|
|
327
|
+
: notEmulated('only a format=minimal read of an absent 16-hex-digit id is recorded')
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
type AttachmentRead = { readonly messageId: string; readonly attachmentId: string }
|
|
332
|
+
|
|
333
|
+
const getAttachment: GoogleRoute = statefulRoute(
|
|
334
|
+
gmail(
|
|
335
|
+
'GET',
|
|
336
|
+
'/messages/{messageId}/attachments/{attachmentId}',
|
|
337
|
+
false,
|
|
338
|
+
[attachmentCase],
|
|
339
|
+
['messageId', 'attachmentId']
|
|
340
|
+
),
|
|
341
|
+
'none',
|
|
342
|
+
(request): AttachmentRead | NotEmulated =>
|
|
343
|
+
withoutQuery(request) ?? {
|
|
344
|
+
messageId: param(request, 'messageId'),
|
|
345
|
+
attachmentId: param(request, 'attachmentId')
|
|
346
|
+
},
|
|
347
|
+
(state, input, { env }) => {
|
|
348
|
+
const attachment = state.attachments.find(
|
|
349
|
+
item => item.messageId === input.messageId && item.attachmentId === input.attachmentId
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
if (attachment === undefined || findMessage(state, input.messageId) === undefined) {
|
|
353
|
+
return notEmulated('an attachment the state does not hold is not emulated')
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const data = env.drills.attachmentStandardBase64
|
|
357
|
+
? attachment.data.replaceAll('-', '+').replaceAll('_', '/')
|
|
358
|
+
: attachment.data
|
|
359
|
+
|
|
360
|
+
return answer(() => googleJson(200, { size: attachment.size, data }))
|
|
361
|
+
}
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
// Labels.
|
|
365
|
+
|
|
366
|
+
const createLabel: GoogleRoute = statefulRoute(
|
|
367
|
+
gmail('POST', '/labels', true, [labelCase]),
|
|
368
|
+
'json',
|
|
369
|
+
(request): string | NotEmulated => {
|
|
370
|
+
const body = withoutQuery(request) ?? exactBodyKeys(request.json, 'the label body', ['name'])
|
|
371
|
+
|
|
372
|
+
if (isNotEmulated(body)) return body
|
|
373
|
+
|
|
374
|
+
return Predicate.isString(body.name) && isRunText(body.name, 'label')
|
|
375
|
+
? body.name
|
|
376
|
+
: notEmulated('a label name other than "yolk-conformance <runId> label" is not emulated')
|
|
377
|
+
},
|
|
378
|
+
(state, name) => {
|
|
379
|
+
if (state.labels.some(label => label.name === name)) {
|
|
380
|
+
return notEmulated('creating a label whose name exists is not emulated')
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Refused before any fault: no minted label id is left.
|
|
384
|
+
if (state.counters.nextLabelNumber > lastLabelNumber) {
|
|
385
|
+
return notEmulated('creating a label when no Label_<n> id is left is not emulated')
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return () => {
|
|
389
|
+
const number = state.counters.nextLabelNumber
|
|
390
|
+
|
|
391
|
+
const label = {
|
|
392
|
+
id: `Label_${number}`,
|
|
393
|
+
name,
|
|
394
|
+
messageListVisibility: 'show',
|
|
395
|
+
labelListVisibility: 'labelShow',
|
|
396
|
+
type: 'user'
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
state.counters = { ...state.counters, nextLabelNumber: number + 1 }
|
|
400
|
+
state.labels = [...state.labels, label]
|
|
401
|
+
|
|
402
|
+
return googleJson(200, { ...label })
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
const getLabel: GoogleRoute = statefulRoute(
|
|
408
|
+
gmail('GET', '/labels/{labelId}', false, [labelCase], ['labelId']),
|
|
409
|
+
'none',
|
|
410
|
+
request => withoutQuery(request) ?? param(request, 'labelId'),
|
|
411
|
+
(state, id, { env }) => {
|
|
412
|
+
if (state.labels.some(label => label.id === id) || state.impliedLabelIds.includes(id)) {
|
|
413
|
+
return notEmulated('no fixture records a label read of an existing label')
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return mintedLabelIdPattern.test(id)
|
|
417
|
+
? answer(() => notFound(env))
|
|
418
|
+
: notEmulated('only a read of an absent Label_<n> id is recorded')
|
|
419
|
+
}
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
const deleteLabel: GoogleRoute = statefulRoute(
|
|
423
|
+
gmail('DELETE', '/labels/{labelId}', true, [labelCase], ['labelId']),
|
|
424
|
+
'none',
|
|
425
|
+
request => withoutQuery(request) ?? param(request, 'labelId'),
|
|
426
|
+
(state, id, { env }) => {
|
|
427
|
+
if (!state.labels.some(label => label.id === id)) {
|
|
428
|
+
return notEmulated('only deleting a label created here is recorded')
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return () => {
|
|
432
|
+
const without = <M extends { readonly labelIds: ReadonlyArray<string> }>(message: M): M =>
|
|
433
|
+
message.labelIds.includes(id)
|
|
434
|
+
? { ...message, labelIds: message.labelIds.filter(label => label !== id) }
|
|
435
|
+
: message
|
|
436
|
+
|
|
437
|
+
state.labels = state.labels.filter(label => label.id !== id)
|
|
438
|
+
|
|
439
|
+
// Deleting a label removes it from every message (the label fixture's last read).
|
|
440
|
+
if (!env.drills.labelDeleteKeepsOnMessages) {
|
|
441
|
+
state.messages = state.messages.map(without)
|
|
442
|
+
state.impliedMessages = state.impliedMessages.map(without)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return googleNoContent()
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
// Label changes, trash, and untrash.
|
|
451
|
+
|
|
452
|
+
type ModifyInput = { readonly messageId: string; readonly labelId: string }
|
|
453
|
+
|
|
454
|
+
const modifyMessage: GoogleRoute = statefulRoute(
|
|
455
|
+
gmail('POST', '/messages/{messageId}/modify', true, [labelCase], ['messageId']),
|
|
456
|
+
'json',
|
|
457
|
+
(request): ModifyInput | NotEmulated => {
|
|
458
|
+
const body =
|
|
459
|
+
withoutQuery(request) ?? exactBodyKeys(request.json, 'the modify body', ['addLabelIds'])
|
|
460
|
+
|
|
461
|
+
if (isNotEmulated(body)) return body
|
|
462
|
+
|
|
463
|
+
const added = body.addLabelIds
|
|
464
|
+
|
|
465
|
+
if (!Array.isArray(added) || added.length !== 1 || !Predicate.isString(added[0])) {
|
|
466
|
+
return notEmulated('a modify adding other than one label is not emulated')
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return { messageId: param(request, 'messageId'), labelId: added[0] }
|
|
470
|
+
},
|
|
471
|
+
(state, input) => {
|
|
472
|
+
const message = findMessage(state, input.messageId)
|
|
473
|
+
|
|
474
|
+
if (message === undefined || isDraftMessage(message)) {
|
|
475
|
+
return notEmulated('changing the labels of a message the state does not hold is not emulated')
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (!state.labels.some(label => label.id === input.labelId)) {
|
|
479
|
+
return notEmulated('adding a label other than one created here is not emulated')
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (message.labelIds.includes(input.labelId)) {
|
|
483
|
+
return notEmulated('adding a label the message carries already is not emulated')
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return () => {
|
|
487
|
+
const next = { ...message, labelIds: [...message.labelIds, input.labelId] }
|
|
488
|
+
|
|
489
|
+
state.messages = replaceMessage(state, next)
|
|
490
|
+
|
|
491
|
+
return labelsAnswer(next, next.labelIds)
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
const trashRoute = (action: 'trash' | 'untrash'): GoogleRoute =>
|
|
497
|
+
statefulRoute(
|
|
498
|
+
gmail('POST', `/messages/{messageId}/${action}`, true, [trashCase], ['messageId']),
|
|
499
|
+
'none',
|
|
500
|
+
request => withoutQuery(request) ?? param(request, 'messageId'),
|
|
501
|
+
(state, id, { env }) => {
|
|
502
|
+
const message = findMessage(state, id)
|
|
503
|
+
|
|
504
|
+
if (message === undefined || isDraftMessage(message)) {
|
|
505
|
+
return notEmulated(`${action} of a message the state does not hold is not emulated`)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const inTrash = message.labelIds.includes('TRASH')
|
|
509
|
+
|
|
510
|
+
if (action === 'trash' ? inTrash : !inTrash) {
|
|
511
|
+
return notEmulated(
|
|
512
|
+
action === 'trash'
|
|
513
|
+
? 'trashing a message already in Trash is not emulated'
|
|
514
|
+
: 'untrashing a message not in Trash is not emulated'
|
|
515
|
+
)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return () => {
|
|
519
|
+
const labelIds =
|
|
520
|
+
action === 'trash'
|
|
521
|
+
? [...message.labelIds, 'TRASH']
|
|
522
|
+
: message.labelIds.filter(label => label !== 'TRASH')
|
|
523
|
+
|
|
524
|
+
const next = { ...message, labelIds }
|
|
525
|
+
|
|
526
|
+
state.messages = replaceMessage(state, next)
|
|
527
|
+
|
|
528
|
+
return labelsAnswer(
|
|
529
|
+
next,
|
|
530
|
+
action === 'trash' && env.drills.trashAnswerOmitsTrash
|
|
531
|
+
? labelIds.filter(label => label !== 'TRASH')
|
|
532
|
+
: labelIds
|
|
533
|
+
)
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
// Drafts.
|
|
539
|
+
|
|
540
|
+
const base64UrlPattern = /^[A-Za-z0-9_-]*$/
|
|
541
|
+
|
|
542
|
+
/** The UTF-8 text of canonical unpadded base64url, or `undefined`. */
|
|
543
|
+
const decodeBase64UrlText = (raw: string): string | undefined => {
|
|
544
|
+
if (!base64UrlPattern.test(raw) || raw.length % 4 === 1) return undefined
|
|
545
|
+
|
|
546
|
+
try {
|
|
547
|
+
const binary = atob(raw.replaceAll('-', '+').replaceAll('_', '/'))
|
|
548
|
+
const bytes = Uint8Array.from(binary, char => char.charCodeAt(0))
|
|
549
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
550
|
+
|
|
551
|
+
// Canonical only: the encoding the connector writes, byte for byte.
|
|
552
|
+
return base64UrlOfText(text) === raw ? text : undefined
|
|
553
|
+
} catch {
|
|
554
|
+
return undefined
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const draftMimePattern =
|
|
559
|
+
/^Subject: ([^\r\n]*)\r\nContent-Type: text\/plain; charset=utf-8\r\n\r\n([\s\S]*)$/
|
|
560
|
+
|
|
561
|
+
type DraftContent = { readonly subject: string; readonly body: string }
|
|
562
|
+
|
|
563
|
+
/** The reasons a draft `message.raw` is refused with (constants; never derived from a request). */
|
|
564
|
+
const draftKeysReason = 'message has a key this route does not take'
|
|
565
|
+
|
|
566
|
+
const canonicalRawReason = 'message.raw must be canonical base64url UTF-8 MIME'
|
|
567
|
+
|
|
568
|
+
const otherDraftReason = (kind: 'compose' | 'update'): string =>
|
|
569
|
+
`a draft ${kind} other than the recorded run draft is not emulated`
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Every reason `readDraftRaw` refuses a draft of `kind` with: the route's declared
|
|
573
|
+
* `viewRefusalReasons`, so the wrapper ledgers exactly these (and nothing derived from a request).
|
|
574
|
+
*/
|
|
575
|
+
const draftRefusalReasons = (kind: 'compose' | 'update'): ReadonlyArray<string> => [
|
|
576
|
+
draftKeysReason,
|
|
577
|
+
canonicalRawReason,
|
|
578
|
+
otherDraftReason(kind),
|
|
579
|
+
sizedRunIdReason('draft')
|
|
580
|
+
]
|
|
581
|
+
|
|
582
|
+
type DraftRaw = {
|
|
583
|
+
/** The decoded MIME when `message.raw` is canonical base64url UTF-8 (decoded once). */
|
|
584
|
+
readonly decoded: string | undefined
|
|
585
|
+
readonly content: DraftContent | NotEmulated
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** The recorded draft MIME (no recipient) of a compose or an update, decoded once. */
|
|
589
|
+
const readDraftRaw = (message: Schema.Json | undefined, kind: 'compose' | 'update'): DraftRaw => {
|
|
590
|
+
// Decode `raw` before the key check, so a cleanly decoded raw is always available to the
|
|
591
|
+
// credential guard (a bearer inside it is a repeat, whatever else is wrong with `message`).
|
|
592
|
+
const raw =
|
|
593
|
+
Predicate.hasProperty(message, 'raw') && !Array.isArray(message) ? message.raw : undefined
|
|
594
|
+
|
|
595
|
+
const decoded = Predicate.isString(raw) ? decodeBase64UrlText(raw) : undefined
|
|
596
|
+
|
|
597
|
+
const fields = exactBodyKeys(message, 'message', ['raw'])
|
|
598
|
+
|
|
599
|
+
if (isNotEmulated(fields)) return { decoded, content: fields }
|
|
600
|
+
|
|
601
|
+
if (decoded === undefined) return { decoded, content: notEmulated(canonicalRawReason) }
|
|
602
|
+
|
|
603
|
+
const parts = draftMimePattern.exec(decoded)
|
|
604
|
+
const subject = parts?.[1] ?? ''
|
|
605
|
+
const body = parts?.[2] ?? ''
|
|
606
|
+
|
|
607
|
+
const runId =
|
|
608
|
+
kind === 'compose'
|
|
609
|
+
? runIdOf(subject, 'draft', draftSubjectRest)
|
|
610
|
+
: runIdOf(subject, 'draft updated', draftSubjectRest)
|
|
611
|
+
|
|
612
|
+
const recordedBody = kind === 'compose' ? draftText : updatedDraftText
|
|
613
|
+
|
|
614
|
+
if (parts === null || runId === undefined || body !== recordedBody) {
|
|
615
|
+
return { decoded, content: notEmulated(otherDraftReason(kind)) }
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
decoded,
|
|
620
|
+
content:
|
|
621
|
+
runId.length === recordedRunIdLength
|
|
622
|
+
? { subject, body }
|
|
623
|
+
: notEmulated(sizedRunIdReason('draft'))
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* The decoded view of a draft body for the credential guard (Gmail's own wire format wraps the
|
|
629
|
+
* draft MIME in base64url `message.raw`). Fail closed: when the body is a JSON object whose
|
|
630
|
+
* `message` object carries `raw`, the view returns the decoded MIME only when it is exactly the
|
|
631
|
+
* recorded draft MIME of this route (`kind`), the run id aside. Any other raw makes it throw a
|
|
632
|
+
* `DecodedViewRefusal` with the route's own constant reason (one of `draftRefusalReasons`) and
|
|
633
|
+
* whatever it decoded cleanly: the wrapper ledgers a repeat when that text holds the bearer, and
|
|
634
|
+
* the declared reason otherwise, before anything is recorded, so a refused raw (line-wrapped, the
|
|
635
|
+
* standard alphabet, a stray character, RFC 2047 words, quoted-printable, UTF-16, a 12-character
|
|
636
|
+
* run id, ...) never reaches the ledger. A body without `message.raw` yields no view (the route's
|
|
637
|
+
* own shape check refuses it).
|
|
638
|
+
*/
|
|
639
|
+
const draftRawViews =
|
|
640
|
+
(kind: 'compose' | 'update') =>
|
|
641
|
+
(body: string): ReadonlyArray<string> => {
|
|
642
|
+
const parsed = parseJsonText(body)
|
|
643
|
+
const message = isJsonObject(parsed) ? parsed.message : undefined
|
|
644
|
+
|
|
645
|
+
if (!isJsonObject(message) || !('raw' in message)) return []
|
|
646
|
+
|
|
647
|
+
const { decoded, content } = readDraftRaw(message, kind)
|
|
648
|
+
|
|
649
|
+
if (isNotEmulated(content)) {
|
|
650
|
+
throw new DecodedViewRefusal({
|
|
651
|
+
reason: content.reason,
|
|
652
|
+
decoded: decoded === undefined ? [] : [decoded]
|
|
653
|
+
})
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return decoded === undefined ? [] : [decoded]
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const textPayload = (subject: string, body: Schema.JsonObject): Schema.JsonObject => ({
|
|
660
|
+
partId: '',
|
|
661
|
+
mimeType: 'text/plain',
|
|
662
|
+
filename: '',
|
|
663
|
+
headers: [
|
|
664
|
+
{ name: 'Subject', value: subject },
|
|
665
|
+
{ name: 'Content-Type', value: 'text/plain; charset=utf-8' }
|
|
666
|
+
],
|
|
667
|
+
body
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
/** A draft message as the draft fixture records it (the metadata rendering only on compose). */
|
|
671
|
+
const draftMessage = (
|
|
672
|
+
id: string,
|
|
673
|
+
threadId: string,
|
|
674
|
+
content: DraftContent,
|
|
675
|
+
withMetadata: boolean
|
|
676
|
+
): GoogleEmulatorGmailMessage => ({
|
|
677
|
+
id,
|
|
678
|
+
threadId,
|
|
679
|
+
labelIds: ['DRAFT'],
|
|
680
|
+
snippet: content.body,
|
|
681
|
+
sizeEstimate: 512,
|
|
682
|
+
historyId: '900020',
|
|
683
|
+
internalDate: recordedInternalDate,
|
|
684
|
+
minimal: false,
|
|
685
|
+
metadataPayload: withMetadata ? textPayload(content.subject, { size: 64 }) : null,
|
|
686
|
+
fullPayload: textPayload(content.subject, {
|
|
687
|
+
size: new TextEncoder().encode(content.body).byteLength,
|
|
688
|
+
data: base64UrlOfText(content.body)
|
|
689
|
+
})
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
const draftAnswer = (draftId: string, message: GoogleEmulatorGmailMessage): Response =>
|
|
693
|
+
googleJson(200, {
|
|
694
|
+
id: draftId,
|
|
695
|
+
message: { id: message.id, threadId: message.threadId, labelIds: [...message.labelIds] }
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
const composeDraft: GoogleRoute = statefulRoute(
|
|
699
|
+
{
|
|
700
|
+
...gmail('POST', '/drafts', true, [draftCase]),
|
|
701
|
+
decodedViews: draftRawViews('compose'),
|
|
702
|
+
viewRefusalReasons: draftRefusalReasons('compose')
|
|
703
|
+
},
|
|
704
|
+
'json',
|
|
705
|
+
(request): DraftContent | NotEmulated => {
|
|
706
|
+
const body = withoutQuery(request) ?? exactBodyKeys(request.json, 'the draft body', ['message'])
|
|
707
|
+
|
|
708
|
+
return isNotEmulated(body) ? body : readDraftRaw(body.message, 'compose').content
|
|
709
|
+
},
|
|
710
|
+
(state, content) => () => {
|
|
711
|
+
const draftNumber = state.counters.nextDraftNumber
|
|
712
|
+
const messageNumber = state.counters.nextDraftMessageNumber
|
|
713
|
+
const id = mintedMessageId('d', messageNumber)
|
|
714
|
+
const message = draftMessage(id, id, content, true)
|
|
715
|
+
const draftId = mintedDraftId(draftNumber)
|
|
716
|
+
|
|
717
|
+
state.counters = {
|
|
718
|
+
...state.counters,
|
|
719
|
+
nextDraftNumber: draftNumber + 1,
|
|
720
|
+
nextDraftMessageNumber: messageNumber + 1
|
|
721
|
+
}
|
|
722
|
+
state.messages = [...state.messages, message]
|
|
723
|
+
state.drafts = [...state.drafts, { id: draftId, messageId: id }]
|
|
724
|
+
|
|
725
|
+
return draftAnswer(draftId, message)
|
|
726
|
+
}
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
type UpdateInput = { readonly draftId: string; readonly content: DraftContent }
|
|
730
|
+
|
|
731
|
+
const updateDraft: GoogleRoute = statefulRoute(
|
|
732
|
+
{
|
|
733
|
+
...gmail('PUT', '/drafts/{draftId}', true, [draftCase], ['draftId']),
|
|
734
|
+
decodedViews: draftRawViews('update'),
|
|
735
|
+
viewRefusalReasons: draftRefusalReasons('update')
|
|
736
|
+
},
|
|
737
|
+
'json',
|
|
738
|
+
(request): UpdateInput | NotEmulated => {
|
|
739
|
+
const draftId = param(request, 'draftId')
|
|
740
|
+
|
|
741
|
+
const body =
|
|
742
|
+
withoutQuery(request) ?? exactBodyKeys(request.json, 'the draft body', ['id', 'message'])
|
|
743
|
+
|
|
744
|
+
if (isNotEmulated(body)) return body
|
|
745
|
+
|
|
746
|
+
if (body.id !== draftId) return notEmulated('a body id other than the path draft id')
|
|
747
|
+
|
|
748
|
+
const content = readDraftRaw(body.message, 'update').content
|
|
749
|
+
|
|
750
|
+
return isNotEmulated(content) ? content : { draftId, content }
|
|
751
|
+
},
|
|
752
|
+
(state, input, { env }) => {
|
|
753
|
+
const draft = state.drafts.find(candidate => candidate.id === input.draftId)
|
|
754
|
+
const previous = draft === undefined ? undefined : findMessage(state, draft.messageId)
|
|
755
|
+
|
|
756
|
+
if (draft === undefined || previous === undefined) {
|
|
757
|
+
return notEmulated('updating a draft the state does not hold is not emulated')
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return () => {
|
|
761
|
+
const number = state.counters.nextDraftMessageNumber
|
|
762
|
+
const id = mintedMessageId('d', number)
|
|
763
|
+
|
|
764
|
+
const message: GoogleEmulatorGmailMessage = env.drills.draftUpdateKeepsContent
|
|
765
|
+
? { ...previous, id, metadataPayload: null }
|
|
766
|
+
: draftMessage(id, previous.threadId, input.content, false)
|
|
767
|
+
|
|
768
|
+
state.counters = { ...state.counters, nextDraftMessageNumber: number + 1 }
|
|
769
|
+
state.messages = [
|
|
770
|
+
...state.messages.filter(candidate => candidate.id !== previous.id),
|
|
771
|
+
message
|
|
772
|
+
]
|
|
773
|
+
state.drafts = state.drafts.map(candidate =>
|
|
774
|
+
candidate.id === draft.id ? { ...candidate, messageId: id } : candidate
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
return draftAnswer(draft.id, message)
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
const deleteDraft: GoogleRoute = statefulRoute(
|
|
783
|
+
gmail('DELETE', '/drafts/{draftId}', true, [draftCase], ['draftId']),
|
|
784
|
+
'none',
|
|
785
|
+
request => withoutQuery(request) ?? param(request, 'draftId'),
|
|
786
|
+
(state, id, { env }) => {
|
|
787
|
+
const draft = state.drafts.find(candidate => candidate.id === id)
|
|
788
|
+
|
|
789
|
+
if (draft === undefined) {
|
|
790
|
+
// The recorded repeated delete: an absent draft answers the not-found envelope.
|
|
791
|
+
return draftIdPattern.test(id)
|
|
792
|
+
? answer(() => notFound(env))
|
|
793
|
+
: notEmulated('only deleting an absent r-<digits> draft id is recorded')
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return () => {
|
|
797
|
+
state.drafts = state.drafts.filter(candidate => candidate.id !== id)
|
|
798
|
+
state.messages = state.messages.filter(message => message.id !== draft.messageId)
|
|
799
|
+
|
|
800
|
+
return googleNoContent()
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
const getThread: GoogleRoute = statefulRoute(
|
|
806
|
+
gmail('GET', '/threads/{threadId}', false, [draftCase], ['threadId']),
|
|
807
|
+
'none',
|
|
808
|
+
(request): string | NotEmulated => {
|
|
809
|
+
const query = exactQuery(request, ['format'])
|
|
810
|
+
|
|
811
|
+
if (isNotEmulated(query)) return query
|
|
812
|
+
|
|
813
|
+
return recordedValue(query, 'format', 'full') ?? param(request, 'threadId')
|
|
814
|
+
},
|
|
815
|
+
(state, threadId) => {
|
|
816
|
+
const messages = state.messages.filter(message => message.threadId === threadId)
|
|
817
|
+
|
|
818
|
+
// Only draft threads created here are recorded (a seeded message's thread is not).
|
|
819
|
+
const createdDrafts = messages.every(
|
|
820
|
+
message => isDraftMessage(message) && mintedMessageIdPattern.test(message.id)
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
if (messages.length === 0 || !createdDrafts) {
|
|
824
|
+
return notEmulated('a thread other than a draft thread created here is not emulated')
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const rendered = messages.map(message => renderMessage(message, 'full'))
|
|
828
|
+
|
|
829
|
+
if (rendered.some(item => item === undefined)) {
|
|
830
|
+
return notEmulated('a thread with a message without a full rendering is not emulated')
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
return answer(() =>
|
|
834
|
+
googleJson(200, {
|
|
835
|
+
id: threadId,
|
|
836
|
+
historyId: messages.at(-1)?.historyId ?? '',
|
|
837
|
+
messages: rendered.flatMap(item => (item === undefined ? [] : [item]))
|
|
838
|
+
})
|
|
839
|
+
)
|
|
840
|
+
}
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
// The practice send (upload endpoint).
|
|
844
|
+
|
|
845
|
+
const boundaryPattern = /^[A-Za-z0-9_]{1,70}$/
|
|
846
|
+
|
|
847
|
+
type SendInput = { readonly boundary: string; readonly subject: string; readonly text: string }
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* The boundary of `multipart/related; boundary=<b>`, or `undefined`. The parameter list is parsed
|
|
851
|
+
* whole: each parameter is split on its first `=`, and the content type must carry exactly one
|
|
852
|
+
* parameter, `boundary`, whose whole value is 1 to 70 of `[A-Za-z0-9_]` (unquoted). Any other,
|
|
853
|
+
* repeated, empty, or malformed parameter is refused (the send records no other parameter).
|
|
854
|
+
*/
|
|
855
|
+
const multipartBoundary = (contentType: string | undefined): string | undefined => {
|
|
856
|
+
if (mediaType(contentType) !== 'multipart/related') return undefined
|
|
857
|
+
|
|
858
|
+
const params = (contentType ?? '')
|
|
859
|
+
.split(';')
|
|
860
|
+
.slice(1)
|
|
861
|
+
.map(param => {
|
|
862
|
+
const separator = param.indexOf('=')
|
|
863
|
+
|
|
864
|
+
return separator === -1
|
|
865
|
+
? undefined
|
|
866
|
+
: {
|
|
867
|
+
name: param.slice(0, separator).trim().toLowerCase(),
|
|
868
|
+
value: param.slice(separator + 1)
|
|
869
|
+
}
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
const [only] = params
|
|
873
|
+
|
|
874
|
+
return params.length === 1 &&
|
|
875
|
+
only !== undefined &&
|
|
876
|
+
only.name === 'boundary' &&
|
|
877
|
+
boundaryPattern.test(only.value)
|
|
878
|
+
? only.value
|
|
879
|
+
: undefined
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const sendMessage: GoogleRoute = statefulRoute(
|
|
883
|
+
evidence(googleEmulatorGmailOrigin, 'POST', '/upload/gmail/v1/users/me/messages/send', true, [
|
|
884
|
+
sendCase
|
|
885
|
+
]),
|
|
886
|
+
'bytes',
|
|
887
|
+
(request): SendInput | NotEmulated => {
|
|
888
|
+
const query = exactQuery(request, ['uploadType'])
|
|
889
|
+
|
|
890
|
+
if (isNotEmulated(query)) return query
|
|
891
|
+
|
|
892
|
+
const problem = recordedValue(query, 'uploadType', 'multipart')
|
|
893
|
+
|
|
894
|
+
if (problem !== undefined) return problem
|
|
895
|
+
|
|
896
|
+
const boundary = multipartBoundary(request.header('content-type'))
|
|
897
|
+
|
|
898
|
+
if (boundary === undefined) {
|
|
899
|
+
return notEmulated(
|
|
900
|
+
'a content-type other than multipart/related; boundary=<b> is not emulated'
|
|
901
|
+
)
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
let text: string
|
|
905
|
+
|
|
906
|
+
try {
|
|
907
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(request.bytes)
|
|
908
|
+
} catch {
|
|
909
|
+
return notEmulated('an upload body that is not 7-bit text is not emulated')
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const subject = /\r\nSubject: ([^\r\n]*)\r\n/.exec(text)?.[1] ?? ''
|
|
913
|
+
|
|
914
|
+
const runId = runIdOf(subject, 'send', sendSubjectRest)
|
|
915
|
+
|
|
916
|
+
if (runId === undefined) {
|
|
917
|
+
return notEmulated(
|
|
918
|
+
'a send other than the recorded run-scoped practice message is not emulated'
|
|
919
|
+
)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
return runId.length === recordedRunIdLength
|
|
923
|
+
? { boundary, subject, text }
|
|
924
|
+
: notEmulated(sizedRunIdReason('send'))
|
|
925
|
+
},
|
|
926
|
+
(state, input, { env }) => {
|
|
927
|
+
// The recorded sizeEstimate covers the To and From headers: only the recorded address.
|
|
928
|
+
if (state.practiceAddress !== recordedPracticeAddress) {
|
|
929
|
+
return notEmulated(
|
|
930
|
+
`a send while the seeded practiceAddress is not the recorded ${recordedPracticeAddress} is not emulated (the recorded sizeEstimate covers the address)`
|
|
931
|
+
)
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Only the recorded message, whose ONLY recipient is the seeded practice address.
|
|
935
|
+
if (
|
|
936
|
+
input.text !== uploadBody(input.boundary, practiceMime(state.practiceAddress, input.subject))
|
|
937
|
+
) {
|
|
938
|
+
return notEmulated(
|
|
939
|
+
'a send other than the recorded 7-bit message to the seeded practice address only is not emulated'
|
|
940
|
+
)
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
return () => {
|
|
944
|
+
// Read the clock before any write: a failing clock, or one answering an instant outside the
|
|
945
|
+
// Date range (non-finite or not), writes nothing.
|
|
946
|
+
const instant = new Date(env.now())
|
|
947
|
+
|
|
948
|
+
if (!Number.isFinite(instant.getTime())) {
|
|
949
|
+
throw new Error('the clock answered an instant outside the Date range')
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const date = instant.toUTCString().replace(/GMT$/, '+0000')
|
|
953
|
+
const number = state.counters.nextSentNumber
|
|
954
|
+
const id = mintedMessageId('e', number)
|
|
955
|
+
const address = state.practiceAddress
|
|
956
|
+
|
|
957
|
+
const headers = [
|
|
958
|
+
{ name: 'MIME-Version', value: '1.0' },
|
|
959
|
+
{ name: 'Date', value: date },
|
|
960
|
+
{ name: 'Message-ID', value: `<synthetic-send-${pad(number, 4)}@example.test>` },
|
|
961
|
+
{ name: 'Subject', value: input.subject },
|
|
962
|
+
{ name: 'From', value: address },
|
|
963
|
+
{ name: 'To', value: address },
|
|
964
|
+
{ name: 'Content-Type', value: 'text/plain; charset=us-ascii' }
|
|
965
|
+
].filter(header => !(env.drills.sentMessageWithoutTo && header.name === 'To'))
|
|
966
|
+
|
|
967
|
+
// Recorded in the state only: nothing is delivered anywhere.
|
|
968
|
+
const sent: GoogleEmulatorGmailMessage = {
|
|
969
|
+
id,
|
|
970
|
+
threadId: id,
|
|
971
|
+
labelIds: ['SENT', 'INBOX', 'UNREAD'],
|
|
972
|
+
snippet: 'Synthetic conformance message sent to the seeded practice address only.',
|
|
973
|
+
sizeEstimate: 640,
|
|
974
|
+
historyId: '900030',
|
|
975
|
+
internalDate: recordedInternalDate,
|
|
976
|
+
minimal: false,
|
|
977
|
+
metadataPayload: {
|
|
978
|
+
partId: '',
|
|
979
|
+
mimeType: 'text/plain',
|
|
980
|
+
filename: '',
|
|
981
|
+
headers,
|
|
982
|
+
body: { size: 88 }
|
|
983
|
+
},
|
|
984
|
+
fullPayload: null
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
state.counters = { ...state.counters, nextSentNumber: number + 1 }
|
|
988
|
+
state.messages = [...state.messages, sent]
|
|
989
|
+
|
|
990
|
+
return googleJson(200, { id, threadId: id, labelIds: ['SENT'] })
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
/** The Gmail routes, in manifest order. */
|
|
996
|
+
export const gmailRoutes: ReadonlyArray<GoogleRoute> = [
|
|
997
|
+
listMessages,
|
|
998
|
+
getMessage,
|
|
999
|
+
getAttachment,
|
|
1000
|
+
modifyMessage,
|
|
1001
|
+
trashRoute('trash'),
|
|
1002
|
+
trashRoute('untrash'),
|
|
1003
|
+
createLabel,
|
|
1004
|
+
getLabel,
|
|
1005
|
+
deleteLabel,
|
|
1006
|
+
composeDraft,
|
|
1007
|
+
updateDraft,
|
|
1008
|
+
deleteDraft,
|
|
1009
|
+
getThread,
|
|
1010
|
+
sendMessage
|
|
1011
|
+
]
|