@yolk-sdk/emulators 0.1.0-canary.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (333) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2135 -0
  3. package/dist/anthropic.d.mts +269 -0
  4. package/dist/anthropic.d.mts.map +1 -0
  5. package/dist/anthropic.mjs +177 -0
  6. package/dist/anthropic.mjs.map +1 -0
  7. package/dist/chat-completions.d.mts +288 -0
  8. package/dist/chat-completions.d.mts.map +1 -0
  9. package/dist/chat-completions.mjs +451 -0
  10. package/dist/chat-completions.mjs.map +1 -0
  11. package/dist/codex.d.mts +265 -0
  12. package/dist/codex.d.mts.map +1 -0
  13. package/dist/codex.mjs +199 -0
  14. package/dist/codex.mjs.map +1 -0
  15. package/dist/dropbox/api.d.mts +63 -0
  16. package/dist/dropbox/api.d.mts.map +1 -0
  17. package/dist/dropbox/api.mjs +565 -0
  18. package/dist/dropbox/api.mjs.map +1 -0
  19. package/dist/dropbox/state.d.mts +136 -0
  20. package/dist/dropbox/state.d.mts.map +1 -0
  21. package/dist/dropbox/state.mjs +209 -0
  22. package/dist/dropbox/state.mjs.map +1 -0
  23. package/dist/dropbox.d.mts +79 -0
  24. package/dist/dropbox.d.mts.map +1 -0
  25. package/dist/dropbox.mjs +124 -0
  26. package/dist/dropbox.mjs.map +1 -0
  27. package/dist/email-fixtures.d.mts +36 -0
  28. package/dist/email-fixtures.d.mts.map +1 -0
  29. package/dist/email-fixtures.mjs +1080 -0
  30. package/dist/email-fixtures.mjs.map +1 -0
  31. package/dist/email.d.mts +160 -0
  32. package/dist/email.d.mts.map +1 -0
  33. package/dist/email.mjs +608 -0
  34. package/dist/email.mjs.map +1 -0
  35. package/dist/emulator-compose.d.mts +40 -0
  36. package/dist/emulator-compose.d.mts.map +1 -0
  37. package/dist/emulator-compose.mjs +91 -0
  38. package/dist/emulator-compose.mjs.map +1 -0
  39. package/dist/emulator-http.d.mts +53 -0
  40. package/dist/emulator-http.d.mts.map +1 -0
  41. package/dist/emulator-http.mjs +117 -0
  42. package/dist/emulator-http.mjs.map +1 -0
  43. package/dist/emulator-kernel.d.mts +176 -0
  44. package/dist/emulator-kernel.d.mts.map +1 -0
  45. package/dist/emulator-kernel.mjs +413 -0
  46. package/dist/emulator-kernel.mjs.map +1 -0
  47. package/dist/fixture-route.d.mts +129 -0
  48. package/dist/fixture-route.d.mts.map +1 -0
  49. package/dist/fixture-route.mjs +257 -0
  50. package/dist/fixture-route.mjs.map +1 -0
  51. package/dist/fortnox/api.d.mts +92 -0
  52. package/dist/fortnox/api.d.mts.map +1 -0
  53. package/dist/fortnox/api.mjs +750 -0
  54. package/dist/fortnox/api.mjs.map +1 -0
  55. package/dist/fortnox/state.d.mts +533 -0
  56. package/dist/fortnox/state.d.mts.map +1 -0
  57. package/dist/fortnox/state.mjs +612 -0
  58. package/dist/fortnox/state.mjs.map +1 -0
  59. package/dist/fortnox.d.mts +128 -0
  60. package/dist/fortnox.d.mts.map +1 -0
  61. package/dist/fortnox.mjs +403 -0
  62. package/dist/fortnox.mjs.map +1 -0
  63. package/dist/gateway-evaluate-recordings.d.mts +12 -0
  64. package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
  65. package/dist/gateway-evaluate-recordings.mjs +128 -0
  66. package/dist/gateway-evaluate-recordings.mjs.map +1 -0
  67. package/dist/gateway.d.mts +272 -0
  68. package/dist/gateway.d.mts.map +1 -0
  69. package/dist/gateway.mjs +400 -0
  70. package/dist/gateway.mjs.map +1 -0
  71. package/dist/github/api.d.mts +63 -0
  72. package/dist/github/api.d.mts.map +1 -0
  73. package/dist/github/api.mjs +527 -0
  74. package/dist/github/api.mjs.map +1 -0
  75. package/dist/github/state.d.mts +272 -0
  76. package/dist/github/state.d.mts.map +1 -0
  77. package/dist/github/state.mjs +303 -0
  78. package/dist/github/state.mjs.map +1 -0
  79. package/dist/github.d.mts +77 -0
  80. package/dist/github.d.mts.map +1 -0
  81. package/dist/github.mjs +180 -0
  82. package/dist/github.mjs.map +1 -0
  83. package/dist/google/calendar.d.mts +10 -0
  84. package/dist/google/calendar.d.mts.map +1 -0
  85. package/dist/google/calendar.mjs +252 -0
  86. package/dist/google/calendar.mjs.map +1 -0
  87. package/dist/google/drive.d.mts +8 -0
  88. package/dist/google/drive.d.mts.map +1 -0
  89. package/dist/google/drive.mjs +252 -0
  90. package/dist/google/drive.mjs.map +1 -0
  91. package/dist/google/gmail.d.mts +10 -0
  92. package/dist/google/gmail.d.mts.map +1 -0
  93. package/dist/google/gmail.mjs +591 -0
  94. package/dist/google/gmail.mjs.map +1 -0
  95. package/dist/google/shared.d.mts +107 -0
  96. package/dist/google/shared.d.mts.map +1 -0
  97. package/dist/google/shared.mjs +146 -0
  98. package/dist/google/shared.mjs.map +1 -0
  99. package/dist/google/state.d.mts +385 -0
  100. package/dist/google/state.d.mts.map +1 -0
  101. package/dist/google/state.mjs +496 -0
  102. package/dist/google/state.mjs.map +1 -0
  103. package/dist/google.d.mts +75 -0
  104. package/dist/google.d.mts.map +1 -0
  105. package/dist/google.mjs +210 -0
  106. package/dist/google.mjs.map +1 -0
  107. package/dist/linkedin-search/api.d.mts +45 -0
  108. package/dist/linkedin-search/api.d.mts.map +1 -0
  109. package/dist/linkedin-search/api.mjs +181 -0
  110. package/dist/linkedin-search/api.mjs.map +1 -0
  111. package/dist/linkedin-search/state.d.mts +123 -0
  112. package/dist/linkedin-search/state.d.mts.map +1 -0
  113. package/dist/linkedin-search/state.mjs +261 -0
  114. package/dist/linkedin-search/state.mjs.map +1 -0
  115. package/dist/linkedin-search.d.mts +69 -0
  116. package/dist/linkedin-search.d.mts.map +1 -0
  117. package/dist/linkedin-search.mjs +158 -0
  118. package/dist/linkedin-search.mjs.map +1 -0
  119. package/dist/mcp/api.d.mts +44 -0
  120. package/dist/mcp/api.d.mts.map +1 -0
  121. package/dist/mcp/api.mjs +557 -0
  122. package/dist/mcp/api.mjs.map +1 -0
  123. package/dist/mcp/recordings.d.mts +40 -0
  124. package/dist/mcp/recordings.d.mts.map +1 -0
  125. package/dist/mcp/recordings.mjs +2390 -0
  126. package/dist/mcp/recordings.mjs.map +1 -0
  127. package/dist/mcp/state.d.mts +71 -0
  128. package/dist/mcp/state.d.mts.map +1 -0
  129. package/dist/mcp/state.mjs +83 -0
  130. package/dist/mcp/state.mjs.map +1 -0
  131. package/dist/mcp.d.mts +84 -0
  132. package/dist/mcp.d.mts.map +1 -0
  133. package/dist/mcp.mjs +241 -0
  134. package/dist/mcp.mjs.map +1 -0
  135. package/dist/messages.d.mts +183 -0
  136. package/dist/messages.d.mts.map +1 -0
  137. package/dist/messages.mjs +532 -0
  138. package/dist/messages.mjs.map +1 -0
  139. package/dist/microsoft/api.d.mts +47 -0
  140. package/dist/microsoft/api.d.mts.map +1 -0
  141. package/dist/microsoft/api.mjs +178 -0
  142. package/dist/microsoft/api.mjs.map +1 -0
  143. package/dist/microsoft/calendar.d.mts +30 -0
  144. package/dist/microsoft/calendar.d.mts.map +1 -0
  145. package/dist/microsoft/calendar.mjs +271 -0
  146. package/dist/microsoft/calendar.mjs.map +1 -0
  147. package/dist/microsoft/drive.d.mts +36 -0
  148. package/dist/microsoft/drive.d.mts.map +1 -0
  149. package/dist/microsoft/drive.mjs +298 -0
  150. package/dist/microsoft/drive.mjs.map +1 -0
  151. package/dist/microsoft/graph.d.mts +198 -0
  152. package/dist/microsoft/graph.d.mts.map +1 -0
  153. package/dist/microsoft/graph.mjs +264 -0
  154. package/dist/microsoft/graph.mjs.map +1 -0
  155. package/dist/microsoft/mail.d.mts +48 -0
  156. package/dist/microsoft/mail.d.mts.map +1 -0
  157. package/dist/microsoft/mail.mjs +488 -0
  158. package/dist/microsoft/mail.mjs.map +1 -0
  159. package/dist/microsoft/state.d.mts +480 -0
  160. package/dist/microsoft/state.d.mts.map +1 -0
  161. package/dist/microsoft/state.mjs +625 -0
  162. package/dist/microsoft/state.mjs.map +1 -0
  163. package/dist/microsoft.d.mts +168 -0
  164. package/dist/microsoft.d.mts.map +1 -0
  165. package/dist/microsoft.mjs +483 -0
  166. package/dist/microsoft.mjs.map +1 -0
  167. package/dist/node.d.mts +47 -0
  168. package/dist/node.d.mts.map +1 -0
  169. package/dist/node.mjs +178 -0
  170. package/dist/node.mjs.map +1 -0
  171. package/dist/notion/api.d.mts +51 -0
  172. package/dist/notion/api.d.mts.map +1 -0
  173. package/dist/notion/api.mjs +507 -0
  174. package/dist/notion/api.mjs.map +1 -0
  175. package/dist/notion/state.d.mts +329 -0
  176. package/dist/notion/state.d.mts.map +1 -0
  177. package/dist/notion/state.mjs +432 -0
  178. package/dist/notion/state.mjs.map +1 -0
  179. package/dist/notion.d.mts +72 -0
  180. package/dist/notion.d.mts.map +1 -0
  181. package/dist/notion.mjs +127 -0
  182. package/dist/notion.mjs.map +1 -0
  183. package/dist/openai.d.mts +165 -0
  184. package/dist/openai.d.mts.map +1 -0
  185. package/dist/openai.mjs +141 -0
  186. package/dist/openai.mjs.map +1 -0
  187. package/dist/opencode-recordings.d.mts +7 -0
  188. package/dist/opencode-recordings.d.mts.map +1 -0
  189. package/dist/opencode-recordings.mjs +222 -0
  190. package/dist/opencode-recordings.mjs.map +1 -0
  191. package/dist/opencode.d.mts +70 -0
  192. package/dist/opencode.d.mts.map +1 -0
  193. package/dist/opencode.mjs +217 -0
  194. package/dist/opencode.mjs.map +1 -0
  195. package/dist/r2-fixtures.d.mts +36 -0
  196. package/dist/r2-fixtures.d.mts.map +1 -0
  197. package/dist/r2-fixtures.mjs +245 -0
  198. package/dist/r2-fixtures.mjs.map +1 -0
  199. package/dist/r2-guard.d.mts +43 -0
  200. package/dist/r2-guard.d.mts.map +1 -0
  201. package/dist/r2-guard.mjs +215 -0
  202. package/dist/r2-guard.mjs.map +1 -0
  203. package/dist/r2.d.mts +166 -0
  204. package/dist/r2.d.mts.map +1 -0
  205. package/dist/r2.mjs +517 -0
  206. package/dist/r2.mjs.map +1 -0
  207. package/dist/responses.d.mts +231 -0
  208. package/dist/responses.d.mts.map +1 -0
  209. package/dist/responses.mjs +556 -0
  210. package/dist/responses.mjs.map +1 -0
  211. package/dist/route-evidence.d.mts +45 -0
  212. package/dist/route-evidence.d.mts.map +1 -0
  213. package/dist/route-evidence.mjs +56 -0
  214. package/dist/route-evidence.mjs.map +1 -0
  215. package/dist/router.d.mts +93 -0
  216. package/dist/router.d.mts.map +1 -0
  217. package/dist/router.mjs +259 -0
  218. package/dist/router.mjs.map +1 -0
  219. package/dist/stateful-core.d.mts +17 -0
  220. package/dist/stateful-core.d.mts.map +1 -0
  221. package/dist/stateful-core.mjs +41 -0
  222. package/dist/stateful-core.mjs.map +1 -0
  223. package/dist/stateful-emulator.d.mts +565 -0
  224. package/dist/stateful-emulator.d.mts.map +1 -0
  225. package/dist/stateful-emulator.mjs +1228 -0
  226. package/dist/stateful-emulator.mjs.map +1 -0
  227. package/dist/stateful-secrets.d.mts +84 -0
  228. package/dist/stateful-secrets.d.mts.map +1 -0
  229. package/dist/stateful-secrets.mjs +216 -0
  230. package/dist/stateful-secrets.mjs.map +1 -0
  231. package/dist/subscription-usage-recordings.d.mts +9 -0
  232. package/dist/subscription-usage-recordings.d.mts.map +1 -0
  233. package/dist/subscription-usage-recordings.mjs +53 -0
  234. package/dist/subscription-usage-recordings.mjs.map +1 -0
  235. package/dist/subscription-usage.d.mts +53 -0
  236. package/dist/subscription-usage.d.mts.map +1 -0
  237. package/dist/subscription-usage.mjs +21 -0
  238. package/dist/subscription-usage.mjs.map +1 -0
  239. package/dist/telegram/api.d.mts +34 -0
  240. package/dist/telegram/api.d.mts.map +1 -0
  241. package/dist/telegram/api.mjs +257 -0
  242. package/dist/telegram/api.mjs.map +1 -0
  243. package/dist/telegram/state.d.mts +128 -0
  244. package/dist/telegram/state.d.mts.map +1 -0
  245. package/dist/telegram/state.mjs +154 -0
  246. package/dist/telegram/state.mjs.map +1 -0
  247. package/dist/telegram.d.mts +62 -0
  248. package/dist/telegram.d.mts.map +1 -0
  249. package/dist/telegram.mjs +127 -0
  250. package/dist/telegram.mjs.map +1 -0
  251. package/dist/todoist/api.d.mts +55 -0
  252. package/dist/todoist/api.d.mts.map +1 -0
  253. package/dist/todoist/api.mjs +415 -0
  254. package/dist/todoist/api.mjs.map +1 -0
  255. package/dist/todoist/state.d.mts +275 -0
  256. package/dist/todoist/state.d.mts.map +1 -0
  257. package/dist/todoist/state.mjs +345 -0
  258. package/dist/todoist/state.mjs.map +1 -0
  259. package/dist/todoist.d.mts +68 -0
  260. package/dist/todoist.d.mts.map +1 -0
  261. package/dist/todoist.mjs +181 -0
  262. package/dist/todoist.mjs.map +1 -0
  263. package/dist/xai.d.mts +267 -0
  264. package/dist/xai.d.mts.map +1 -0
  265. package/dist/xai.mjs +252 -0
  266. package/dist/xai.mjs.map +1 -0
  267. package/package.json +157 -0
  268. package/src/anthropic.ts +289 -0
  269. package/src/chat-completions.ts +924 -0
  270. package/src/codex.ts +296 -0
  271. package/src/dropbox/api.ts +1084 -0
  272. package/src/dropbox/state.ts +364 -0
  273. package/src/dropbox.ts +203 -0
  274. package/src/email-fixtures.ts +1297 -0
  275. package/src/email.ts +1108 -0
  276. package/src/emulator-compose.ts +147 -0
  277. package/src/emulator-http.ts +184 -0
  278. package/src/emulator-kernel.ts +844 -0
  279. package/src/fixture-route.ts +561 -0
  280. package/src/fortnox/api.ts +1352 -0
  281. package/src/fortnox/state.ts +798 -0
  282. package/src/fortnox.ts +801 -0
  283. package/src/gateway-evaluate-recordings.ts +153 -0
  284. package/src/gateway.ts +530 -0
  285. package/src/github/api.ts +986 -0
  286. package/src/github/state.ts +439 -0
  287. package/src/github.ts +271 -0
  288. package/src/google/calendar.ts +486 -0
  289. package/src/google/drive.ts +471 -0
  290. package/src/google/gmail.ts +1011 -0
  291. package/src/google/shared.ts +321 -0
  292. package/src/google/state.ts +686 -0
  293. package/src/google.ts +298 -0
  294. package/src/linkedin-search/api.ts +363 -0
  295. package/src/linkedin-search/state.ts +373 -0
  296. package/src/linkedin-search.ts +241 -0
  297. package/src/mcp/api.ts +995 -0
  298. package/src/mcp/recordings.ts +2665 -0
  299. package/src/mcp/state.ts +125 -0
  300. package/src/mcp.ts +319 -0
  301. package/src/messages.ts +844 -0
  302. package/src/microsoft/api.ts +426 -0
  303. package/src/microsoft/calendar.ts +491 -0
  304. package/src/microsoft/drive.ts +541 -0
  305. package/src/microsoft/graph.ts +550 -0
  306. package/src/microsoft/mail.ts +833 -0
  307. package/src/microsoft/state.ts +822 -0
  308. package/src/microsoft.ts +982 -0
  309. package/src/node.ts +293 -0
  310. package/src/notion/api.ts +976 -0
  311. package/src/notion/state.ts +512 -0
  312. package/src/notion.ts +210 -0
  313. package/src/openai.ts +198 -0
  314. package/src/opencode-recordings.ts +257 -0
  315. package/src/opencode.ts +271 -0
  316. package/src/r2-fixtures.ts +295 -0
  317. package/src/r2-guard.ts +323 -0
  318. package/src/r2.ts +890 -0
  319. package/src/responses.ts +901 -0
  320. package/src/route-evidence.ts +90 -0
  321. package/src/router.ts +490 -0
  322. package/src/stateful-core.ts +70 -0
  323. package/src/stateful-emulator.ts +2571 -0
  324. package/src/stateful-secrets.ts +299 -0
  325. package/src/subscription-usage-recordings.ts +77 -0
  326. package/src/subscription-usage.ts +68 -0
  327. package/src/telegram/api.ts +437 -0
  328. package/src/telegram/state.ts +229 -0
  329. package/src/telegram.ts +227 -0
  330. package/src/todoist/api.ts +736 -0
  331. package/src/todoist/state.ts +456 -0
  332. package/src/todoist.ts +316 -0
  333. package/src/xai.ts +341 -0
@@ -0,0 +1,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
+ ]