@hraness/ghostget 0.17.2

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 (500) hide show
  1. package/CHANGELOG.md +458 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1359 -0
  4. package/bunfig.toml +2 -0
  5. package/dist/apple-photos-client.js +704 -0
  6. package/dist/beeper-client.js +4602 -0
  7. package/dist/client.js +1605 -0
  8. package/dist/index-26yq8q16.js +31 -0
  9. package/dist/index-9t13b4k7.js +508 -0
  10. package/dist/index-axwm9b3v.js +5 -0
  11. package/dist/index-gwk7rbyj.js +30 -0
  12. package/dist/index.js +701 -0
  13. package/dist/messaging.js +1130 -0
  14. package/dist/omni-client.js +1406 -0
  15. package/dist/whatsapp-client.js +355 -0
  16. package/docs/imessage-direct-provider.md +106 -0
  17. package/docs/rental-listings.md +94 -0
  18. package/package.json +600 -0
  19. package/skills/ghostget/SKILL.md +251 -0
  20. package/skills/ghostget/agents/openai.yaml +4 -0
  21. package/skills/ghostget/references/adapter-contract.md +181 -0
  22. package/skills/ghostget/references/apple-photos.md +115 -0
  23. package/skills/ghostget/references/article-drafts.md +235 -0
  24. package/skills/ghostget/references/code-owned-provider-scaffold.md +134 -0
  25. package/skills/ghostget/references/cross-posting-video.md +139 -0
  26. package/skills/ghostget/references/cross-posting.md +82 -0
  27. package/skills/ghostget/references/derivation.md +390 -0
  28. package/skills/ghostget/references/hraness-social-profile-stats.json +269 -0
  29. package/skills/ghostget/references/install.md +66 -0
  30. package/skills/ghostget/references/linkedin-adapter.md +442 -0
  31. package/skills/ghostget/references/messaging.md +168 -0
  32. package/skills/ghostget/references/meta-comet-contract.md +153 -0
  33. package/skills/ghostget/references/platform-patterns.md +122 -0
  34. package/skills/ghostget/references/provider-plugins.md +496 -0
  35. package/skills/ghostget/references/reddit-flair.md +78 -0
  36. package/skills/ghostget/references/safety-and-state.md +214 -0
  37. package/skills/ghostget/references/settlement-and-duplicate-cleanup.md +79 -0
  38. package/skills/ghostget/references/social-platform-routing.md +66 -0
  39. package/skills/ghostget/references/social-profile-stats.md +195 -0
  40. package/skills/ghostget/references/social-video-platform-routing.md +89 -0
  41. package/skills/ghostget/references/whatsapp-adapter.md +101 -0
  42. package/skills/ghostget/references/x-adapter.md +332 -0
  43. package/skills/ghostget/references/x-ai-disclosure.md +71 -0
  44. package/src/apple-photos-cli.ts +639 -0
  45. package/src/apple-photos-client-types.ts +168 -0
  46. package/src/apple-photos-client.ts +201 -0
  47. package/src/apple-photos-contact-evidence.ts +838 -0
  48. package/src/apple-photos-local-source.ts +1131 -0
  49. package/src/args.ts +2053 -0
  50. package/src/article-draft-document.ts +417 -0
  51. package/src/article-draft-embeds.ts +123 -0
  52. package/src/article-draft-images.ts +214 -0
  53. package/src/assets/adapter-template/wrench-adapter.json +40 -0
  54. package/src/assets/adapters/beeper/wrench-web-adapter.json +1500 -0
  55. package/src/assets/adapters/beeper/wrench-web-adapter.v1.0.0.json +129 -0
  56. package/src/assets/adapters/beeper/wrench-web-adapter.v1.1.0.json +207 -0
  57. package/src/assets/adapters/beeper/wrench-web-adapter.v2.0.0.json +1463 -0
  58. package/src/assets/adapters/beeper/wrench-web-adapter.v2.1.0.json +1463 -0
  59. package/src/assets/adapters/beeper/wrench-web-adapter.v2.2.0.json +1475 -0
  60. package/src/assets/adapters/beeper/wrench-web-adapter.v2.3.0.json +1488 -0
  61. package/src/assets/adapters/bluesky/wrench-web-adapter.json +684 -0
  62. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.0.0.json +581 -0
  63. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.1.0.json +581 -0
  64. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.2.0.json +581 -0
  65. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.3.0.json +615 -0
  66. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.4.0.json +684 -0
  67. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.5.0.json +684 -0
  68. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.6.0.json +684 -0
  69. package/src/assets/adapters/clasificados/wrench-web-adapter.json +54 -0
  70. package/src/assets/adapters/facebook/wrench-web-adapter.json +750 -0
  71. package/src/assets/adapters/facebook/wrench-web-adapter.v1.0.0.json +706 -0
  72. package/src/assets/adapters/facebook/wrench-web-adapter.v1.1.0.json +703 -0
  73. package/src/assets/adapters/facebook/wrench-web-adapter.v1.2.0.json +750 -0
  74. package/src/assets/adapters/facebook-group/wrench-web-adapter.json +682 -0
  75. package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.0.0.json +583 -0
  76. package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.1.0.json +682 -0
  77. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.json +437 -0
  78. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.0.0.json +383 -0
  79. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.0.json +437 -0
  80. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.1.json +437 -0
  81. package/src/assets/adapters/facebook-page/wrench-web-adapter.json +880 -0
  82. package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.0.0.json +740 -0
  83. package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.1.0.json +880 -0
  84. package/src/assets/adapters/github/wrench-web-adapter.json +69 -0
  85. package/src/assets/adapters/github/wrench-web-adapter.v1.0.0.json +42 -0
  86. package/src/assets/adapters/github/wrench-web-adapter.v1.1.0.json +69 -0
  87. package/src/assets/adapters/gmail/wrench-adapter.json +161 -0
  88. package/src/assets/adapters/gmail/wrench-adapter.v1.2.0.json +157 -0
  89. package/src/assets/adapters/hacker-news/wrench-web-adapter.json +315 -0
  90. package/src/assets/adapters/hacker-news/wrench-web-adapter.v1.0.0.json +315 -0
  91. package/src/assets/adapters/imessage/wrench-web-adapter.json +236 -0
  92. package/src/assets/adapters/instagram/wrench-web-adapter.json +719 -0
  93. package/src/assets/adapters/instagram/wrench-web-adapter.v1.0.0.json +627 -0
  94. package/src/assets/adapters/instagram/wrench-web-adapter.v1.1.0.json +609 -0
  95. package/src/assets/adapters/instagram/wrench-web-adapter.v1.2.0.json +640 -0
  96. package/src/assets/adapters/instagram/wrench-web-adapter.v1.3.0.json +671 -0
  97. package/src/assets/adapters/instagram/wrench-web-adapter.v1.4.0.json +710 -0
  98. package/src/assets/adapters/instagram/wrench-web-adapter.v1.5.0.json +710 -0
  99. package/src/assets/adapters/instagram/wrench-web-adapter.v1.6.0.json +719 -0
  100. package/src/assets/adapters/instagram/wrench-web-adapter.v1.7.0.json +719 -0
  101. package/src/assets/adapters/linkedin/wrench-adapter.json +442 -0
  102. package/src/assets/adapters/linkedin/wrench-adapter.v0.4.0.json +124 -0
  103. package/src/assets/adapters/linkedin/wrench-adapter.v1.0.0.json +411 -0
  104. package/src/assets/adapters/linkedin/wrench-web-adapter.json +914 -0
  105. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.0.0.json +604 -0
  106. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.1.0.json +604 -0
  107. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.10.0.json +809 -0
  108. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.11.0.json +809 -0
  109. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.12.0.json +820 -0
  110. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.13.0.json +819 -0
  111. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.14.0.json +819 -0
  112. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.15.0.json +819 -0
  113. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.16.0.json +874 -0
  114. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.17.0.json +874 -0
  115. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.18.0.json +874 -0
  116. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.19.0.json +874 -0
  117. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.2.0.json +731 -0
  118. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.3.0.json +768 -0
  119. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.4.0.json +808 -0
  120. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.5.0.json +808 -0
  121. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.6.0.json +808 -0
  122. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.7.0.json +825 -0
  123. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.8.0.json +792 -0
  124. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.9.0.json +809 -0
  125. package/src/assets/adapters/reddit/wrench-web-adapter.json +855 -0
  126. package/src/assets/adapters/reddit/wrench-web-adapter.v1.0.0.json +577 -0
  127. package/src/assets/adapters/reddit/wrench-web-adapter.v1.1.0.json +604 -0
  128. package/src/assets/adapters/reddit/wrench-web-adapter.v1.10.0.json +712 -0
  129. package/src/assets/adapters/reddit/wrench-web-adapter.v1.11.0.json +712 -0
  130. package/src/assets/adapters/reddit/wrench-web-adapter.v1.12.0.json +712 -0
  131. package/src/assets/adapters/reddit/wrench-web-adapter.v1.2.0.json +653 -0
  132. package/src/assets/adapters/reddit/wrench-web-adapter.v1.3.0.json +712 -0
  133. package/src/assets/adapters/reddit/wrench-web-adapter.v1.4.0.json +713 -0
  134. package/src/assets/adapters/reddit/wrench-web-adapter.v1.5.0.json +712 -0
  135. package/src/assets/adapters/reddit/wrench-web-adapter.v1.6.0.json +713 -0
  136. package/src/assets/adapters/reddit/wrench-web-adapter.v1.7.0.json +713 -0
  137. package/src/assets/adapters/reddit/wrench-web-adapter.v1.8.0.json +713 -0
  138. package/src/assets/adapters/reddit/wrench-web-adapter.v1.9.0.json +713 -0
  139. package/src/assets/adapters/substack/wrench-web-adapter.json +936 -0
  140. package/src/assets/adapters/substack/wrench-web-adapter.v1.0.0.json +817 -0
  141. package/src/assets/adapters/substack/wrench-web-adapter.v1.1.0.json +812 -0
  142. package/src/assets/adapters/substack/wrench-web-adapter.v1.2.0.json +812 -0
  143. package/src/assets/adapters/substack/wrench-web-adapter.v1.3.0.json +902 -0
  144. package/src/assets/adapters/substack/wrench-web-adapter.v1.4.0.json +936 -0
  145. package/src/assets/adapters/substack/wrench-web-adapter.v1.5.0.json +936 -0
  146. package/src/assets/adapters/substack/wrench-web-adapter.v1.6.0.json +936 -0
  147. package/src/assets/adapters/threads/wrench-web-adapter.json +692 -0
  148. package/src/assets/adapters/threads/wrench-web-adapter.v1.0.0.json +629 -0
  149. package/src/assets/adapters/threads/wrench-web-adapter.v1.1.0.json +623 -0
  150. package/src/assets/adapters/threads/wrench-web-adapter.v1.2.0.json +622 -0
  151. package/src/assets/adapters/threads/wrench-web-adapter.v1.3.0.json +622 -0
  152. package/src/assets/adapters/threads/wrench-web-adapter.v1.4.0.json +622 -0
  153. package/src/assets/adapters/threads/wrench-web-adapter.v1.5.0.json +699 -0
  154. package/src/assets/adapters/threads/wrench-web-adapter.v1.6.0.json +693 -0
  155. package/src/assets/adapters/threads/wrench-web-adapter.v1.7.0.json +692 -0
  156. package/src/assets/adapters/tiktok/wrench-web-adapter.json +762 -0
  157. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.0.0.json +681 -0
  158. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.1.0.json +708 -0
  159. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.2.0.json +762 -0
  160. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.3.0.json +762 -0
  161. package/src/assets/adapters/twitch/wrench-web-adapter.json +42 -0
  162. package/src/assets/adapters/twitch/wrench-web-adapter.v1.0.0.json +42 -0
  163. package/src/assets/adapters/whatsapp/wrench-web-adapter.json +400 -0
  164. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.0.0.json +355 -0
  165. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.1.0.json +386 -0
  166. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.2.0.json +386 -0
  167. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.3.0.json +400 -0
  168. package/src/assets/adapters/x/wrench-adapter.json +749 -0
  169. package/src/assets/adapters/x/wrench-adapter.v1.0.0.json +700 -0
  170. package/src/assets/adapters/x/wrench-adapter.v1.1.0.json +707 -0
  171. package/src/assets/adapters/x/wrench-web-adapter.json +712 -0
  172. package/src/assets/adapters/x/wrench-web-adapter.v1.1.0.json +579 -0
  173. package/src/assets/adapters/x/wrench-web-adapter.v1.10.0.json +685 -0
  174. package/src/assets/adapters/x/wrench-web-adapter.v1.11.0.json +712 -0
  175. package/src/assets/adapters/x/wrench-web-adapter.v1.12.0.json +712 -0
  176. package/src/assets/adapters/x/wrench-web-adapter.v1.13.0.json +712 -0
  177. package/src/assets/adapters/x/wrench-web-adapter.v1.2.0.json +578 -0
  178. package/src/assets/adapters/x/wrench-web-adapter.v1.3.0.json +610 -0
  179. package/src/assets/adapters/x/wrench-web-adapter.v1.4.0.json +617 -0
  180. package/src/assets/adapters/x/wrench-web-adapter.v1.5.0.json +634 -0
  181. package/src/assets/adapters/x/wrench-web-adapter.v1.6.0.json +632 -0
  182. package/src/assets/adapters/x/wrench-web-adapter.v1.7.0.json +649 -0
  183. package/src/assets/adapters/x/wrench-web-adapter.v1.8.0.json +649 -0
  184. package/src/assets/adapters/x/wrench-web-adapter.v1.9.0.json +683 -0
  185. package/src/assets/adapters/youtube/wrench-web-adapter.json +561 -0
  186. package/src/assets/adapters/youtube/wrench-web-adapter.v1.0.0.json +465 -0
  187. package/src/assets/adapters/youtube/wrench-web-adapter.v1.1.0.json +519 -0
  188. package/src/assets/adapters/youtube/wrench-web-adapter.v1.2.0.json +561 -0
  189. package/src/assets/adapters/youtube/wrench-web-adapter.v1.3.0.json +561 -0
  190. package/src/assets/code-owned-provider-template/plugin.test.ts.template +54 -0
  191. package/src/assets/code-owned-provider-template/plugin.ts.template +102 -0
  192. package/src/assets/code-owned-provider-template/runtime.internal.test.ts.template +44 -0
  193. package/src/assets/code-owned-provider-template/runtime.ts.template +59 -0
  194. package/src/assets/code-owned-provider-template/wrench-adapter.json.template +33 -0
  195. package/src/assets/fonts/nebula-sans/LICENSE.txt +96 -0
  196. package/src/assets/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
  197. package/src/assets/fonts/nebula-sans/PROVENANCE.md +14 -0
  198. package/src/assets.d.ts +4 -0
  199. package/src/auth.ts +1157 -0
  200. package/src/beeper-client-types.ts +169 -0
  201. package/src/beeper-client.ts +250 -0
  202. package/src/beeper-contact-interactions-cli.ts +180 -0
  203. package/src/beeper-contact-interactions.ts +1612 -0
  204. package/src/beeper-message-bundle-v1.ts +257 -0
  205. package/src/beeper-message-like-me-cli.ts +84 -0
  206. package/src/beeper-message-like-me-export.ts +2274 -0
  207. package/src/beeper-message-like-me-recovery.ts +931 -0
  208. package/src/beeper-message-like-me-source.ts +4034 -0
  209. package/src/browser-admission.ts +928 -0
  210. package/src/browser-snapshots.ts +260 -0
  211. package/src/browser.ts +3873 -0
  212. package/src/canonical-json.ts +60 -0
  213. package/src/catalog-cli.ts +638 -0
  214. package/src/cli.ts +208 -0
  215. package/src/client-types.ts +301 -0
  216. package/src/client.ts +2726 -0
  217. package/src/confirmed-write-failure.ts +44 -0
  218. package/src/confirmed-write-model.ts +72 -0
  219. package/src/confirmed-write-platform.ts +1055 -0
  220. package/src/confirmed-write-program.ts +224 -0
  221. package/src/confirmed-write-runtime.ts +16 -0
  222. package/src/cursor-token.ts +228 -0
  223. package/src/derivation-dnr-readiness.ts +367 -0
  224. package/src/derivation-file-chooser.ts +384 -0
  225. package/src/derivation-network-boundary.ts +835 -0
  226. package/src/derivation-network-guard.ts +782 -0
  227. package/src/derivation-network-proxy-helper.ts +440 -0
  228. package/src/derivation-network-proxy.ts +385 -0
  229. package/src/derive-command-helper.ts +693 -0
  230. package/src/derive-fixtures.ts +343 -0
  231. package/src/derive-review.ts +980 -0
  232. package/src/derive.ts +4754 -0
  233. package/src/fixtures/beeper-message-like-me-v1/accounts.ndjson +2 -0
  234. package/src/fixtures/beeper-message-like-me-v1/conversations.ndjson +1 -0
  235. package/src/fixtures/beeper-message-like-me-v1/manifest.json +1 -0
  236. package/src/fixtures/beeper-message-like-me-v1/messages.ndjson +2 -0
  237. package/src/fixtures/beeper-message-like-me-v1/participants.ndjson +3 -0
  238. package/src/fixtures/beeper-message-like-me-v1/reactions.ndjson +1 -0
  239. package/src/fixtures/beeper-message-like-me-v1/tombstones.ndjson +1 -0
  240. package/src/ghostget.ts +3672 -0
  241. package/src/gmail-capture.ts +1244 -0
  242. package/src/har-internal.ts +1242 -0
  243. package/src/har.ts +743 -0
  244. package/src/index.ts +218 -0
  245. package/src/invocation-read-platform.ts +55 -0
  246. package/src/invocation-read-program.ts +93 -0
  247. package/src/invocation-read-runtime.ts +11 -0
  248. package/src/linked-device-lifecycle-admission.ts +450 -0
  249. package/src/linked-device-lifecycle-journal.ts +1560 -0
  250. package/src/linked-device-lifecycle-runtime.ts +1602 -0
  251. package/src/local-cli-admission.ts +135 -0
  252. package/src/local-cli-contracts.ts +271 -0
  253. package/src/local-cli-execution.ts +98 -0
  254. package/src/local-cli-surface-contract.ts +1510 -0
  255. package/src/local-cli-tool-identity.ts +336 -0
  256. package/src/media/archive.ts +2325 -0
  257. package/src/media/args.ts +303 -0
  258. package/src/media/cli.ts +287 -0
  259. package/src/media/doctor.ts +475 -0
  260. package/src/media/ffmpeg.ts +1330 -0
  261. package/src/media/http-capture.ts +711 -0
  262. package/src/media/http-probe.ts +593 -0
  263. package/src/media/http.ts +387 -0
  264. package/src/media/index.ts +10 -0
  265. package/src/media/local-transcription.ts +613 -0
  266. package/src/media/lock.ts +321 -0
  267. package/src/media/manifest.ts +1712 -0
  268. package/src/media/metadata.ts +906 -0
  269. package/src/media/process.ts +723 -0
  270. package/src/media/revision.ts +166 -0
  271. package/src/media/runtime-closure.ts +1063 -0
  272. package/src/media/source-router.ts +187 -0
  273. package/src/media/transcriber-config.ts +1122 -0
  274. package/src/media/transcript-persistence-model.ts +15 -0
  275. package/src/media/transcript-persistence-platform.ts +89 -0
  276. package/src/media/transcript-persistence-program.ts +39 -0
  277. package/src/media/transcript-persistence-runtime.ts +24 -0
  278. package/src/media/transcript.ts +616 -0
  279. package/src/media/utf8-order.ts +30 -0
  280. package/src/media/whisper-cpp.ts +464 -0
  281. package/src/media/whisper-language.ts +58 -0
  282. package/src/media/yt-dlp.ts +439 -0
  283. package/src/message-like-me-agentic-messaging.ts +882 -0
  284. package/src/messaging-action-store.ts +793 -0
  285. package/src/messaging-runtime.ts +2136 -0
  286. package/src/messaging-store.ts +539 -0
  287. package/src/messaging-types.ts +1663 -0
  288. package/src/messaging.ts +334 -0
  289. package/src/model.ts +2671 -0
  290. package/src/oauth-google.ts +888 -0
  291. package/src/omni-client-types.ts +306 -0
  292. package/src/omni-client.ts +2225 -0
  293. package/src/omni-limits.ts +34 -0
  294. package/src/omni-model.ts +1614 -0
  295. package/src/omni-request.ts +379 -0
  296. package/src/omni-runtime.ts +1632 -0
  297. package/src/operation-deadline.ts +189 -0
  298. package/src/path-helper.ts +1656 -0
  299. package/src/pinned-https.ts +194 -0
  300. package/src/plan-assets.ts +832 -0
  301. package/src/platform-catalog.ts +1832 -0
  302. package/src/plugins/beeper-linked-device/plugin.ts +310 -0
  303. package/src/plugins/bluesky-web/plugin.ts +178 -0
  304. package/src/plugins/clasificados-web/plugin.ts +66 -0
  305. package/src/plugins/github-web/plugin.ts +80 -0
  306. package/src/plugins/gmail-official/plugin.ts +110 -0
  307. package/src/plugins/hacker-news-web/plugin.ts +52 -0
  308. package/src/plugins/imessage-direct/plugin.ts +195 -0
  309. package/src/plugins/imessage-direct/vendor/0001-fix-keep-AppleScript-send-payloads-out-of-child-argv.patch +1430 -0
  310. package/src/plugins/imessage-direct/vendor/0002-feat-rpc-add-exact-chat-lookup.patch +225 -0
  311. package/src/plugins/imessage-direct/vendor/provenance.json +80 -0
  312. package/src/plugins/linkedin-official/plugin.ts +84 -0
  313. package/src/plugins/linkedin-web/plugin.ts +412 -0
  314. package/src/plugins/meta-web/plugin.ts +317 -0
  315. package/src/plugins/reddit-web/flair.ts +492 -0
  316. package/src/plugins/reddit-web/plugin.ts +477 -0
  317. package/src/plugins/substack-web/plugin.ts +124 -0
  318. package/src/plugins/tiktok-web/plugin.ts +67 -0
  319. package/src/plugins/twitch-web/plugin.ts +54 -0
  320. package/src/plugins/whatsapp-linked-device/plugin.ts +105 -0
  321. package/src/plugins/x-official/plugin.ts +64 -0
  322. package/src/plugins/x-web/plugin.ts +488 -0
  323. package/src/plugins/youtube-web/plugin.ts +101 -0
  324. package/src/portable-run-recovery.ts +466 -0
  325. package/src/process-identity.ts +501 -0
  326. package/src/profile-clone-helper.ts +104 -0
  327. package/src/provider-catalog-views.ts +51 -0
  328. package/src/provider-context.ts +69 -0
  329. package/src/provider-contract-definitions-x.ts +45 -0
  330. package/src/provider-contract-definitions.ts +948 -0
  331. package/src/provider-contract-input-gmail.ts +176 -0
  332. package/src/provider-contract-input-linkedin.ts +77 -0
  333. package/src/provider-contract-input-x.ts +304 -0
  334. package/src/provider-contract-planning.ts +23 -0
  335. package/src/provider-contract-semantic-identity.ts +54 -0
  336. package/src/provider-contracts.ts +251 -0
  337. package/src/provider-http.ts +723 -0
  338. package/src/provider-plugin-auth.ts +36 -0
  339. package/src/provider-plugin-builtins.ts +229 -0
  340. package/src/provider-plugin-cleanup-barrier.ts +229 -0
  341. package/src/provider-plugin-cleanup-execution.ts +79 -0
  342. package/src/provider-plugin-cleanup-resource.ts +470 -0
  343. package/src/provider-plugin-contract-identity.ts +707 -0
  344. package/src/provider-plugin-host.ts +1972 -0
  345. package/src/provider-plugin-identifiers.ts +60 -0
  346. package/src/provider-plugin-invocation-lease.ts +889 -0
  347. package/src/provider-plugin-lifecycle-kernel.ts +566 -0
  348. package/src/provider-plugin-lifecycle.ts +1455 -0
  349. package/src/provider-plugin-module-analysis.ts +336 -0
  350. package/src/provider-plugin-package.ts +2530 -0
  351. package/src/provider-plugin-portable-authority.ts +106 -0
  352. package/src/provider-plugin-portable-catalog.ts +94 -0
  353. package/src/provider-plugin-portable-identity.ts +315 -0
  354. package/src/provider-plugin-portable-runtime.ts +2057 -0
  355. package/src/provider-plugin-protocol.ts +1625 -0
  356. package/src/provider-plugin-registry.ts +3342 -0
  357. package/src/provider-plugin-store.ts +2155 -0
  358. package/src/provider-plugin-test-fixture.ts +5 -0
  359. package/src/provider-plugin.ts +4907 -0
  360. package/src/provider-plugins.generated.ts +43 -0
  361. package/src/provider-plugins.ts +8 -0
  362. package/src/provider-subject.ts +41 -0
  363. package/src/provider.ts +315 -0
  364. package/src/providers/beeper-local-runtime.ts +5426 -0
  365. package/src/providers/beeper-local.ts +2739 -0
  366. package/src/providers/beeper-messaging.ts +462 -0
  367. package/src/providers/beeper-omni.ts +1159 -0
  368. package/src/providers/bluesky-web-runtime.ts +2895 -0
  369. package/src/providers/bluesky-web.ts +1614 -0
  370. package/src/providers/clasificados-web-runtime.ts +255 -0
  371. package/src/providers/clasificados-web.ts +503 -0
  372. package/src/providers/contact-projection.ts +301 -0
  373. package/src/providers/github-read-model.ts +94 -0
  374. package/src/providers/github-read-platform.ts +165 -0
  375. package/src/providers/github-read-program.ts +97 -0
  376. package/src/providers/github-web-runtime.ts +320 -0
  377. package/src/providers/github-web.ts +403 -0
  378. package/src/providers/gmail-api.ts +2834 -0
  379. package/src/providers/gmail-omni.ts +830 -0
  380. package/src/providers/gmail.ts +1130 -0
  381. package/src/providers/hacker-news-web-runtime.ts +220 -0
  382. package/src/providers/hacker-news-web.ts +809 -0
  383. package/src/providers/imessage-direct-install.ts +354 -0
  384. package/src/providers/imessage-direct-messaging.ts +532 -0
  385. package/src/providers/imessage-direct-omni.ts +400 -0
  386. package/src/providers/imessage-direct-runtime.ts +1498 -0
  387. package/src/providers/imessage-direct.ts +351 -0
  388. package/src/providers/instagram-video-foundations.ts +913 -0
  389. package/src/providers/instagram-web-profile-browser.ts +600 -0
  390. package/src/providers/iso-bmff.ts +338 -0
  391. package/src/providers/linkedin-company-platform.ts +42 -0
  392. package/src/providers/linkedin-company-program.ts +88 -0
  393. package/src/providers/linkedin-contact-failure.ts +84 -0
  394. package/src/providers/linkedin-contact-platform.ts +45 -0
  395. package/src/providers/linkedin-contact-program.ts +91 -0
  396. package/src/providers/linkedin-profile-activity-failure.ts +76 -0
  397. package/src/providers/linkedin-profile-activity-platform.ts +41 -0
  398. package/src/providers/linkedin-profile-activity-program.ts +67 -0
  399. package/src/providers/linkedin-read-failure.ts +52 -0
  400. package/src/providers/linkedin-self-platform.ts +53 -0
  401. package/src/providers/linkedin-self-program.ts +122 -0
  402. package/src/providers/linkedin-web-article-browser.ts +794 -0
  403. package/src/providers/linkedin-web-bootstrap.ts +146 -0
  404. package/src/providers/linkedin-web-contact.ts +830 -0
  405. package/src/providers/linkedin-web-feed-browser.ts +832 -0
  406. package/src/providers/linkedin-web-feed.ts +864 -0
  407. package/src/providers/linkedin-web-post-browser.ts +653 -0
  408. package/src/providers/linkedin-web-profile-browser.ts +683 -0
  409. package/src/providers/linkedin-web-runtime.ts +2388 -0
  410. package/src/providers/linkedin-web.ts +3305 -0
  411. package/src/providers/linkedin.ts +1885 -0
  412. package/src/providers/meta-bootstrap.ts +1043 -0
  413. package/src/providers/meta-facebook-group.ts +690 -0
  414. package/src/providers/meta-marketplace-relay.ts +747 -0
  415. package/src/providers/meta-omni.ts +373 -0
  416. package/src/providers/meta-relay-bundle.ts +1049 -0
  417. package/src/providers/meta-web-descriptors.ts +1740 -0
  418. package/src/providers/meta-web-runtime.ts +3388 -0
  419. package/src/providers/meta-web.ts +2931 -0
  420. package/src/providers/read-failure.ts +139 -0
  421. package/src/providers/reddit-omni.ts +462 -0
  422. package/src/providers/reddit-web-runtime.ts +1929 -0
  423. package/src/providers/reddit-web.ts +1611 -0
  424. package/src/providers/rental-listings.ts +460 -0
  425. package/src/providers/substack-omni.ts +406 -0
  426. package/src/providers/substack-video-mp4.ts +33 -0
  427. package/src/providers/substack-web-runtime.ts +2812 -0
  428. package/src/providers/substack-web.ts +1502 -0
  429. package/src/providers/tiktok-video-mp4.ts +33 -0
  430. package/src/providers/tiktok-web-runtime.ts +770 -0
  431. package/src/providers/tiktok-web.ts +2786 -0
  432. package/src/providers/twitch-web-runtime.ts +226 -0
  433. package/src/providers/twitch-web.ts +274 -0
  434. package/src/providers/whatsapp-account-identity.ts +55 -0
  435. package/src/providers/whatsapp-contact-projection-helper.ts +874 -0
  436. package/src/providers/whatsapp-contact-projection-protocol.ts +403 -0
  437. package/src/providers/whatsapp-interaction-projection-helper.ts +1452 -0
  438. package/src/providers/whatsapp-interaction-projection-protocol.ts +462 -0
  439. package/src/providers/whatsapp-message-export-projection-protocol.ts +532 -0
  440. package/src/providers/whatsapp-messaging.ts +132 -0
  441. package/src/providers/whatsapp-omni.ts +586 -0
  442. package/src/providers/whatsapp-web-runtime.ts +3108 -0
  443. package/src/providers/whatsapp-web.ts +1037 -0
  444. package/src/providers/x-image-provenance.ts +523 -0
  445. package/src/providers/x-made-with-ai.ts +106 -0
  446. package/src/providers/x-transaction-id.ts +413 -0
  447. package/src/providers/x-web-runtime.ts +3485 -0
  448. package/src/providers/x-web.ts +2332 -0
  449. package/src/providers/x.ts +1889 -0
  450. package/src/providers/youtube-web-runtime.ts +1400 -0
  451. package/src/providers/youtube-web.ts +1221 -0
  452. package/src/read-admission-runtime.ts +91 -0
  453. package/src/read-client.ts +521 -0
  454. package/src/read-effect-platform.ts +7 -0
  455. package/src/read-effect-runtime.ts +14 -0
  456. package/src/read-effect.ts +52 -0
  457. package/src/read-projection-admission.ts +866 -0
  458. package/src/read-projections.ts +3151 -0
  459. package/src/recovery.ts +1492 -0
  460. package/src/reviewed-template.ts +146 -0
  461. package/src/run-journal.ts +1272 -0
  462. package/src/runtime.ts +5116 -0
  463. package/src/scripts/generate-provider-plugin-catalog.ts +121 -0
  464. package/src/scripts/install-whatsapp-protocol.sh +232 -0
  465. package/src/scripts/recover-install-stages.ts +1491 -0
  466. package/src/scripts/resolve-state-home.ts +30 -0
  467. package/src/scripts/scaffold-web-provider.ts +973 -0
  468. package/src/scripts/sync-bundled-adapters.ts +695 -0
  469. package/src/session-secrets.ts +1183 -0
  470. package/src/state-helper.bunfig.toml +2 -0
  471. package/src/state-helper.ts +2914 -0
  472. package/src/storage.ts +3842 -0
  473. package/src/transport-policy.ts +2 -0
  474. package/src/usage.ts +182 -0
  475. package/src/version.ts +2 -0
  476. package/src/web-session-authentication-policy.ts +136 -0
  477. package/src/web-session-cleanup-admission.ts +2344 -0
  478. package/src/web-session-cleanup-join.ts +99 -0
  479. package/src/web-session-client.ts +996 -0
  480. package/src/web-session-contract-definitions.ts +820 -0
  481. package/src/web-session-contract-planning.ts +42 -0
  482. package/src/web-session-contracts.ts +228 -0
  483. package/src/web-session-cookies.ts +500 -0
  484. package/src/web-session-execution.ts +385 -0
  485. package/src/web-session-http.ts +373 -0
  486. package/src/web-session-read-errors.ts +19 -0
  487. package/src/web-session-read-runtime.ts +124 -0
  488. package/src/web-session-recovery.ts +818 -0
  489. package/src/web-session-sites.ts +58 -0
  490. package/src/web-session-template.ts +994 -0
  491. package/src/web-session.ts +93 -0
  492. package/src/whatsapp-client-binding.ts +21 -0
  493. package/src/whatsapp-client-types.ts +75 -0
  494. package/src/whatsapp-client.ts +395 -0
  495. package/src/whatsapp-export-coordinate.ts +17 -0
  496. package/src/whatsapp-message-bundle-v2.ts +195 -0
  497. package/src/whatsapp-message-like-me-cli.ts +195 -0
  498. package/src/whatsapp-message-like-me-export.ts +1072 -0
  499. package/src/whatsapp-message-like-me-source.ts +1012 -0
  500. package/tsconfig.json +67 -0
@@ -0,0 +1,2834 @@
1
+ import { types as nodeTypes } from "node:util";
2
+
3
+ import { bearerHeaders, type ProviderHttpClient } from "../provider-http";
4
+ import { isGmailAccountSubject } from "../provider-subject";
5
+
6
+ const GMAIL_API_ORIGIN = "https://gmail.googleapis.com";
7
+ const GMAIL_API_HOSTS = ["gmail.googleapis.com"] as const;
8
+ const PEOPLE_API_ORIGIN = "https://people.googleapis.com";
9
+ const PEOPLE_API_HOSTS = ["people.googleapis.com"] as const;
10
+ const GMAIL_WEB_ORIGIN = "https://mail.google.com";
11
+ const MAX_RESPONSE_TEXT_BYTES = 8 * 1024 * 1024;
12
+ const MAX_CONFIGURED_RESPONSE_TEXT_BYTES = 64 * 1024 * 1024;
13
+ const MAX_DIRECT_BODY_BYTES = 7 * 1024 * 1024;
14
+ const MAX_MIME_PARTS = 10_000;
15
+ const MAX_MIME_DEPTH = 32;
16
+ const MAX_MIME_ATTACHMENTS = 512;
17
+ const MAX_HEADERS = 2_048;
18
+ const MAX_HEADER_BYTES = 64 * 1024;
19
+ const MAX_ATTACHMENT_RESPONSE_ENVELOPE_BYTES = 32 * 1024;
20
+ const HTML_DEADLINE_CHECKPOINT_CODE_UNITS = 4_096;
21
+
22
+ const GMAIL_PROFILE_FIELDS = "emailAddress,messagesTotal,threadsTotal,historyId";
23
+ const GMAIL_SEND_AS_FIELDS = "sendAs(sendAsEmail,verificationStatus)";
24
+ const GMAIL_THREAD_LIST_FIELDS = "threads(id,snippet,historyId),nextPageToken,resultSizeEstimate";
25
+ const GMAIL_MESSAGE_LIST_FIELDS = "messages(id,threadId),nextPageToken,resultSizeEstimate";
26
+ const GMAIL_MESSAGE_FIELDS = "id,threadId,labelIds,snippet,historyId,internalDate,payload(partId,mimeType,filename,headers(name,value),body(attachmentId,size,data),parts),sizeEstimate";
27
+ const GMAIL_INTERACTION_MESSAGE_FIELDS = "id,threadId,labelIds,internalDate,payload(headers(name,value))";
28
+ const GMAIL_THREAD_FIELDS = `id,snippet,historyId,messages(${GMAIL_MESSAGE_FIELDS})`;
29
+ const GMAIL_ATTACHMENT_FIELDS = "attachmentId,size,data";
30
+ const PEOPLE_CONNECTION_LIST_FIELDS = "connections(resourceName),nextPageToken,totalItems";
31
+ const PEOPLE_CORE_PERSON_FIELDS = "metadata,names,emailAddresses,phoneNumbers,organizations,photos";
32
+ const PEOPLE_DATES_PERSON_FIELDS = `${PEOPLE_CORE_PERSON_FIELDS},birthdays,events`;
33
+ const PEOPLE_CORE_PERSON_PROJECTION = "resourceName,etag,metadata(sources(type,id,etag,updateTime),deleted),names(displayName,givenName,familyName,metadata(primary,sourcePrimary,verified,source(type,id))),emailAddresses(value,type,metadata(primary,sourcePrimary,verified,source(type,id))),phoneNumbers(value,type,canonicalForm,metadata(primary,sourcePrimary,verified,source(type,id))),organizations(name,title,department,type,current),photos(url,default)";
34
+ const PEOPLE_DATES_PERSON_PROJECTION = "resourceName,etag,metadata(sources(type,id,etag,updateTime),deleted),names(displayName,givenName,middleName,familyName,honorificPrefix,honorificSuffix,metadata(primary,sourcePrimary,verified,source(type,id))),emailAddresses(value,type,metadata(primary,sourcePrimary,verified,source(type,id))),phoneNumbers(value,type,canonicalForm,metadata(primary,sourcePrimary,verified,source(type,id))),organizations(name,title,department,type,current),photos(url,default),birthdays(date(year,month,day),text,metadata(primary,sourcePrimary,verified,source(type,id))),events(date(year,month,day),type,formattedType,metadata(primary,sourcePrimary,verified,source(type,id)))";
35
+ const PEOPLE_OTHER_PERSON_PROJECTION = "resourceName,etag,metadata(sources(type,id,etag,updateTime),deleted),names(displayName,givenName,familyName,metadata(primary,sourcePrimary,verified,source(type,id))),emailAddresses(value,type,metadata(primary,sourcePrimary,verified,source(type,id))),phoneNumbers(value,type,canonicalForm,metadata(primary,sourcePrimary,verified,source(type,id))),photos(url,default)";
36
+ const PEOPLE_OTHER_LIST_FIELDS = `otherContacts(${PEOPLE_OTHER_PERSON_PROJECTION}),nextPageToken,totalSize`;
37
+
38
+ const inlineAttachmentBytes = new WeakMap<GmailAttachment, Uint8Array>();
39
+ const parsedThreadBodies = new WeakMap<GmailThread, ParsedThreadBodyState>();
40
+
41
+ type JsonRecord = Readonly<Record<string, unknown>>;
42
+
43
+ export type GmailApiClient = Readonly<{
44
+ http: ProviderHttpClient;
45
+ accessToken: string;
46
+ /** Canonical Gmail email address declared by the OAuth locator. */
47
+ subject: string;
48
+ }>;
49
+
50
+ export type GmailProfile = Readonly<{
51
+ emailAddress: string;
52
+ messagesTotal: number;
53
+ threadsTotal: number;
54
+ historyId: string;
55
+ }>;
56
+
57
+ /** List every configured primary or custom send-as address for self exclusion. */
58
+ export async function listGmailSendAsAliases(
59
+ client: GmailApiClient,
60
+ maximumResponseBytes?: number,
61
+ ): Promise<readonly string[]> {
62
+ const url = gmailUrl("/gmail/v1/users/me/settings/sendAs");
63
+ url.searchParams.set("fields", GMAIL_SEND_AS_FIELDS);
64
+ const source = record(
65
+ await gmailGet(client, url, maximumResponseBytes),
66
+ "send-as aliases response",
67
+ );
68
+ exactKeys(source, ["sendAs"], [], "send-as aliases response");
69
+ if (!Array.isArray(source.sendAs) || source.sendAs.length < 1 || source.sendAs.length > 100) {
70
+ return fail("send-as aliases response.sendAs", "must contain 1-100 aliases");
71
+ }
72
+ const aliases = new Set<string>();
73
+ for (const [index, value] of source.sendAs.entries()) {
74
+ const alias = record(value, `send-as aliases response.sendAs[${index}]`);
75
+ exactKeys(
76
+ alias,
77
+ ["sendAsEmail"],
78
+ ["verificationStatus"],
79
+ `send-as aliases response.sendAs[${index}]`,
80
+ );
81
+ const email = text(
82
+ alias.sendAsEmail,
83
+ `send-as aliases response.sendAs[${index}].sendAsEmail`,
84
+ 254,
85
+ ).toLowerCase();
86
+ if (!isGmailAccountSubject(email)) {
87
+ return fail(
88
+ `send-as aliases response.sendAs[${index}].sendAsEmail`,
89
+ "must be an exact email address",
90
+ );
91
+ }
92
+ if (
93
+ alias.verificationStatus !== undefined
94
+ && alias.verificationStatus !== "accepted"
95
+ && alias.verificationStatus !== "pending"
96
+ && alias.verificationStatus !== "verificationStatusUnspecified"
97
+ ) {
98
+ return fail(
99
+ `send-as aliases response.sendAs[${index}].verificationStatus`,
100
+ "is unsupported",
101
+ );
102
+ }
103
+ aliases.add(email);
104
+ }
105
+ if (!aliases.has(client.subject.toLowerCase())) {
106
+ return fail("send-as aliases response", "does not contain the authenticated subject");
107
+ }
108
+ return Object.freeze([...aliases].sort());
109
+ }
110
+
111
+ export type GmailThreadView = "all" | "inbox";
112
+
113
+ export type ParsedGmailThreadUrl = Readonly<{
114
+ accountLocator: string;
115
+ /** Canonical navigation view. Non-inbox source views normalize to all. */
116
+ view: GmailThreadView;
117
+ /** Exact reviewed source-view prefix observed in the copied URL. */
118
+ sourceView?: string;
119
+ threadId: string;
120
+ canonicalUrl: string;
121
+ }>;
122
+
123
+ export type GmailAttachment = Readonly<{
124
+ attachmentId: string | null;
125
+ /** Exact Gmail MIME-part identity, unique within its parent message. */
126
+ partId: string;
127
+ messageId: string;
128
+ filename: string;
129
+ mimeType: string;
130
+ /** Validated and case-normalized MIME disposition type, when declared. */
131
+ contentDisposition: "attachment" | "inline" | null;
132
+ size: number;
133
+ }>;
134
+
135
+ export type GmailThreadParseOptions = Readonly<{
136
+ /** Root is depth zero. The reviewed implementation permits at most 32. */
137
+ maxDepth?: number;
138
+ /** Aggregate decoded MIME and render-safe UTF-8 body bytes across the thread. */
139
+ maxBodyBytes?: number;
140
+ /** Synchronous operation-deadline checkpoint used during bounded MIME/HTML projection. */
141
+ deadlineCheckpoint?: () => void;
142
+ }>;
143
+
144
+ /** One bounded render-safe body projection with an explicit MIME basis. */
145
+ export type GmailBodyProjection = Readonly<{
146
+ text: string | null;
147
+ source: "text/plain" | "text/html" | "none";
148
+ }>;
149
+
150
+ export type GmailMessage = Readonly<{
151
+ id: string;
152
+ threadId: string | null;
153
+ historyId: string | null;
154
+ internalDate: string | null;
155
+ labelIds: readonly string[];
156
+ snippet: string | null;
157
+ from: string | null;
158
+ to: string | null;
159
+ cc: string | null;
160
+ bcc: string | null;
161
+ subject: string | null;
162
+ date: string | null;
163
+ messageId: string | null;
164
+ inReplyTo: string | null;
165
+ body: GmailBodyProjection;
166
+ attachments: readonly GmailAttachment[];
167
+ }>;
168
+
169
+ export type GmailThread = Readonly<{
170
+ id: string;
171
+ snippet: string | null;
172
+ historyId: string | null;
173
+ messages: readonly GmailMessage[];
174
+ }>;
175
+
176
+ export type GmailThreadStub = Readonly<{
177
+ id: string;
178
+ snippet: string | null;
179
+ historyId: string | null;
180
+ }>;
181
+
182
+ export type GmailMessageStub = Readonly<{
183
+ id: string;
184
+ threadId: string | null;
185
+ }>;
186
+
187
+ export type GmailThreadListPage = Readonly<{
188
+ threads: readonly GmailThreadStub[];
189
+ nextPageToken: string | null;
190
+ resultSizeEstimate: number;
191
+ }>;
192
+
193
+ export type GmailMessageListPage = Readonly<{
194
+ messages: readonly GmailMessageStub[];
195
+ nextPageToken: string | null;
196
+ resultSizeEstimate: number;
197
+ }>;
198
+
199
+ export type GmailContact = Readonly<{
200
+ resourceName: string;
201
+ etag: string | null;
202
+ metadata: Readonly<{
203
+ deleted: boolean | null;
204
+ sources: readonly GmailContactSource[];
205
+ }> | null;
206
+ displayName: string | null;
207
+ name?: Readonly<{
208
+ displayName: string | null;
209
+ givenName: string | null;
210
+ middleName: string | null;
211
+ familyName: string | null;
212
+ honorificPrefix: string | null;
213
+ honorificSuffix: string | null;
214
+ metadata: GmailContactFieldMetadata | null;
215
+ }> | null;
216
+ emailAddresses: readonly Readonly<{
217
+ /** Exact People API value retained for display and audit. */
218
+ value: string;
219
+ /** Case-folded validated address used only for dedupe and Gmail search. */
220
+ canonicalValue: string | null;
221
+ type: string | null;
222
+ metadata: GmailContactFieldMetadata | null;
223
+ }>[];
224
+ phoneNumbers: readonly Readonly<{
225
+ value: string;
226
+ canonicalForm: string | null;
227
+ type: string | null;
228
+ metadata: GmailContactFieldMetadata | null;
229
+ }>[];
230
+ organizations: readonly Readonly<{
231
+ name: string | null;
232
+ title: string | null;
233
+ department: string | null;
234
+ type: string | null;
235
+ current: boolean | null;
236
+ }>[];
237
+ photoUrl: string | null;
238
+ birthdays?: readonly GmailContactDate[];
239
+ events?: readonly Readonly<GmailContactDate & {
240
+ type: string | null;
241
+ formattedType: string | null;
242
+ }>[];
243
+ }>;
244
+
245
+ export type GmailContactDate = Readonly<{
246
+ date: Readonly<{
247
+ year: number;
248
+ month: number;
249
+ day: number;
250
+ }> | null;
251
+ text: string | null;
252
+ metadata: GmailContactFieldMetadata | null;
253
+ }>;
254
+
255
+ export type GmailContactSource = Readonly<{
256
+ type: "SOURCE_TYPE_UNSPECIFIED" | "ACCOUNT" | "PROFILE" | "DOMAIN_PROFILE" | "CONTACT" | "OTHER_CONTACT" | "DOMAIN_CONTACT";
257
+ id: string;
258
+ etag: string | null;
259
+ updateTime: string | null;
260
+ }>;
261
+
262
+ export type GmailContactFieldMetadata = Readonly<{
263
+ primary: boolean | null;
264
+ sourcePrimary: boolean | null;
265
+ verified: boolean | null;
266
+ source: Readonly<Pick<GmailContactSource, "type" | "id">> | null;
267
+ }>;
268
+
269
+ export type GmailContactPage = Readonly<{
270
+ contacts: readonly GmailContact[];
271
+ nextPageToken: string | null;
272
+ totalItems: number | null;
273
+ }>;
274
+
275
+ export type GmailContactCollection = "contacts" | "other-contacts";
276
+ export type GmailContactProjection = "core" | "dates";
277
+
278
+ function fail(label: string, message: string): never {
279
+ throw new Error(`official Gmail ${label} ${message}`);
280
+ }
281
+
282
+ function record(value: unknown, label: string): JsonRecord {
283
+ if (
284
+ nodeTypes.isProxy(value)
285
+ || typeof value !== "object"
286
+ || value === null
287
+ || Array.isArray(value)
288
+ || (
289
+ Object.getPrototypeOf(value) !== Object.prototype
290
+ && Object.getPrototypeOf(value) !== null
291
+ )
292
+ ) return fail(label, "must be a plain object");
293
+ const descriptors = Object.getOwnPropertyDescriptors(value);
294
+ for (const key of Reflect.ownKeys(descriptors)) {
295
+ if (typeof key !== "string") return fail(label, "must not contain symbol properties");
296
+ const descriptor = descriptors[key];
297
+ if (
298
+ descriptor === undefined
299
+ || !("value" in descriptor)
300
+ || !descriptor.enumerable
301
+ ) return fail(`${label}.${key}`, "must be an enumerable data property");
302
+ }
303
+ return value as JsonRecord;
304
+ }
305
+
306
+ function exactKeys(
307
+ value: JsonRecord,
308
+ required: readonly string[],
309
+ optional: readonly string[],
310
+ label: string,
311
+ ): void {
312
+ const allowed = new Set([...required, ...optional]);
313
+ for (const key of Object.keys(value)) {
314
+ if (!allowed.has(key)) return fail(label, `contains unreviewed property ${key}`);
315
+ }
316
+ for (const key of required) {
317
+ if (!Object.hasOwn(value, key)) return fail(`${label}.${key}`, "is required");
318
+ }
319
+ }
320
+
321
+ function array(value: unknown, label: string, maximum: number): readonly unknown[] {
322
+ if (
323
+ nodeTypes.isProxy(value)
324
+ || !Array.isArray(value)
325
+ || Object.getPrototypeOf(value) !== Array.prototype
326
+ || value.length > maximum
327
+ ) return fail(label, `must be a dense array of at most ${maximum} items`);
328
+ const descriptors = Object.getOwnPropertyDescriptors(value);
329
+ const expected = new Set<PropertyKey>([
330
+ "length",
331
+ ...Array.from({ length: value.length }, (_unused, index) => String(index)),
332
+ ]);
333
+ if (
334
+ Reflect.ownKeys(descriptors).length !== expected.size
335
+ || Reflect.ownKeys(descriptors).some((key) => !expected.has(key))
336
+ ) return fail(label, "must be a dense array without named properties");
337
+ const result: unknown[] = [];
338
+ for (let index = 0; index < value.length; index += 1) {
339
+ const descriptor = descriptors[String(index)];
340
+ if (
341
+ descriptor === undefined
342
+ || !("value" in descriptor)
343
+ || !descriptor.enumerable
344
+ ) return fail(`${label}[${index}]`, "must be an enumerable data property");
345
+ result.push(descriptor.value);
346
+ }
347
+ return Object.freeze(result);
348
+ }
349
+
350
+ function hasUnsafeControl(value: string, allowLayout = false): boolean {
351
+ for (let index = 0; index < value.length; index += 1) {
352
+ const code = value.charCodeAt(index);
353
+ if (
354
+ (code <= 0x1f && !(allowLayout && (code === 0x09 || code === 0x0a || code === 0x0d)))
355
+ || code === 0x7f
356
+ || (code >= 0x80 && code <= 0x9f)
357
+ || code === 0x061c
358
+ || code === 0x200e
359
+ || code === 0x200f
360
+ || (code >= 0x2028 && code <= 0x202e)
361
+ || (code >= 0x2066 && code <= 0x2069)
362
+ ) return true;
363
+ }
364
+ return false;
365
+ }
366
+
367
+ function isWellFormedText(value: string): boolean {
368
+ for (let index = 0; index < value.length; index += 1) {
369
+ const code = value.charCodeAt(index);
370
+ if (code >= 0xd800 && code <= 0xdbff) {
371
+ const next = value.charCodeAt(index + 1);
372
+ if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
373
+ index += 1;
374
+ } else if (code >= 0xdc00 && code <= 0xdfff) return false;
375
+ }
376
+ return true;
377
+ }
378
+
379
+ function text(
380
+ value: unknown,
381
+ label: string,
382
+ maximum: number,
383
+ allowEmpty = false,
384
+ allowLayout = false,
385
+ ): string {
386
+ if (
387
+ typeof value !== "string"
388
+ || !isWellFormedText(value)
389
+ || Buffer.byteLength(value, "utf8") > maximum
390
+ || (!allowEmpty && value.length === 0)
391
+ || hasUnsafeControl(value, allowLayout)
392
+ ) return fail(label, "must be bounded text without unsafe controls");
393
+ return value;
394
+ }
395
+
396
+ function optionalText(
397
+ value: unknown,
398
+ label: string,
399
+ maximum: number,
400
+ allowEmpty = false,
401
+ allowLayout = false,
402
+ ): string | null {
403
+ return value === undefined
404
+ ? null
405
+ : text(value, label, maximum, allowEmpty, allowLayout);
406
+ }
407
+
408
+ function integer(
409
+ value: unknown,
410
+ label: string,
411
+ minimum = 0,
412
+ maximum = Number.MAX_SAFE_INTEGER,
413
+ ): number {
414
+ if (
415
+ typeof value !== "number"
416
+ || !Number.isSafeInteger(value)
417
+ || value < minimum
418
+ || value > maximum
419
+ ) return fail(label, `must be an integer from ${minimum} through ${maximum}`);
420
+ return value;
421
+ }
422
+
423
+ function optionalInteger(
424
+ value: unknown,
425
+ label: string,
426
+ minimum = 0,
427
+ maximum = Number.MAX_SAFE_INTEGER,
428
+ ): number | null {
429
+ return value === undefined ? null : integer(value, label, minimum, maximum);
430
+ }
431
+
432
+ function historyId(value: unknown, label: string): string {
433
+ const result = text(value, label, 64);
434
+ if (!/^[0-9]{1,64}$/u.test(result)) return fail(label, "must be a decimal history ID");
435
+ return result;
436
+ }
437
+
438
+ function optionalHistoryId(value: unknown, label: string): string | null {
439
+ return value === undefined ? null : historyId(value, label);
440
+ }
441
+
442
+ export function parseGmailId(value: unknown, label = "ID"): string {
443
+ const result = text(value, label, 256);
444
+ if (!/^[A-Za-z0-9_-]{1,256}$/u.test(result)) {
445
+ return fail(label, "must be an exact bounded Gmail ID");
446
+ }
447
+ return result;
448
+ }
449
+
450
+ export function parseGmailAttachmentId(value: unknown, label = "attachment ID"): string {
451
+ return text(value, label, 4_096);
452
+ }
453
+
454
+ function pageToken(value: unknown, label: string): string {
455
+ if (
456
+ typeof value !== "string"
457
+ || value.length === 0
458
+ || value.length > 4_096
459
+ || !isWellFormedText(value)
460
+ || hasUnsafeControl(value)
461
+ ) return fail(label, "must be bounded text without unsafe controls");
462
+ return value;
463
+ }
464
+
465
+ function optionalPageToken(value: unknown, label: string): string | null {
466
+ return value === undefined ? null : pageToken(value, label);
467
+ }
468
+
469
+ function encodedPathSegment(value: string): string {
470
+ return encodeURIComponent(value)
471
+ .replaceAll("!", "%21")
472
+ .replaceAll("'", "%27")
473
+ .replaceAll("(", "%28")
474
+ .replaceAll(")", "%29")
475
+ .replaceAll("*", "%2A");
476
+ }
477
+
478
+ function gmailUrl(path: string): URL {
479
+ return new URL(path, GMAIL_API_ORIGIN);
480
+ }
481
+
482
+ function peopleUrl(path: string): URL {
483
+ return new URL(path, PEOPLE_API_ORIGIN);
484
+ }
485
+
486
+ function apiHeaders(client: GmailApiClient): Headers {
487
+ return bearerHeaders(client.accessToken, { Accept: "application/json" });
488
+ }
489
+
490
+ async function gmailGet(
491
+ client: GmailApiClient,
492
+ url: URL,
493
+ maximumResponseBytes?: number,
494
+ ): Promise<unknown> {
495
+ const response = await client.http.request(
496
+ url,
497
+ { method: "GET", headers: apiHeaders(client) },
498
+ [200],
499
+ GMAIL_API_HOSTS,
500
+ maximumResponseBytes,
501
+ "application/json",
502
+ );
503
+ return response.body;
504
+ }
505
+
506
+ async function peopleGet(client: GmailApiClient, url: URL): Promise<unknown> {
507
+ const response = await client.http.request(
508
+ url,
509
+ { method: "GET", headers: apiHeaders(client) },
510
+ [200],
511
+ PEOPLE_API_HOSTS,
512
+ undefined,
513
+ "application/json",
514
+ );
515
+ return response.body;
516
+ }
517
+
518
+ /** Create a context-independent official Gmail/People API client. */
519
+ export function createGmailApiClient(input: {
520
+ readonly http: ProviderHttpClient;
521
+ readonly accessToken: string;
522
+ readonly subject: string;
523
+ }): GmailApiClient {
524
+ if (!(input.http && typeof input.http.request === "function")) {
525
+ throw new Error("official Gmail API client requires a ProviderHttpClient");
526
+ }
527
+ if (
528
+ typeof input.accessToken !== "string"
529
+ || Buffer.byteLength(input.accessToken, "utf8") < 8
530
+ || Buffer.byteLength(input.accessToken, "utf8") > 16 * 1024
531
+ || [...input.accessToken].some((character) => {
532
+ const code = character.codePointAt(0) ?? -1;
533
+ return code <= 0x20 || code === 0x7f;
534
+ })
535
+ ) throw new Error("official Gmail API client requires a bounded access token");
536
+ if (!isGmailAccountSubject(input.subject)) {
537
+ throw new Error("official Gmail API client subject must be an exact Gmail email address");
538
+ }
539
+ return Object.freeze({
540
+ http: input.http,
541
+ accessToken: input.accessToken,
542
+ subject: input.subject,
543
+ });
544
+ }
545
+
546
+ /** Naming-compatible alias for capture consumers that immediately run profile preflight. */
547
+ export const createAuthenticatedGmailClient = createGmailApiClient;
548
+
549
+ function parseGmailProfile(value: unknown): GmailProfile {
550
+ const source = record(value, "profile response");
551
+ exactKeys(source, [
552
+ "emailAddress",
553
+ "messagesTotal",
554
+ "threadsTotal",
555
+ "historyId",
556
+ ], [], "profile response");
557
+ const emailAddress = text(source.emailAddress, "profile response.emailAddress", 254);
558
+ if (!isGmailAccountSubject(emailAddress)) {
559
+ return fail("profile response.emailAddress", "must be an exact Gmail email address");
560
+ }
561
+ return Object.freeze({
562
+ emailAddress,
563
+ messagesTotal: integer(source.messagesTotal, "profile response.messagesTotal"),
564
+ threadsTotal: integer(source.threadsTotal, "profile response.threadsTotal"),
565
+ historyId: historyId(source.historyId, "profile response.historyId"),
566
+ });
567
+ }
568
+
569
+ /**
570
+ * Preflight the authenticated mailbox and bind it to the OAuth locator subject.
571
+ * Every supported official read calls this before any operation-specific API.
572
+ */
573
+ export async function getAuthenticatedGmailProfile(
574
+ client: GmailApiClient,
575
+ maximumResponseBytes?: number,
576
+ ): Promise<GmailProfile> {
577
+ const url = gmailUrl("/gmail/v1/users/me/profile");
578
+ url.searchParams.set("fields", GMAIL_PROFILE_FIELDS);
579
+ const profile = parseGmailProfile(await gmailGet(client, url, maximumResponseBytes));
580
+ if (profile.emailAddress.toLowerCase() !== client.subject.toLowerCase()) {
581
+ throw new Error("official Gmail profile email does not match the OAuth locator subject");
582
+ }
583
+ return profile;
584
+ }
585
+
586
+ function gmailThreadWebUrl(accountLocator: string, threadId: string, view: GmailThreadView): string {
587
+ const encodedAccount = encodedPathSegment(accountLocator);
588
+ return `${GMAIL_WEB_ORIGIN}/mail/u/${encodedAccount}/#${view}/${threadId}`;
589
+ }
590
+
591
+ export function buildGmailThreadUrl(
592
+ accountEmail: string,
593
+ threadIdValue: string,
594
+ view: GmailThreadView = "all",
595
+ ): string {
596
+ if (!isGmailAccountSubject(accountEmail)) {
597
+ throw new Error("Gmail thread URL account must be an exact email address");
598
+ }
599
+ const threadId = parseGmailId(threadIdValue, "thread URL thread ID");
600
+ if (view !== "all" && view !== "inbox") {
601
+ throw new Error("Gmail thread URL view must be all or inbox");
602
+ }
603
+ return gmailThreadWebUrl(accountEmail, threadId, view);
604
+ }
605
+
606
+ export function parseGmailThreadUrl(url: URL): ParsedGmailThreadUrl {
607
+ if (
608
+ !(url instanceof URL)
609
+ || url.protocol !== "https:"
610
+ || url.hostname !== "mail.google.com"
611
+ || url.port !== ""
612
+ || url.username !== ""
613
+ || url.password !== ""
614
+ || url.search !== ""
615
+ ) throw new Error("Gmail thread URL must use the exact credential-free mail.google.com origin");
616
+ const pathMatch = /^\/mail\/u\/([^/]+)\/$/u.exec(url.pathname);
617
+ const fragmentSegments = url.hash.startsWith("#")
618
+ ? url.hash.slice(1).split("/")
619
+ : [];
620
+ if (pathMatch === null) {
621
+ throw new Error("Gmail thread URL must target one exact account locator");
622
+ }
623
+ const simpleViews = new Set([
624
+ "all",
625
+ "inbox",
626
+ "sent",
627
+ "drafts",
628
+ "spam",
629
+ "trash",
630
+ "starred",
631
+ "important",
632
+ ]);
633
+ let sourceView: string;
634
+ let rawThreadId: string;
635
+ if (
636
+ fragmentSegments.length === 2
637
+ && simpleViews.has(fragmentSegments[0] ?? "")
638
+ ) {
639
+ sourceView = fragmentSegments[0] ?? "";
640
+ rawThreadId = fragmentSegments[1] ?? "";
641
+ } else if (
642
+ fragmentSegments.length === 3
643
+ && (fragmentSegments[0] === "category" || fragmentSegments[0] === "label" || fragmentSegments[0] === "search")
644
+ ) {
645
+ const kind = fragmentSegments[0];
646
+ const encodedSelector = fragmentSegments[1] ?? "";
647
+ if (encodedSelector.length === 0 || Buffer.byteLength(encodedSelector, "utf8") > 4_096) {
648
+ throw new Error("Gmail thread URL has an invalid bounded view selector");
649
+ }
650
+ let selector: string;
651
+ try {
652
+ selector = decodeURIComponent(encodedSelector.replaceAll("+", "%20"));
653
+ } catch (error) {
654
+ throw new Error("Gmail thread URL has an invalid view selector encoding", { cause: error });
655
+ }
656
+ if (
657
+ selector.length === 0
658
+ || Buffer.byteLength(selector, "utf8") > 2_048
659
+ || hasUnsafeControl(selector)
660
+ ) throw new Error("Gmail thread URL has an unsafe or ambiguous view selector");
661
+ sourceView = `${kind}/${encodedSelector}`;
662
+ rawThreadId = fragmentSegments[2] ?? "";
663
+ } else {
664
+ throw new Error("Gmail thread URL must target one exact reviewed mailbox or search view");
665
+ }
666
+ let accountLocator: string;
667
+ try {
668
+ accountLocator = decodeURIComponent(pathMatch[1] ?? "");
669
+ } catch (error) {
670
+ throw new Error("Gmail thread URL has an invalid account locator encoding", { cause: error });
671
+ }
672
+ if (!/^[0-9]{1,8}$/u.test(accountLocator) && !isGmailAccountSubject(accountLocator)) {
673
+ throw new Error("Gmail thread URL account locator must be an email address or numeric account slot");
674
+ }
675
+ const view: GmailThreadView = sourceView === "inbox" ? "inbox" : "all";
676
+ const threadId = parseGmailId(rawThreadId, "thread URL thread ID");
677
+ return Object.freeze({
678
+ accountLocator,
679
+ view,
680
+ sourceView,
681
+ threadId,
682
+ canonicalUrl: gmailThreadWebUrl(accountLocator, threadId, view),
683
+ });
684
+ }
685
+
686
+ function parseThreadStub(value: unknown, label: string): GmailThreadStub {
687
+ const source = record(value, label);
688
+ exactKeys(source, ["id"], ["snippet", "historyId"], label);
689
+ return Object.freeze({
690
+ id: parseGmailId(source.id, `${label}.id`),
691
+ snippet: optionalText(source.snippet, `${label}.snippet`, 64 * 1024, true, true),
692
+ historyId: optionalHistoryId(source.historyId, `${label}.historyId`),
693
+ });
694
+ }
695
+
696
+ function parseMessageStub(value: unknown, label: string): GmailMessageStub {
697
+ const source = record(value, label);
698
+ exactKeys(source, ["id"], ["threadId"], label);
699
+ return Object.freeze({
700
+ id: parseGmailId(source.id, `${label}.id`),
701
+ threadId: source.threadId === undefined
702
+ ? null
703
+ : parseGmailId(source.threadId, `${label}.threadId`),
704
+ });
705
+ }
706
+
707
+ function uniqueIds(values: readonly { readonly id: string }[], label: string): void {
708
+ const ids = values.map((value) => value.id);
709
+ if (new Set(ids).size !== ids.length) return fail(label, "contains duplicate stable IDs");
710
+ }
711
+
712
+ function parseThreadListPage(value: unknown, maximum: number): GmailThreadListPage {
713
+ const source = record(value, "thread list response");
714
+ exactKeys(source, [], ["threads", "nextPageToken", "resultSizeEstimate"], "thread list response");
715
+ const threads = source.threads === undefined
716
+ ? Object.freeze([])
717
+ : Object.freeze(array(source.threads, "thread list response.threads", maximum)
718
+ .map((entry, index) => parseThreadStub(entry, `thread list response.threads[${index}]`)));
719
+ uniqueIds(threads, "thread list response.threads");
720
+ return Object.freeze({
721
+ threads,
722
+ nextPageToken: optionalPageToken(source.nextPageToken, "thread list response.nextPageToken"),
723
+ resultSizeEstimate: optionalInteger(
724
+ source.resultSizeEstimate,
725
+ "thread list response.resultSizeEstimate",
726
+ ) ?? threads.length,
727
+ });
728
+ }
729
+
730
+ function parseMessageListPage(value: unknown, maximum: number): GmailMessageListPage {
731
+ const source = record(value, "message list response");
732
+ exactKeys(source, [], ["messages", "nextPageToken", "resultSizeEstimate"], "message list response");
733
+ const messages = source.messages === undefined
734
+ ? Object.freeze([])
735
+ : Object.freeze(array(source.messages, "message list response.messages", maximum)
736
+ .map((entry, index) => parseMessageStub(entry, `message list response.messages[${index}]`)));
737
+ uniqueIds(messages, "message list response.messages");
738
+ return Object.freeze({
739
+ messages,
740
+ nextPageToken: optionalPageToken(source.nextPageToken, "message list response.nextPageToken"),
741
+ resultSizeEstimate: optionalInteger(
742
+ source.resultSizeEstimate,
743
+ "message list response.resultSizeEstimate",
744
+ ) ?? messages.length,
745
+ });
746
+ }
747
+
748
+ export async function fetchGmailThreadList(
749
+ client: GmailApiClient,
750
+ input: Readonly<{
751
+ limit: number;
752
+ pageToken: string | null;
753
+ query: string | null;
754
+ labelIds: readonly string[];
755
+ includeSpamTrash: boolean;
756
+ }>,
757
+ ): Promise<GmailThreadListPage> {
758
+ const limit = integer(input.limit, "thread list limit", 1, 500);
759
+ const url = gmailUrl("/gmail/v1/users/me/threads");
760
+ url.searchParams.set("maxResults", String(limit));
761
+ url.searchParams.set("includeSpamTrash", String(input.includeSpamTrash));
762
+ if (input.pageToken !== null) url.searchParams.set("pageToken", pageToken(input.pageToken, "thread list page token"));
763
+ if (input.query !== null) url.searchParams.set("q", text(input.query, "thread list query", 4_096));
764
+ for (const label of input.labelIds) {
765
+ url.searchParams.append("labelIds", text(label, "thread list label ID", 256));
766
+ }
767
+ url.searchParams.set("fields", GMAIL_THREAD_LIST_FIELDS);
768
+ return parseThreadListPage(await gmailGet(client, url), limit);
769
+ }
770
+
771
+ export async function fetchGmailMessageList(
772
+ client: GmailApiClient,
773
+ input: Readonly<{
774
+ limit: number;
775
+ pageToken: string | null;
776
+ query: string;
777
+ includeSpamTrash?: boolean;
778
+ }>,
779
+ ): Promise<GmailMessageListPage> {
780
+ const limit = integer(input.limit, "message list limit", 1, 500);
781
+ const url = gmailUrl("/gmail/v1/users/me/messages");
782
+ url.searchParams.set("maxResults", String(limit));
783
+ url.searchParams.set("q", text(input.query, "message list query", 4_096));
784
+ url.searchParams.set("includeSpamTrash", String(input.includeSpamTrash ?? false));
785
+ if (input.pageToken !== null) url.searchParams.set("pageToken", pageToken(input.pageToken, "message list page token"));
786
+ url.searchParams.set("fields", GMAIL_MESSAGE_LIST_FIELDS);
787
+ return parseMessageListPage(await gmailGet(client, url), limit);
788
+ }
789
+
790
+ function decodeBase64Url(value: unknown, label: string, maximumBytes = 100 * 1024 * 1024): Uint8Array {
791
+ const encoded = text(value, label, Math.ceil(maximumBytes * 4 / 3) + 4, true);
792
+ if (!/^[A-Za-z0-9_-]*={0,2}$/u.test(encoded)) {
793
+ return fail(label, "must be canonical base64url data");
794
+ }
795
+ const withoutPadding = encoded.replace(/=+$/u, "");
796
+ let decoded: Buffer;
797
+ try {
798
+ decoded = Buffer.from(withoutPadding, "base64url");
799
+ } catch (error) {
800
+ throw new Error(`official Gmail ${label} is not valid base64url data`, { cause: error });
801
+ }
802
+ if (
803
+ decoded.byteLength > maximumBytes
804
+ || decoded.toString("base64url") !== withoutPadding
805
+ ) return fail(label, "must be canonical bounded base64url data");
806
+ return new Uint8Array(decoded);
807
+ }
808
+
809
+ function internalDate(value: unknown, label: string): string {
810
+ const raw = text(value, label, 32);
811
+ if (!/^(?:0|[1-9][0-9]{0,15})$/u.test(raw)) {
812
+ return fail(label, "must be a decimal epoch-millisecond timestamp");
813
+ }
814
+ const milliseconds = Number(raw);
815
+ if (!Number.isSafeInteger(milliseconds)) return fail(label, "must be a safe epoch-millisecond timestamp");
816
+ const date = new Date(milliseconds);
817
+ if (!Number.isFinite(date.getTime())) return fail(label, "must be a valid epoch-millisecond timestamp");
818
+ return date.toISOString();
819
+ }
820
+
821
+ function optionalInternalDate(value: unknown, label: string): string | null {
822
+ return value === undefined ? null : internalDate(value, label);
823
+ }
824
+
825
+ type ParsedHeader = Readonly<{ name: string; value: string }>;
826
+
827
+ function parseHeaders(value: unknown, label: string): readonly ParsedHeader[] {
828
+ if (value === undefined) return Object.freeze([]);
829
+ return Object.freeze(array(value, label, MAX_HEADERS).map((entry, index) => {
830
+ const path = `${label}[${index}]`;
831
+ const source = record(entry, path);
832
+ exactKeys(source, ["name", "value"], [], path);
833
+ return Object.freeze({
834
+ name: text(source.name, `${path}.name`, 256),
835
+ value: text(source.value, `${path}.value`, MAX_HEADER_BYTES, true, true),
836
+ });
837
+ }));
838
+ }
839
+
840
+ function interactionMetadataHeaders(
841
+ value: unknown,
842
+ label: string,
843
+ ): readonly ParsedHeader[] {
844
+ const merged: ParsedHeader[] = [];
845
+ const destinationIndexes = new Map<string, number>();
846
+ const sources = value === undefined ? [] : array(value, label, MAX_HEADERS);
847
+ for (const [sourceIndex, entry] of sources.entries()) {
848
+ const path = `${label}[${sourceIndex}]`;
849
+ const source = record(entry, path);
850
+ exactKeys(source, ["name", "value"], [], path);
851
+ const headerName = text(source.name, `${path}.name`, 256);
852
+ if (typeof source.value !== "string") {
853
+ return fail(`${path}.value`, "must be bounded text without unsafe controls");
854
+ }
855
+ const name = headerName.toLowerCase();
856
+ if (name !== "from" && name !== "to" && name !== "cc" && name !== "bcc") {
857
+ return fail(`${path}.name`, "is outside the reviewed interaction projection");
858
+ }
859
+ if (
860
+ !isWellFormedText(source.value)
861
+ || Buffer.byteLength(source.value, "utf8") > MAX_HEADER_BYTES
862
+ || hasUnsafeControl(source.value, true)
863
+ ) continue;
864
+ const header = Object.freeze({ name: headerName, value: source.value });
865
+ if (name === "from") {
866
+ merged.push(header);
867
+ continue;
868
+ }
869
+ const destinationIndex = destinationIndexes.get(name);
870
+ if (destinationIndex === undefined) {
871
+ destinationIndexes.set(name, merged.length);
872
+ merged.push(header);
873
+ continue;
874
+ }
875
+ const prior = merged[destinationIndex];
876
+ if (prior === undefined) {
877
+ throw new Error("interaction header merge index is unavailable");
878
+ }
879
+ merged[destinationIndex] = Object.freeze({
880
+ name: prior.name,
881
+ value: text(
882
+ `${prior.value}, ${header.value}`,
883
+ `${label}.${prior.name}`,
884
+ MAX_HEADER_BYTES,
885
+ true,
886
+ true,
887
+ ),
888
+ });
889
+ }
890
+ return Object.freeze(merged);
891
+ }
892
+
893
+ function normalizedHeaderValue(value: string): string {
894
+ return value
895
+ .replace(/\r\n[\t ]+/gu, " ")
896
+ .replace(/[\r\n]+/gu, " ")
897
+ .replace(/[\t ]+/gu, " ")
898
+ .trim();
899
+ }
900
+
901
+ function leapYear(year: number): boolean {
902
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
903
+ }
904
+
905
+ function calendarMonthDays(year: number, month: number): number {
906
+ if (month === 2) return leapYear(year) ? 29 : 28;
907
+ return [4, 6, 9, 11].includes(month) ? 30 : 31;
908
+ }
909
+
910
+ const RFC_5322_MONTHS = [
911
+ "jan", "feb", "mar", "apr", "may", "jun",
912
+ "jul", "aug", "sep", "oct", "nov", "dec",
913
+ ] as const;
914
+ const RFC_5322_WEEKDAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
915
+ const RFC_5322_NAMED_ZONE_MINUTES = new Map<string, number>([
916
+ ["UT", 0],
917
+ ["GMT", 0],
918
+ ["EST", -5 * 60],
919
+ ["EDT", -4 * 60],
920
+ ["CST", -6 * 60],
921
+ ["CDT", -5 * 60],
922
+ ["MST", -7 * 60],
923
+ ["MDT", -6 * 60],
924
+ ["PST", -8 * 60],
925
+ ["PDT", -7 * 60],
926
+ ]);
927
+ const MAX_RFC_5322_COMMENT_DEPTH = 16;
928
+
929
+ /**
930
+ * Remove semantically invisible RFC 5322 CFWS for syntax review. The source
931
+ * header is returned separately with only layout whitespace normalized.
932
+ */
933
+ function dateWithoutCfws(value: string, label: string): string {
934
+ let syntax = "";
935
+ let pendingSpace = false;
936
+ let commentDepth = 0;
937
+
938
+ for (let index = 0; index < value.length; index += 1) {
939
+ const character = value[index] ?? "";
940
+ const code = character.charCodeAt(0);
941
+ if (
942
+ code > 0x7e
943
+ || (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d)
944
+ ) return fail(label, "must contain only reviewed RFC 5322 ASCII text");
945
+
946
+ if (character === "\r") {
947
+ if (
948
+ value[index + 1] !== "\n"
949
+ || (value[index + 2] !== " " && value[index + 2] !== "\t")
950
+ ) return fail(label, "contains an unsafe or ambiguous line fold");
951
+ if (commentDepth === 0) pendingSpace = true;
952
+ index += 2;
953
+ continue;
954
+ }
955
+ if (character === "\n") return fail(label, "contains an unsafe bare line feed");
956
+
957
+ if (commentDepth > 0) {
958
+ if (character === "\\") {
959
+ const quoted = value[index + 1];
960
+ if (quoted === undefined || !/^[\t\x20-\x7e]$/u.test(quoted)) {
961
+ return fail(label, "contains a malformed quoted pair in a comment");
962
+ }
963
+ index += 1;
964
+ } else if (character === "(") {
965
+ commentDepth += 1;
966
+ if (commentDepth > MAX_RFC_5322_COMMENT_DEPTH) {
967
+ return fail(label, "exceeds the reviewed RFC 5322 comment depth");
968
+ }
969
+ } else if (character === ")") {
970
+ commentDepth -= 1;
971
+ if (commentDepth === 0) pendingSpace = true;
972
+ }
973
+ continue;
974
+ }
975
+
976
+ if (character === "(") {
977
+ commentDepth = 1;
978
+ pendingSpace = true;
979
+ continue;
980
+ }
981
+ if (character === ")") return fail(label, "contains an unmatched comment terminator");
982
+ if (character === " " || character === "\t") {
983
+ pendingSpace = true;
984
+ continue;
985
+ }
986
+ if (pendingSpace && syntax.length > 0) syntax += " ";
987
+ syntax += character;
988
+ pendingSpace = false;
989
+ }
990
+
991
+ if (commentDepth !== 0) return fail(label, "contains an unterminated comment");
992
+ return syntax;
993
+ }
994
+
995
+ function interpretedRfc5322Year(value: string, label: string): number {
996
+ const numeric = Number(value);
997
+ const year = value.length === 2
998
+ ? numeric + (numeric < 50 ? 2_000 : 1_900)
999
+ : value.length === 3
1000
+ ? numeric + 1_900
1001
+ : numeric;
1002
+ if (!Number.isSafeInteger(year) || year < 1_900 || year > 9_999) {
1003
+ return fail(label, "contains a year outside the reviewed instant range");
1004
+ }
1005
+ return year;
1006
+ }
1007
+
1008
+ function gmailDateHeader(value: string, label: string): string {
1009
+ const syntax = dateWithoutCfws(value, label);
1010
+ const match = /^(?:(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s*,\s*)?([0-9]{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+([0-9]{2,6})\s+([0-9]{2})\s*:\s*([0-9]{2})(?:\s*:\s*([0-9]{2}))?\s+(?:([+-])([0-9]{2})([0-9]{2})|([A-Za-z]{1,5}))$/iu.exec(syntax);
1011
+ if (match === null) return fail(label, "must be a reviewed RFC 5322 date-time");
1012
+ const weekday = match[1]?.toLowerCase();
1013
+ const day = Number(match[2]);
1014
+ const month = RFC_5322_MONTHS.indexOf((match[3] ?? "").toLowerCase() as typeof RFC_5322_MONTHS[number]) + 1;
1015
+ const year = interpretedRfc5322Year(match[4] ?? "", label);
1016
+ const hour = Number(match[5]);
1017
+ const minute = Number(match[6]);
1018
+ const second = match[7] === undefined ? 0 : Number(match[7]);
1019
+ const zoneMinute = match[10] === undefined ? 0 : Number(match[10]);
1020
+ if (
1021
+ month < 1
1022
+ || day < 1
1023
+ || day > calendarMonthDays(year, month)
1024
+ || hour > 23
1025
+ || minute > 59
1026
+ || second > 60
1027
+ || zoneMinute > 59
1028
+ ) return fail(label, "contains an invalid calendar date, time, or numeric zone");
1029
+
1030
+ let zoneOffsetMinutes: number;
1031
+ if (match[11] !== undefined) {
1032
+ const namedZone = match[11].toUpperCase();
1033
+ const namedOffset = RFC_5322_NAMED_ZONE_MINUTES.get(namedZone);
1034
+ if (namedOffset !== undefined) {
1035
+ zoneOffsetMinutes = namedOffset;
1036
+ } else if (/^[A-IK-Z]$/u.test(namedZone)) {
1037
+ // RFC 5322 assigns military zones unknown-zone (-0000) semantics unless
1038
+ // out-of-band information establishes an offset. This parser has none.
1039
+ zoneOffsetMinutes = 0;
1040
+ } else {
1041
+ return fail(label, "uses an ambiguous or unsupported named zone");
1042
+ }
1043
+ } else {
1044
+ const zoneHours = Number(match[9]);
1045
+ const direction = match[8] === "-" ? -1 : 1;
1046
+ zoneOffsetMinutes = direction * (zoneHours * 60 + zoneMinute);
1047
+ }
1048
+
1049
+ const representedSecond = Math.min(second, 59);
1050
+ const localMilliseconds = Date.UTC(year, month - 1, day, hour, minute, representedSecond);
1051
+ const localDate = new Date(localMilliseconds);
1052
+ if (
1053
+ !Number.isFinite(localDate.getTime())
1054
+ || localDate.getUTCFullYear() !== year
1055
+ || localDate.getUTCMonth() !== month - 1
1056
+ || localDate.getUTCDate() !== day
1057
+ || localDate.getUTCHours() !== hour
1058
+ || localDate.getUTCMinutes() !== minute
1059
+ || localDate.getUTCSeconds() !== representedSecond
1060
+ ) return fail(label, "does not identify an exact representable local date-time");
1061
+ if (
1062
+ weekday !== undefined
1063
+ && RFC_5322_WEEKDAYS[localDate.getUTCDay()] !== weekday
1064
+ ) return fail(label, "contains a day-of-week inconsistent with its calendar date");
1065
+
1066
+ const instantMilliseconds = localMilliseconds
1067
+ - zoneOffsetMinutes * 60_000
1068
+ + (second === 60 ? 1_000 : 0);
1069
+ if (
1070
+ !Number.isSafeInteger(instantMilliseconds)
1071
+ || !Number.isFinite(new Date(instantMilliseconds).getTime())
1072
+ ) return fail(label, "does not identify an exact representable instant");
1073
+ return normalizedHeaderValue(value);
1074
+ }
1075
+
1076
+ function selectedHeaders(headers: readonly ParsedHeader[], label: string): Readonly<{
1077
+ from: string | null;
1078
+ to: string | null;
1079
+ cc: string | null;
1080
+ bcc: string | null;
1081
+ subject: string | null;
1082
+ date: string | null;
1083
+ messageId: string | null;
1084
+ inReplyTo: string | null;
1085
+ }> {
1086
+ const wanted = new Map<string, "from" | "to" | "cc" | "bcc" | "subject" | "date" | "messageId" | "inReplyTo">([
1087
+ ["from", "from"],
1088
+ ["to", "to"],
1089
+ ["cc", "cc"],
1090
+ ["bcc", "bcc"],
1091
+ ["subject", "subject"],
1092
+ ["date", "date"],
1093
+ ["message-id", "messageId"],
1094
+ ["in-reply-to", "inReplyTo"],
1095
+ ]);
1096
+ const result: Record<string, string | null> = {
1097
+ from: null,
1098
+ to: null,
1099
+ cc: null,
1100
+ bcc: null,
1101
+ subject: null,
1102
+ date: null,
1103
+ messageId: null,
1104
+ inReplyTo: null,
1105
+ };
1106
+ for (const header of headers) {
1107
+ const key = wanted.get(header.name.toLowerCase());
1108
+ if (key === undefined) continue;
1109
+ if (result[key] !== null) return fail(`${label}.${header.name}`, "must not be duplicated");
1110
+ result[key] = key === "date"
1111
+ ? gmailDateHeader(header.value, `${label}.${header.name}`)
1112
+ : normalizedHeaderValue(header.value);
1113
+ }
1114
+ return Object.freeze(result) as ReturnType<typeof selectedHeaders>;
1115
+ }
1116
+
1117
+ function headerValue(headers: readonly ParsedHeader[], name: string): string | null {
1118
+ const values = headers
1119
+ .filter((header) => header.name.toLowerCase() === name.toLowerCase())
1120
+ .map((header) => normalizedHeaderValue(header.value));
1121
+ if (values.length > 1) return fail(`MIME header ${name}`, "must not be duplicated");
1122
+ return values[0] ?? null;
1123
+ }
1124
+
1125
+ function isMimeTokenCharacter(character: string): boolean {
1126
+ const code = character.charCodeAt(0);
1127
+ return code >= 33
1128
+ && code <= 126
1129
+ && !"()<>@,;:\\\"/[]?=".includes(character);
1130
+ }
1131
+
1132
+ function unfoldMimeHeader(value: string, label: string): string {
1133
+ let unfolded = "";
1134
+ for (let index = 0; index < value.length; index += 1) {
1135
+ const character = value[index] ?? "";
1136
+ if (character === "\n") return fail(label, "contains an unsafe bare line feed");
1137
+ if (character !== "\r") {
1138
+ unfolded += character;
1139
+ continue;
1140
+ }
1141
+ if (value[index + 1] !== "\n" || (value[index + 2] !== " " && value[index + 2] !== "\t")) {
1142
+ return fail(label, "contains an unsafe or ambiguous line fold");
1143
+ }
1144
+ index += 2;
1145
+ while (value[index + 1] === " " || value[index + 1] === "\t") index += 1;
1146
+ unfolded += " ";
1147
+ }
1148
+ return unfolded;
1149
+ }
1150
+
1151
+ type DispositionParameter = Readonly<{
1152
+ quoted: boolean;
1153
+ value: string;
1154
+ }>;
1155
+
1156
+ function dispositionParameters(
1157
+ rawValue: string,
1158
+ label: string,
1159
+ ): Readonly<{
1160
+ type: "attachment" | "inline";
1161
+ parameters: ReadonlyMap<string, DispositionParameter>;
1162
+ }> {
1163
+ const value = unfoldMimeHeader(rawValue, label);
1164
+ let index = 0;
1165
+ const skipWhitespace = (): void => {
1166
+ while (value[index] === " " || value[index] === "\t") index += 1;
1167
+ };
1168
+ const token = (tokenLabel: string): string => {
1169
+ const start = index;
1170
+ while (index < value.length && isMimeTokenCharacter(value[index] ?? "")) index += 1;
1171
+ if (start === index) return fail(tokenLabel, "must be a nonempty MIME token");
1172
+ return value.slice(start, index);
1173
+ };
1174
+ const parameterValue = (parameterLabel: string): DispositionParameter => {
1175
+ if (value[index] !== "\"") {
1176
+ return Object.freeze({ quoted: false, value: token(parameterLabel) });
1177
+ }
1178
+ index += 1;
1179
+ let parsed = "";
1180
+ let closed = false;
1181
+ while (index < value.length) {
1182
+ const character = value[index] ?? "";
1183
+ index += 1;
1184
+ if (character === "\"") {
1185
+ closed = true;
1186
+ break;
1187
+ }
1188
+ if (character === "\\") {
1189
+ if (index >= value.length) return fail(parameterLabel, "ends with an ambiguous quoted escape");
1190
+ const escaped = value[index] ?? "";
1191
+ index += 1;
1192
+ if (hasUnsafeControl(escaped)) return fail(parameterLabel, "contains an unsafe quoted escape");
1193
+ parsed += escaped;
1194
+ continue;
1195
+ }
1196
+ if (hasUnsafeControl(character)) return fail(parameterLabel, "contains unsafe quoted text");
1197
+ parsed += character;
1198
+ }
1199
+ if (!closed) return fail(parameterLabel, "contains an unterminated quoted value");
1200
+ return Object.freeze({ quoted: true, value: parsed });
1201
+ };
1202
+
1203
+ skipWhitespace();
1204
+ const rawType = token(`${label} type`).toLowerCase();
1205
+ if (rawType !== "attachment" && rawType !== "inline") {
1206
+ return fail(`${label} type`, "must be attachment or inline");
1207
+ }
1208
+ const parameters = new Map<string, DispositionParameter>();
1209
+ while (true) {
1210
+ skipWhitespace();
1211
+ if (index === value.length) break;
1212
+ if (value[index] !== ";") return fail(label, "contains ambiguous text after its disposition type");
1213
+ index += 1;
1214
+ skipWhitespace();
1215
+ if (index === value.length) return fail(label, "contains an empty trailing parameter");
1216
+ const rawName = token(`${label} parameter name`);
1217
+ const name = rawName.toLowerCase();
1218
+ skipWhitespace();
1219
+ if (value[index] !== "=") return fail(`${label} parameter ${rawName}`, "must contain an equals sign");
1220
+ index += 1;
1221
+ skipWhitespace();
1222
+ const parsed = parameterValue(`${label} parameter ${rawName}`);
1223
+ skipWhitespace();
1224
+ if (parameters.has(name)) return fail(`${label} parameter ${rawName}`, "must not be duplicated");
1225
+ parameters.set(name, parsed);
1226
+ }
1227
+ return Object.freeze({ type: rawType, parameters });
1228
+ }
1229
+
1230
+ function decodeExtendedDispositionFilename(parameter: DispositionParameter, label: string): string {
1231
+ if (parameter.quoted) return fail(label, "must use unquoted RFC 5987 syntax");
1232
+ const firstQuote = parameter.value.indexOf("'");
1233
+ const secondQuote = firstQuote < 0 ? -1 : parameter.value.indexOf("'", firstQuote + 1);
1234
+ if (firstQuote <= 0 || secondQuote < 0) return fail(label, "must contain charset, language, and value components");
1235
+ const charset = parameter.value.slice(0, firstQuote).toLowerCase();
1236
+ const language = parameter.value.slice(firstQuote + 1, secondQuote);
1237
+ if (charset !== "utf-8" && charset !== "us-ascii") {
1238
+ return fail(label, "uses an unsupported filename charset");
1239
+ }
1240
+ if (language !== "" && !/^[A-Za-z0-9]{1,8}(?:-[A-Za-z0-9]{1,8})*$/u.test(language)) {
1241
+ return fail(label, "contains an invalid filename language tag");
1242
+ }
1243
+ const encoded = parameter.value.slice(secondQuote + 1);
1244
+ const bytes: number[] = [];
1245
+ for (let index = 0; index < encoded.length; index += 1) {
1246
+ const character = encoded[index] ?? "";
1247
+ if (character === "%") {
1248
+ const pair = encoded.slice(index + 1, index + 3);
1249
+ if (!/^[0-9A-Fa-f]{2}$/u.test(pair)) return fail(label, "contains invalid filename percent encoding");
1250
+ bytes.push(Number.parseInt(pair, 16));
1251
+ index += 2;
1252
+ continue;
1253
+ }
1254
+ if (!/^[A-Za-z0-9!#$&+.^_`|~-]$/u.test(character)) {
1255
+ return fail(label, "contains an invalid unescaped filename character");
1256
+ }
1257
+ bytes.push(character.charCodeAt(0));
1258
+ }
1259
+ if (bytes.length > 8_192) return fail(label, "exceeds the filename byte bound");
1260
+ if (charset === "us-ascii" && bytes.some((byte) => byte > 0x7f)) {
1261
+ return fail(label, "contains non-ASCII bytes for its declared charset");
1262
+ }
1263
+ let decoded: string;
1264
+ try {
1265
+ decoded = new TextDecoder(charset as "utf-8", { fatal: true }).decode(new Uint8Array(bytes));
1266
+ } catch (error) {
1267
+ throw new Error(`official Gmail ${label} is not valid ${charset} text`, { cause: error });
1268
+ }
1269
+ return text(decoded, label, 8_192, true);
1270
+ }
1271
+
1272
+ function contentDisposition(
1273
+ headers: readonly ParsedHeader[],
1274
+ filename: string,
1275
+ label: string,
1276
+ ): "attachment" | "inline" | null {
1277
+ const matching = headers.filter((header) => header.name.toLowerCase() === "content-disposition");
1278
+ if (matching.length > 1) return fail(label, "must not be duplicated");
1279
+ const rawValue = matching[0]?.value;
1280
+ if (rawValue === undefined) return null;
1281
+ const parsed = dispositionParameters(rawValue, label);
1282
+ for (const name of parsed.parameters.keys()) {
1283
+ if (/^filename\*(?:[0-9]+\*?)?$/u.test(name) && name !== "filename*") {
1284
+ return fail(`${label} parameter ${name}`, "uses ambiguous segmented filename syntax");
1285
+ }
1286
+ }
1287
+ const ordinary = parsed.parameters.get("filename");
1288
+ const extended = parsed.parameters.get("filename*");
1289
+ if (ordinary !== undefined && extended !== undefined) {
1290
+ return fail(label, "must not contain both filename and filename* parameters");
1291
+ }
1292
+ const declaredFilename = ordinary === undefined
1293
+ ? extended === undefined
1294
+ ? null
1295
+ : decodeExtendedDispositionFilename(extended, `${label} parameter filename*`)
1296
+ : text(ordinary.value, `${label} parameter filename`, 8_192, true);
1297
+ if (declaredFilename !== null && declaredFilename !== filename) {
1298
+ return fail(label, "filename does not match the Gmail MIME filename");
1299
+ }
1300
+ return parsed.type;
1301
+ }
1302
+
1303
+ function contentCharset(
1304
+ headers: readonly ParsedHeader[],
1305
+ expectedMimeType: "text/plain" | "text/html",
1306
+ label: string,
1307
+ ): "utf-8" {
1308
+ const rawContentType = headerValue(headers, "content-type");
1309
+ if (rawContentType === null) return "utf-8";
1310
+ const value = unfoldMimeHeader(rawContentType, label);
1311
+ let index = 0;
1312
+ const skipWhitespace = (): void => {
1313
+ while (value[index] === " " || value[index] === "\t") index += 1;
1314
+ };
1315
+ const token = (tokenLabel: string): string => {
1316
+ const start = index;
1317
+ while (index < value.length && isMimeTokenCharacter(value[index] ?? "")) index += 1;
1318
+ if (start === index) return fail(tokenLabel, "must be a nonempty MIME token");
1319
+ return value.slice(start, index);
1320
+ };
1321
+ const parameterValue = (parameterLabel: string): string => {
1322
+ if (value[index] !== "\"") return token(parameterLabel);
1323
+ index += 1;
1324
+ let result = "";
1325
+ let closed = false;
1326
+ while (index < value.length) {
1327
+ const character = value[index] ?? "";
1328
+ index += 1;
1329
+ if (character === "\"") {
1330
+ closed = true;
1331
+ break;
1332
+ }
1333
+ if (character === "\\") {
1334
+ if (index >= value.length) return fail(parameterLabel, "ends with an ambiguous quoted escape");
1335
+ const escaped = value[index] ?? "";
1336
+ index += 1;
1337
+ if (hasUnsafeControl(escaped)) return fail(parameterLabel, "contains an unsafe quoted escape");
1338
+ result += escaped;
1339
+ } else {
1340
+ if (hasUnsafeControl(character)) return fail(parameterLabel, "contains unsafe quoted text");
1341
+ result += character;
1342
+ }
1343
+ }
1344
+ if (!closed) return fail(parameterLabel, "contains an unterminated quoted value");
1345
+ return result;
1346
+ };
1347
+
1348
+ skipWhitespace();
1349
+ const type = token(`${label} type`).toLowerCase();
1350
+ if (value[index] !== "/") return fail(label, "must contain an exact type/subtype separator");
1351
+ index += 1;
1352
+ const subtype = token(`${label} subtype`).toLowerCase();
1353
+ if (`${type}/${subtype}` !== expectedMimeType) {
1354
+ return fail(label, "does not match the Gmail MIME part type");
1355
+ }
1356
+ const parameters = new Map<string, string>();
1357
+ while (true) {
1358
+ skipWhitespace();
1359
+ if (index === value.length) break;
1360
+ if (value[index] !== ";") return fail(label, "contains ambiguous text after its media type");
1361
+ index += 1;
1362
+ skipWhitespace();
1363
+ if (index === value.length) return fail(label, "contains an empty trailing parameter");
1364
+ const rawName = token(`${label} parameter name`);
1365
+ const name = rawName.toLowerCase();
1366
+ skipWhitespace();
1367
+ if (value[index] !== "=") return fail(`${label} parameter ${rawName}`, "must contain an equals sign");
1368
+ index += 1;
1369
+ skipWhitespace();
1370
+ const parsed = parameterValue(`${label} parameter ${rawName}`);
1371
+ if (parameters.has(name)) return fail(`${label} parameter ${rawName}`, "must not be duplicated");
1372
+ parameters.set(name, parsed);
1373
+ }
1374
+ const charset = parameters.get("charset");
1375
+ if (charset !== undefined && charset.toLowerCase() !== "utf-8") {
1376
+ return fail(`${label} charset`, "must be UTF-8 when declared");
1377
+ }
1378
+ return "utf-8";
1379
+ }
1380
+
1381
+ function decodeText(bytes: Uint8Array, label: string): string {
1382
+ let decoded: string;
1383
+ try {
1384
+ decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
1385
+ } catch (error) {
1386
+ throw new Error(`official Gmail ${label} must contain valid UTF-8 text`, { cause: error });
1387
+ }
1388
+ let safe = "";
1389
+ for (const character of decoded.replace(/\r\n?/gu, "\n")) {
1390
+ const code = character.codePointAt(0) ?? -1;
1391
+ if (
1392
+ code === 0
1393
+ || (code >= 1 && code <= 8)
1394
+ || code === 11
1395
+ || code === 12
1396
+ || (code >= 14 && code <= 31)
1397
+ || (code >= 127 && code <= 159)
1398
+ ) continue;
1399
+ safe += character;
1400
+ }
1401
+ return safe.trimEnd();
1402
+ }
1403
+
1404
+ function decodeHtmlEntity(entity: string): string {
1405
+ const lower = entity.toLowerCase();
1406
+ const named: Readonly<Record<string, string>> = {
1407
+ amp: "&",
1408
+ apos: "'",
1409
+ gt: ">",
1410
+ lt: "<",
1411
+ nbsp: " ",
1412
+ quot: "\"",
1413
+ };
1414
+ if (named[lower] !== undefined) return named[lower];
1415
+ const numeric = lower.startsWith("#x")
1416
+ ? Number.parseInt(lower.slice(2), 16)
1417
+ : lower.startsWith("#")
1418
+ ? Number.parseInt(lower.slice(1), 10)
1419
+ : Number.NaN;
1420
+ if (!Number.isInteger(numeric) || numeric < 0 || numeric > 0x10ffff) return `&${entity};`;
1421
+ try {
1422
+ return String.fromCodePoint(numeric);
1423
+ } catch {
1424
+ return "�";
1425
+ }
1426
+ }
1427
+
1428
+ type DeadlineCheckpoint = (() => void) | undefined;
1429
+
1430
+ function checkpointHtmlScan(checkpoint: DeadlineCheckpoint, index: number): void {
1431
+ if (checkpoint !== undefined && index % HTML_DEADLINE_CHECKPOINT_CODE_UNITS === 0) checkpoint();
1432
+ }
1433
+
1434
+ function htmlTagEnd(html: string, start: number, checkpoint: DeadlineCheckpoint): number {
1435
+ let quote: "\"" | "'" | null = null;
1436
+ for (let index = start; index < html.length; index += 1) {
1437
+ checkpointHtmlScan(checkpoint, index);
1438
+ const character = html[index] ?? "";
1439
+ if (quote !== null) {
1440
+ if (character === quote) quote = null;
1441
+ continue;
1442
+ }
1443
+ if (character === "\"" || character === "'") quote = character;
1444
+ else if (character === ">") return index;
1445
+ }
1446
+ return -1;
1447
+ }
1448
+
1449
+ function parsedHtmlTag(value: string): Readonly<{
1450
+ name: string;
1451
+ closing: boolean;
1452
+ selfClosing: boolean;
1453
+ }> | null {
1454
+ let index = 0;
1455
+ while (value[index] === " " || value[index] === "\t" || value[index] === "\r" || value[index] === "\n") index += 1;
1456
+ let closing = false;
1457
+ if (value[index] === "/") {
1458
+ closing = true;
1459
+ index += 1;
1460
+ while (value[index] === " " || value[index] === "\t") index += 1;
1461
+ }
1462
+ const start = index;
1463
+ while (/[A-Za-z0-9-]/u.test(value[index] ?? "")) index += 1;
1464
+ if (start === index) return null;
1465
+ const selfClosing = /\/\s*$/u.test(value);
1466
+ return Object.freeze({
1467
+ name: value.slice(start, index).toLowerCase(),
1468
+ closing,
1469
+ selfClosing,
1470
+ });
1471
+ }
1472
+
1473
+ function normalizedHtmlText(value: string, checkpoint: DeadlineCheckpoint): string {
1474
+ const chunks: string[] = [];
1475
+ let buffer = "";
1476
+ const append = (textValue: string): void => {
1477
+ if (textValue === "") return;
1478
+ if (buffer.length + textValue.length >= 8_192) {
1479
+ if (buffer !== "") chunks.push(buffer);
1480
+ if (textValue.length >= 8_192) chunks.push(textValue);
1481
+ else buffer = textValue;
1482
+ if (textValue.length >= 8_192) buffer = "";
1483
+ } else buffer += textValue;
1484
+ };
1485
+ let horizontalStart: number | null = null;
1486
+ let consecutiveNewlines = 0;
1487
+ for (let index = 0; index < value.length; index += 1) {
1488
+ checkpointHtmlScan(checkpoint, index);
1489
+ const character = value[index] ?? "";
1490
+ if (character === " " || character === "\t") {
1491
+ if (horizontalStart === null) horizontalStart = index;
1492
+ continue;
1493
+ }
1494
+ if (character === "\n") {
1495
+ horizontalStart = null;
1496
+ if (consecutiveNewlines < 2) append("\n");
1497
+ consecutiveNewlines += 1;
1498
+ continue;
1499
+ }
1500
+ if (horizontalStart !== null) append(value.slice(horizontalStart, index));
1501
+ horizontalStart = null;
1502
+ consecutiveNewlines = 0;
1503
+ append(character === "\u00a0" ? " " : character);
1504
+ }
1505
+ if (horizontalStart !== null) append(value.slice(horizontalStart));
1506
+ if (buffer !== "") chunks.push(buffer);
1507
+ checkpoint?.();
1508
+ return chunks.join("").trimEnd();
1509
+ }
1510
+
1511
+ function htmlToText(html: string, checkpoint?: () => void): string {
1512
+ const hiddenElements = new Set(["head", "script", "style"]);
1513
+ const blockElements = new Set([
1514
+ "address", "article", "aside", "blockquote", "div", "footer", "h1", "h2", "h3",
1515
+ "h4", "h5", "h6", "header", "li", "main", "nav", "ol", "p", "pre", "section",
1516
+ "table", "tr", "ul",
1517
+ ]);
1518
+ const output: string[] = [];
1519
+ const hiddenStack: string[] = [];
1520
+ let textStart = 0;
1521
+ let index = 0;
1522
+ checkpoint?.();
1523
+ while (index < html.length) {
1524
+ checkpointHtmlScan(checkpoint, index);
1525
+ const character = html[index] ?? "";
1526
+ if (character === "<") {
1527
+ if (hiddenStack.length === 0 && textStart < index) output.push(html.slice(textStart, index));
1528
+ const end = htmlTagEnd(html, index + 1, checkpoint);
1529
+ if (end < 0) {
1530
+ if (hiddenStack.length === 0) output.push(html.slice(index));
1531
+ textStart = html.length;
1532
+ break;
1533
+ }
1534
+ const tag = parsedHtmlTag(html.slice(index + 1, end));
1535
+ if (tag !== null) {
1536
+ const hidden = hiddenStack.at(-1);
1537
+ if (hidden !== undefined) {
1538
+ if (tag.closing && tag.name === hidden) hiddenStack.pop();
1539
+ else if (!tag.closing && !tag.selfClosing && hiddenElements.has(tag.name)) {
1540
+ hiddenStack.push(tag.name);
1541
+ }
1542
+ } else if (!tag.closing && !tag.selfClosing && hiddenElements.has(tag.name)) {
1543
+ hiddenStack.push(tag.name);
1544
+ } else if (tag.name === "br" && !tag.closing) output.push("\n");
1545
+ else if (tag.closing && blockElements.has(tag.name)) output.push("\n");
1546
+ }
1547
+ index = end + 1;
1548
+ textStart = index;
1549
+ continue;
1550
+ }
1551
+ if (character === "&" && hiddenStack.length === 0) {
1552
+ let end = index + 1;
1553
+ const maximum = Math.min(html.length, index + 35);
1554
+ while (end < maximum && html[end] !== ";") {
1555
+ checkpointHtmlScan(checkpoint, end);
1556
+ end += 1;
1557
+ }
1558
+ if (html[end] === ";") {
1559
+ const entity = html.slice(index + 1, end);
1560
+ if (/^(?:[A-Za-z][A-Za-z0-9]{1,31}|#[0-9]{1,8}|#x[0-9A-Fa-f]{1,6})$/u.test(entity)) {
1561
+ if (textStart < index) output.push(html.slice(textStart, index));
1562
+ output.push(decodeHtmlEntity(entity));
1563
+ index = end + 1;
1564
+ textStart = index;
1565
+ continue;
1566
+ }
1567
+ }
1568
+ }
1569
+ index += 1;
1570
+ }
1571
+ if (hiddenStack.length === 0 && textStart < html.length) output.push(html.slice(textStart));
1572
+ checkpoint?.();
1573
+ return normalizedHtmlText(output.join(""), checkpoint);
1574
+ }
1575
+
1576
+ type ExternalBodyFragment = Readonly<{
1577
+ readonly kind: "external";
1578
+ readonly attachmentId: string;
1579
+ readonly messageId: string;
1580
+ readonly mimeType: "text/plain" | "text/html";
1581
+ readonly charset: "utf-8";
1582
+ readonly size: number;
1583
+ }>;
1584
+
1585
+ type BodyFragment = string | ExternalBodyFragment;
1586
+
1587
+ type BodyBudget = {
1588
+ readonly maxBodyBytes: number;
1589
+ readonly deadlineCheckpoint: DeadlineCheckpoint;
1590
+ directBodyBytes: number;
1591
+ decodedBodyBytes: number;
1592
+ renderedBodyBytes: number;
1593
+ };
1594
+
1595
+ type ParsedMessageBodyState = Readonly<{
1596
+ readonly plain: readonly BodyFragment[];
1597
+ readonly html: readonly BodyFragment[];
1598
+ }>;
1599
+
1600
+ type ParsedThreadBodyState = Readonly<{
1601
+ readonly maxBodyBytes: number;
1602
+ readonly decodedBodyBytes: number;
1603
+ readonly renderedBodyBytes: number;
1604
+ readonly messages: readonly ParsedMessageBodyState[];
1605
+ }>;
1606
+
1607
+ type MimeAccumulator = {
1608
+ readonly plain: BodyFragment[];
1609
+ readonly html: BodyFragment[];
1610
+ readonly attachments: GmailAttachment[];
1611
+ readonly partIds: Set<string>;
1612
+ readonly bodyBudget: BodyBudget;
1613
+ nodes: number;
1614
+ };
1615
+
1616
+ function bodyBudgetFailure(maxBodyBytes: number): never {
1617
+ return fail(
1618
+ "MIME body",
1619
+ `exceeds ${maxBodyBytes} rendered bytes or decoded MIME bytes`,
1620
+ );
1621
+ }
1622
+
1623
+ function reserveDecodedBodyBytes(bodyBudget: BodyBudget, bytes: number): void {
1624
+ const nextBytes = bodyBudget.decodedBodyBytes + bytes;
1625
+ if (!Number.isSafeInteger(nextBytes) || nextBytes > bodyBudget.maxBodyBytes) {
1626
+ return bodyBudgetFailure(bodyBudget.maxBodyBytes);
1627
+ }
1628
+ bodyBudget.decodedBodyBytes = nextBytes;
1629
+ }
1630
+
1631
+ function reserveDirectBodyBytes(bodyBudget: BodyBudget, bytes: number): void {
1632
+ const nextBytes = bodyBudget.directBodyBytes + bytes;
1633
+ if (!Number.isSafeInteger(nextBytes) || nextBytes > MAX_DIRECT_BODY_BYTES) {
1634
+ return fail(
1635
+ "direct MIME body",
1636
+ `exceeds the ${MAX_DIRECT_BODY_BYTES}-byte decoded body.data aggregate`,
1637
+ );
1638
+ }
1639
+ bodyBudget.directBodyBytes = nextBytes;
1640
+ reserveDecodedBodyBytes(bodyBudget, bytes);
1641
+ }
1642
+
1643
+ function appendRenderedBodyText(
1644
+ bodyBudget: BodyBudget,
1645
+ target: BodyFragment[],
1646
+ value: string,
1647
+ ): void {
1648
+ if (value === "") return;
1649
+ const nextBytes = bodyBudget.renderedBodyBytes + Buffer.byteLength(value, "utf8");
1650
+ if (!Number.isSafeInteger(nextBytes) || nextBytes > bodyBudget.maxBodyBytes) {
1651
+ return bodyBudgetFailure(bodyBudget.maxBodyBytes);
1652
+ }
1653
+ bodyBudget.renderedBodyBytes = nextBytes;
1654
+ target.push(value);
1655
+ }
1656
+
1657
+ function parseMimePart(
1658
+ value: unknown,
1659
+ path: string,
1660
+ messageId: string,
1661
+ depth: number,
1662
+ maxDepth: number,
1663
+ accumulator: MimeAccumulator,
1664
+ ): void {
1665
+ accumulator.bodyBudget.deadlineCheckpoint?.();
1666
+ accumulator.nodes += 1;
1667
+ if (depth > maxDepth || accumulator.nodes > MAX_MIME_PARTS) {
1668
+ return fail(path, "exceeds the reviewed MIME tree bound");
1669
+ }
1670
+ const source = record(value, path);
1671
+ exactKeys(source, [], ["partId", "mimeType", "filename", "headers", "body", "parts"], path);
1672
+ const partId = optionalText(source.partId, `${path}.partId`, 256, true) ?? "";
1673
+ const mimeType = (optionalText(source.mimeType, `${path}.mimeType`, 256) ?? "application/octet-stream").toLowerCase();
1674
+ const filename = optionalText(source.filename, `${path}.filename`, 8_192, true, true) ?? "";
1675
+ const headers = parseHeaders(source.headers, `${path}.headers`);
1676
+ const normalizedDisposition = contentDisposition(
1677
+ headers,
1678
+ filename,
1679
+ `${path}.headers.Content-Disposition`,
1680
+ );
1681
+ let attachmentId: string | null = null;
1682
+ let size = 0;
1683
+ let bodyData: Uint8Array | null = null;
1684
+ if (source.body !== undefined) {
1685
+ const body = record(source.body, `${path}.body`);
1686
+ exactKeys(body, [], ["attachmentId", "size", "data"], `${path}.body`);
1687
+ attachmentId = body.attachmentId === undefined
1688
+ ? null
1689
+ : parseGmailAttachmentId(body.attachmentId, `${path}.body.attachmentId`);
1690
+ size = optionalInteger(body.size, `${path}.body.size`) ?? 0;
1691
+ if (body.data !== undefined) {
1692
+ bodyData = decodeBase64Url(body.data, `${path}.body.data`);
1693
+ if (bodyData.byteLength !== size) return fail(`${path}.body`, "size does not match decoded data");
1694
+ }
1695
+ if (attachmentId !== null && bodyData !== null) {
1696
+ return fail(`${path}.body`, "cannot contain both inline data and an external attachment ID");
1697
+ }
1698
+ }
1699
+ const isText = mimeType === "text/plain" || mimeType === "text/html";
1700
+ const isAttachment = filename !== ""
1701
+ || normalizedDisposition === "attachment"
1702
+ || (!isText && (attachmentId !== null || bodyData !== null));
1703
+ if (isAttachment) {
1704
+ if (partId === "") return fail(`${path}.partId`, "must be nonempty for an attachment part");
1705
+ if (accumulator.partIds.has(partId)) {
1706
+ return fail(`${path}.partId`, "must be unique within its message");
1707
+ }
1708
+ accumulator.partIds.add(partId);
1709
+ if (accumulator.attachments.length >= MAX_MIME_ATTACHMENTS) {
1710
+ return fail(path, `contains more than ${MAX_MIME_ATTACHMENTS} attachments`);
1711
+ }
1712
+ const attachment = Object.freeze({
1713
+ attachmentId,
1714
+ partId,
1715
+ messageId,
1716
+ filename,
1717
+ mimeType,
1718
+ contentDisposition: normalizedDisposition,
1719
+ size,
1720
+ });
1721
+ accumulator.attachments.push(attachment);
1722
+ if (attachmentId === null && bodyData !== null) {
1723
+ inlineAttachmentBytes.set(attachment, bodyData);
1724
+ }
1725
+ } else if (isText) {
1726
+ const target = mimeType === "text/plain" ? accumulator.plain : accumulator.html;
1727
+ if (bodyData !== null) {
1728
+ reserveDirectBodyBytes(accumulator.bodyBudget, bodyData.byteLength);
1729
+ contentCharset(headers, mimeType, `${path}.headers.Content-Type`);
1730
+ const decoded = decodeText(bodyData, `${path}.body.data`);
1731
+ appendRenderedBodyText(
1732
+ accumulator.bodyBudget,
1733
+ target,
1734
+ mimeType === "text/plain"
1735
+ ? decoded
1736
+ : htmlToText(decoded, accumulator.bodyBudget.deadlineCheckpoint),
1737
+ );
1738
+ } else if (attachmentId !== null) {
1739
+ reserveDecodedBodyBytes(accumulator.bodyBudget, size);
1740
+ target.push(Object.freeze({
1741
+ kind: "external",
1742
+ attachmentId,
1743
+ messageId,
1744
+ mimeType,
1745
+ charset: contentCharset(headers, mimeType, `${path}.headers.Content-Type`),
1746
+ size,
1747
+ }));
1748
+ }
1749
+ }
1750
+ if (source.parts !== undefined) {
1751
+ const parts = array(source.parts, `${path}.parts`, MAX_MIME_PARTS);
1752
+ for (const [index, part] of parts.entries()) {
1753
+ parseMimePart(part, `${path}.parts[${index}]`, messageId, depth + 1, maxDepth, accumulator);
1754
+ }
1755
+ }
1756
+ if (partId === "" && source.parts === undefined && source.body === undefined && source.mimeType === undefined) {
1757
+ return fail(path, "must contain reviewed MIME part data");
1758
+ }
1759
+ }
1760
+
1761
+ function joinedBody(parts: readonly BodyFragment[]): string | null {
1762
+ const nonempty = parts.filter((part): part is string =>
1763
+ typeof part === "string" && part !== "");
1764
+ return nonempty.length === 0 ? null : nonempty.join("\n\n");
1765
+ }
1766
+
1767
+ function bodyProjection(state: ParsedMessageBodyState): GmailBodyProjection {
1768
+ const plainText = joinedBody(state.plain);
1769
+ const htmlText = plainText === null ? joinedBody(state.html) : null;
1770
+ return Object.freeze({
1771
+ text: plainText ?? htmlText,
1772
+ source: plainText !== null
1773
+ ? "text/plain" as const
1774
+ : htmlText !== null
1775
+ ? "text/html" as const
1776
+ : "none" as const,
1777
+ });
1778
+ }
1779
+
1780
+ type ParsedMessage = Readonly<{
1781
+ readonly message: GmailMessage;
1782
+ readonly bodyState: ParsedMessageBodyState;
1783
+ }>;
1784
+
1785
+ function parseMessage(
1786
+ value: unknown,
1787
+ label: string,
1788
+ maxDepth: number,
1789
+ bodyBudget: BodyBudget,
1790
+ ): ParsedMessage {
1791
+ const source = record(value, label);
1792
+ exactKeys(source, ["id"], [
1793
+ "threadId",
1794
+ "labelIds",
1795
+ "snippet",
1796
+ "historyId",
1797
+ "internalDate",
1798
+ "payload",
1799
+ "sizeEstimate",
1800
+ "classificationLabelValues",
1801
+ ], label);
1802
+ if (source.classificationLabelValues !== undefined) {
1803
+ // The runtime never requests classification label values. Reject a
1804
+ // response that widens beyond the reviewed data projection.
1805
+ return fail(`${label}.classificationLabelValues`, "is outside the reviewed Gmail projection");
1806
+ }
1807
+ if (source.sizeEstimate !== undefined) integer(source.sizeEstimate, `${label}.sizeEstimate`);
1808
+ const id = parseGmailId(source.id, `${label}.id`);
1809
+ const labelIds = source.labelIds === undefined
1810
+ ? Object.freeze([])
1811
+ : Object.freeze(array(source.labelIds, `${label}.labelIds`, 1_000).map((entry, index) =>
1812
+ text(entry, `${label}.labelIds[${index}]`, 256)));
1813
+ if (new Set(labelIds).size !== labelIds.length) return fail(`${label}.labelIds`, "contains duplicates");
1814
+ const accumulator: MimeAccumulator = {
1815
+ plain: [],
1816
+ html: [],
1817
+ attachments: [],
1818
+ partIds: new Set(),
1819
+ bodyBudget,
1820
+ nodes: 0,
1821
+ };
1822
+ let headers: readonly ParsedHeader[] = Object.freeze([]);
1823
+ if (source.payload !== undefined) {
1824
+ const payload = record(source.payload, `${label}.payload`);
1825
+ headers = parseHeaders(payload.headers, `${label}.payload.headers`);
1826
+ parseMimePart(payload, `${label}.payload`, id, 0, maxDepth, accumulator);
1827
+ }
1828
+ const selected = selectedHeaders(headers, `${label}.headers`);
1829
+ const bodyState = Object.freeze({
1830
+ plain: Object.freeze(accumulator.plain),
1831
+ html: Object.freeze(accumulator.html),
1832
+ });
1833
+ const message = Object.freeze({
1834
+ id,
1835
+ threadId: source.threadId === undefined
1836
+ ? null
1837
+ : parseGmailId(source.threadId, `${label}.threadId`),
1838
+ historyId: optionalHistoryId(source.historyId, `${label}.historyId`),
1839
+ internalDate: optionalInternalDate(source.internalDate, `${label}.internalDate`),
1840
+ labelIds,
1841
+ snippet: optionalText(source.snippet, `${label}.snippet`, 64 * 1024, true, true),
1842
+ ...selected,
1843
+ body: bodyProjection(bodyState),
1844
+ attachments: Object.freeze(accumulator.attachments),
1845
+ });
1846
+ return Object.freeze({ message, bodyState });
1847
+ }
1848
+
1849
+ /** Parse one full or metadata Gmail thread response from unknown. */
1850
+ export function parseGmailThread(
1851
+ value: unknown,
1852
+ options: GmailThreadParseOptions = {},
1853
+ ): GmailThread {
1854
+ const optionSource = record(options, "thread parse options");
1855
+ exactKeys(
1856
+ optionSource,
1857
+ [],
1858
+ ["maxDepth", "maxBodyBytes", "deadlineCheckpoint"],
1859
+ "thread parse options",
1860
+ );
1861
+ if (
1862
+ optionSource.deadlineCheckpoint !== undefined
1863
+ && typeof optionSource.deadlineCheckpoint !== "function"
1864
+ ) return fail("thread parse options.deadlineCheckpoint", "must be a function");
1865
+ const deadlineCheckpoint = optionSource.deadlineCheckpoint as DeadlineCheckpoint;
1866
+ deadlineCheckpoint?.();
1867
+ const maxDepth = optionalInteger(
1868
+ optionSource.maxDepth,
1869
+ "thread parse options.maxDepth",
1870
+ 0,
1871
+ MAX_MIME_DEPTH,
1872
+ ) ?? MAX_MIME_DEPTH;
1873
+ const maxBodyBytes = optionalInteger(
1874
+ optionSource.maxBodyBytes,
1875
+ "thread parse options.maxBodyBytes",
1876
+ 1,
1877
+ MAX_CONFIGURED_RESPONSE_TEXT_BYTES,
1878
+ ) ?? MAX_RESPONSE_TEXT_BYTES;
1879
+ const source = record(value, "thread response");
1880
+ exactKeys(source, ["id", "messages"], ["snippet", "historyId"], "thread response");
1881
+ const id = parseGmailId(source.id, "thread response.id");
1882
+ const bodyBudget: BodyBudget = {
1883
+ maxBodyBytes,
1884
+ deadlineCheckpoint,
1885
+ directBodyBytes: 0,
1886
+ decodedBodyBytes: 0,
1887
+ renderedBodyBytes: 0,
1888
+ };
1889
+ const parsedMessages = array(source.messages, "thread response.messages", 1_000)
1890
+ .map((entry, index) => parseMessage(
1891
+ entry,
1892
+ `thread response.messages[${index}]`,
1893
+ maxDepth,
1894
+ bodyBudget,
1895
+ ));
1896
+ const messages = Object.freeze(parsedMessages.map((entry) => entry.message));
1897
+ uniqueIds(messages, "thread response.messages");
1898
+ for (const message of messages) {
1899
+ if (message.threadId !== null && message.threadId !== id) {
1900
+ return fail("thread response.messages", "contains a message bound to another thread");
1901
+ }
1902
+ }
1903
+ const thread = Object.freeze({
1904
+ id,
1905
+ snippet: optionalText(source.snippet, "thread response.snippet", 64 * 1024, true, true),
1906
+ historyId: optionalHistoryId(source.historyId, "thread response.historyId"),
1907
+ messages,
1908
+ });
1909
+ parsedThreadBodies.set(thread, Object.freeze({
1910
+ maxBodyBytes,
1911
+ decodedBodyBytes: bodyBudget.decodedBodyBytes,
1912
+ renderedBodyBytes: bodyBudget.renderedBodyBytes,
1913
+ messages: Object.freeze(parsedMessages.map((entry) => entry.bodyState)),
1914
+ }));
1915
+ return thread;
1916
+ }
1917
+
1918
+ /** Return render-safe body text, preferring the original text/plain projection. */
1919
+ export function renderGmailBodyProjection(body: GmailBodyProjection): string | null {
1920
+ if (
1921
+ (body.source !== "text/plain" && body.source !== "text/html" && body.source !== "none")
1922
+ || (body.source === "none") !== (body.text === null)
1923
+ ) {
1924
+ throw new Error("Gmail body projection source must bind its rendered text");
1925
+ }
1926
+ return body.text;
1927
+ }
1928
+
1929
+ export async function fetchGmailThread(
1930
+ client: GmailApiClient,
1931
+ threadIdValue: string,
1932
+ maximumResponseBytes?: number,
1933
+ ): Promise<unknown> {
1934
+ const threadId = parseGmailId(threadIdValue, "thread fetch ID");
1935
+ const url = gmailUrl(`/gmail/v1/users/me/threads/${encodedPathSegment(threadId)}`);
1936
+ url.searchParams.set("format", "full");
1937
+ url.searchParams.set("fields", GMAIL_THREAD_FIELDS);
1938
+ return gmailGet(client, url, maximumResponseBytes);
1939
+ }
1940
+
1941
+ export async function fetchGmailThreadMetadata(
1942
+ client: GmailApiClient,
1943
+ threadIdValue: string,
1944
+ ): Promise<unknown> {
1945
+ const threadId = parseGmailId(threadIdValue, "thread metadata fetch ID");
1946
+ const url = gmailUrl(`/gmail/v1/users/me/threads/${encodedPathSegment(threadId)}`);
1947
+ url.searchParams.set("format", "metadata");
1948
+ for (const header of ["From", "To", "Cc", "Bcc", "Subject", "Date", "Message-ID", "In-Reply-To"]) {
1949
+ url.searchParams.append("metadataHeaders", header);
1950
+ }
1951
+ url.searchParams.set("fields", GMAIL_THREAD_FIELDS);
1952
+ return gmailGet(client, url);
1953
+ }
1954
+
1955
+ export async function fetchGmailMessageMetadata(
1956
+ client: GmailApiClient,
1957
+ messageIdValue: string,
1958
+ ): Promise<GmailMessage> {
1959
+ const messageId = parseGmailId(messageIdValue, "message metadata fetch ID");
1960
+ const url = gmailUrl(`/gmail/v1/users/me/messages/${encodedPathSegment(messageId)}`);
1961
+ url.searchParams.set("format", "metadata");
1962
+ for (const header of ["From", "To", "Cc", "Bcc", "Subject", "Date", "Message-ID", "In-Reply-To"]) {
1963
+ url.searchParams.append("metadataHeaders", header);
1964
+ }
1965
+ url.searchParams.set("fields", GMAIL_MESSAGE_FIELDS);
1966
+ const bodyBudget: BodyBudget = {
1967
+ maxBodyBytes: MAX_RESPONSE_TEXT_BYTES,
1968
+ deadlineCheckpoint: () => client.http.throwIfUnavailable(),
1969
+ directBodyBytes: 0,
1970
+ decodedBodyBytes: 0,
1971
+ renderedBodyBytes: 0,
1972
+ };
1973
+ return parseMessage(
1974
+ await gmailGet(client, url),
1975
+ "message metadata response",
1976
+ MAX_MIME_DEPTH,
1977
+ bodyBudget,
1978
+ ).message;
1979
+ }
1980
+
1981
+ /**
1982
+ * Fetch only the message metadata needed to project contact interactions.
1983
+ * The Gmail partial-response mask can yield a headers-only payload, so the
1984
+ * code-owned MIME defaults below adapt that exact projection before the
1985
+ * ordinary strict Gmail message parser validates it.
1986
+ */
1987
+ export async function fetchGmailMessageInteractionMetadata(
1988
+ client: GmailApiClient,
1989
+ messageIdValue: string,
1990
+ ): Promise<GmailMessage> {
1991
+ const messageId = parseGmailId(messageIdValue, "interaction message metadata fetch ID");
1992
+ const url = gmailUrl(`/gmail/v1/users/me/messages/${encodedPathSegment(messageId)}`);
1993
+ url.searchParams.set("format", "metadata");
1994
+ for (const header of ["From", "To", "Cc", "Bcc"]) {
1995
+ url.searchParams.append("metadataHeaders", header);
1996
+ }
1997
+ url.searchParams.set("fields", GMAIL_INTERACTION_MESSAGE_FIELDS);
1998
+ const response = record(
1999
+ await gmailGet(client, url),
2000
+ "interaction message metadata response",
2001
+ );
2002
+ let projectedResponse: JsonRecord = response;
2003
+ if (response.payload !== undefined) {
2004
+ const payload = record(
2005
+ response.payload,
2006
+ "interaction message metadata response.payload",
2007
+ );
2008
+ exactKeys(
2009
+ payload,
2010
+ [],
2011
+ ["headers"],
2012
+ "interaction message metadata response.payload",
2013
+ );
2014
+ const headers = interactionMetadataHeaders(
2015
+ payload.headers,
2016
+ "interaction message metadata response.payload.headers",
2017
+ );
2018
+ projectedResponse = Object.freeze({
2019
+ ...response,
2020
+ payload: Object.freeze({
2021
+ partId: "",
2022
+ mimeType: "application/octet-stream",
2023
+ headers,
2024
+ }),
2025
+ });
2026
+ }
2027
+ const bodyBudget: BodyBudget = {
2028
+ maxBodyBytes: MAX_RESPONSE_TEXT_BYTES,
2029
+ deadlineCheckpoint: () => client.http.throwIfUnavailable(),
2030
+ directBodyBytes: 0,
2031
+ decodedBodyBytes: 0,
2032
+ renderedBodyBytes: 0,
2033
+ };
2034
+ return parseMessage(
2035
+ projectedResponse,
2036
+ "interaction message metadata response",
2037
+ MAX_MIME_DEPTH,
2038
+ bodyBudget,
2039
+ ).message;
2040
+ }
2041
+
2042
+ export async function fetchGmailAttachmentBytes(
2043
+ client: GmailApiClient,
2044
+ messageIdValue: string,
2045
+ attachmentIdValue: string,
2046
+ maximumResponseBytes?: number,
2047
+ ): Promise<Uint8Array> {
2048
+ const messageId = parseGmailId(messageIdValue, "attachment message ID");
2049
+ const attachmentId = parseGmailAttachmentId(attachmentIdValue, "attachment ID");
2050
+ const url = gmailUrl(
2051
+ `/gmail/v1/users/me/messages/${encodedPathSegment(messageId)}/attachments/${encodedPathSegment(attachmentId)}`,
2052
+ );
2053
+ url.searchParams.set("fields", GMAIL_ATTACHMENT_FIELDS);
2054
+ const raw = await gmailGet(
2055
+ client,
2056
+ url,
2057
+ maximumResponseBytes,
2058
+ );
2059
+ const source = record(raw, "attachment response");
2060
+ exactKeys(source, ["size", "data"], ["attachmentId"], "attachment response");
2061
+ if (source.attachmentId !== undefined) {
2062
+ const returned = parseGmailAttachmentId(source.attachmentId, "attachment response.attachmentId");
2063
+ if (returned !== attachmentId) return fail("attachment response.attachmentId", "does not match the requested attachment");
2064
+ }
2065
+ const size = integer(source.size, "attachment response.size");
2066
+ const bytes = decodeBase64Url(source.data, "attachment response.data");
2067
+ if (bytes.byteLength !== size) return fail("attachment response", "size does not match decoded data");
2068
+ return bytes;
2069
+ }
2070
+
2071
+ /**
2072
+ * Resolve one parsed attachment without exposing its bytes in the normal
2073
+ * thread projection. Inline MIME bytes are retained only in this module's
2074
+ * weak side table; external attachments use the reviewed GET endpoint.
2075
+ */
2076
+ export async function resolveGmailAttachmentBytes(
2077
+ client: GmailApiClient,
2078
+ attachment: GmailAttachment,
2079
+ maximumResponseBytes?: number,
2080
+ ): Promise<Uint8Array> {
2081
+ const inline = inlineAttachmentBytes.get(attachment);
2082
+ if (inline !== undefined) return new Uint8Array(inline);
2083
+ if (attachment.attachmentId === null) {
2084
+ throw new Error("Gmail inline attachment bytes are unavailable outside their parsed thread lifetime");
2085
+ }
2086
+ return fetchGmailAttachmentBytes(
2087
+ client,
2088
+ attachment.messageId,
2089
+ attachment.attachmentId,
2090
+ maximumResponseBytes,
2091
+ );
2092
+ }
2093
+
2094
+ function externalBodyResponseMaximumBytes(decodedBytes: number): number {
2095
+ const encodedBytes = Math.ceil(decodedBytes / 3) * 4;
2096
+ const maximum = encodedBytes + MAX_ATTACHMENT_RESPONSE_ENVELOPE_BYTES;
2097
+ if (!Number.isSafeInteger(maximum)) {
2098
+ return fail("external MIME body", "has an unsafe response byte bound");
2099
+ }
2100
+ return maximum;
2101
+ }
2102
+
2103
+ function containsExternalBody(state: ParsedThreadBodyState): boolean {
2104
+ return state.messages.some((message) =>
2105
+ [...message.plain, ...message.html].some((fragment) => typeof fragment !== "string"));
2106
+ }
2107
+
2108
+ /**
2109
+ * Resolve externally stored text/plain and text/html MIME leaves into the
2110
+ * render-safe thread projection. Gmail uses the attachment endpoint for these
2111
+ * large body leaves even though they are message bodies, not file attachments.
2112
+ * The parser's one aggregate thread budget remains authoritative.
2113
+ */
2114
+ export async function resolveGmailThreadBodies(
2115
+ client: GmailApiClient,
2116
+ thread: GmailThread,
2117
+ maximumResponseBytes?: number,
2118
+ ): Promise<GmailThread> {
2119
+ const state = parsedThreadBodies.get(thread);
2120
+ if (state === undefined) {
2121
+ throw new Error("Gmail thread body resolution requires the exact parsed thread object");
2122
+ }
2123
+ if (!containsExternalBody(state)) return thread;
2124
+ const configuredMaximum = maximumResponseBytes === undefined
2125
+ ? null
2126
+ : integer(
2127
+ maximumResponseBytes,
2128
+ "external MIME body response maximum",
2129
+ 1,
2130
+ Number.MAX_SAFE_INTEGER,
2131
+ );
2132
+ const bodyBudget: BodyBudget = {
2133
+ maxBodyBytes: state.maxBodyBytes,
2134
+ deadlineCheckpoint: () => client.http.throwIfUnavailable(),
2135
+ directBodyBytes: 0,
2136
+ decodedBodyBytes: state.decodedBodyBytes,
2137
+ renderedBodyBytes: state.renderedBodyBytes,
2138
+ };
2139
+ const bytesByEndpoint = new Map<string, Uint8Array>();
2140
+ const resolvedStates: ParsedMessageBodyState[] = [];
2141
+
2142
+ const resolveFragments = async (
2143
+ fragments: readonly BodyFragment[],
2144
+ ): Promise<readonly BodyFragment[]> => {
2145
+ const resolved: BodyFragment[] = [];
2146
+ for (const fragment of fragments) {
2147
+ if (typeof fragment === "string") {
2148
+ resolved.push(fragment);
2149
+ continue;
2150
+ }
2151
+ const endpointIdentity = `${fragment.messageId}\0${fragment.attachmentId}`;
2152
+ let bytes = bytesByEndpoint.get(endpointIdentity);
2153
+ if (bytes === undefined) {
2154
+ const derivedMaximum = externalBodyResponseMaximumBytes(fragment.size);
2155
+ bytes = await fetchGmailAttachmentBytes(
2156
+ client,
2157
+ fragment.messageId,
2158
+ fragment.attachmentId,
2159
+ configuredMaximum === null
2160
+ ? derivedMaximum
2161
+ : Math.min(configuredMaximum, derivedMaximum),
2162
+ );
2163
+ if (bytes.byteLength !== fragment.size) {
2164
+ return fail("external MIME body", "does not match its declared byte size");
2165
+ }
2166
+ bytesByEndpoint.set(endpointIdentity, bytes);
2167
+ } else if (bytes.byteLength !== fragment.size) {
2168
+ return fail("external MIME body", "reuses an attachment ID with a conflicting declared size");
2169
+ }
2170
+ bodyBudget.deadlineCheckpoint?.();
2171
+ const decoded = decodeText(bytes, "external MIME body");
2172
+ const rendered = fragment.mimeType === "text/plain"
2173
+ ? decoded
2174
+ : htmlToText(decoded, bodyBudget.deadlineCheckpoint);
2175
+ appendRenderedBodyText(bodyBudget, resolved, rendered);
2176
+ }
2177
+ return Object.freeze(resolved);
2178
+ };
2179
+
2180
+ for (const messageState of state.messages) {
2181
+ resolvedStates.push(Object.freeze({
2182
+ plain: await resolveFragments(messageState.plain),
2183
+ html: await resolveFragments(messageState.html),
2184
+ }));
2185
+ }
2186
+ const messages = Object.freeze(thread.messages.map((message, index) => {
2187
+ const bodyState = resolvedStates[index];
2188
+ if (bodyState === undefined) {
2189
+ throw new Error("Gmail parsed thread body state lost a message");
2190
+ }
2191
+ return Object.freeze({
2192
+ ...message,
2193
+ body: bodyProjection(bodyState),
2194
+ });
2195
+ }));
2196
+ const resolvedThread = Object.freeze({
2197
+ ...thread,
2198
+ messages,
2199
+ });
2200
+ parsedThreadBodies.set(resolvedThread, Object.freeze({
2201
+ maxBodyBytes: state.maxBodyBytes,
2202
+ decodedBodyBytes: bodyBudget.decodedBodyBytes,
2203
+ renderedBodyBytes: bodyBudget.renderedBodyBytes,
2204
+ messages: Object.freeze(resolvedStates),
2205
+ }));
2206
+ return resolvedThread;
2207
+ }
2208
+
2209
+ function optionalPersonText(value: unknown, label: string, maximum: number): string | null {
2210
+ return value === undefined ? null : text(value, label, maximum, true, true);
2211
+ }
2212
+
2213
+ function optionalBoolean(value: unknown, label: string): boolean | null {
2214
+ if (value === undefined) return null;
2215
+ if (typeof value !== "boolean") return fail(label, "must be boolean");
2216
+ return value;
2217
+ }
2218
+
2219
+ function contactSourceType(value: unknown, label: string): GmailContactSource["type"] {
2220
+ const result = text(value, label, 64);
2221
+ const values: readonly GmailContactSource["type"][] = [
2222
+ "SOURCE_TYPE_UNSPECIFIED",
2223
+ "ACCOUNT",
2224
+ "PROFILE",
2225
+ "DOMAIN_PROFILE",
2226
+ "CONTACT",
2227
+ "OTHER_CONTACT",
2228
+ "DOMAIN_CONTACT",
2229
+ ];
2230
+ if (!values.includes(result as GmailContactSource["type"])) {
2231
+ return fail(label, "must be a reviewed People source type");
2232
+ }
2233
+ return result as GmailContactSource["type"];
2234
+ }
2235
+
2236
+ function peopleTimestamp(value: unknown, label: string): string {
2237
+ const result = text(value, label, 64);
2238
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?Z$/u.exec(result);
2239
+ if (match === null) return fail(label, "must be a UTC RFC3339 timestamp");
2240
+ const year = Number(match[1]);
2241
+ const month = Number(match[2]);
2242
+ const day = Number(match[3]);
2243
+ const hour = Number(match[4]);
2244
+ const minute = Number(match[5]);
2245
+ const second = Number(match[6]);
2246
+ if (
2247
+ year < 1
2248
+ || month < 1
2249
+ || month > 12
2250
+ || day < 1
2251
+ || day > calendarMonthDays(year, month)
2252
+ || hour > 23
2253
+ || minute > 59
2254
+ || second > 59
2255
+ ) return fail(label, "must contain a valid UTC calendar date and time");
2256
+ return result;
2257
+ }
2258
+
2259
+ function parseContactSource(value: unknown, label: string): GmailContactSource {
2260
+ const source = record(value, label);
2261
+ exactKeys(source, ["type", "id"], ["etag", "updateTime"], label);
2262
+ return Object.freeze({
2263
+ type: contactSourceType(source.type, `${label}.type`),
2264
+ id: text(source.id, `${label}.id`, 512),
2265
+ etag: optionalPersonText(source.etag, `${label}.etag`, 1_024),
2266
+ updateTime: source.updateTime === undefined
2267
+ ? null
2268
+ : peopleTimestamp(source.updateTime, `${label}.updateTime`),
2269
+ });
2270
+ }
2271
+
2272
+ function parseFieldMetadata(value: unknown, label: string): GmailContactFieldMetadata | null {
2273
+ if (value === undefined) return null;
2274
+ const source = record(value, label);
2275
+ exactKeys(source, [], ["primary", "sourcePrimary", "verified", "source"], label);
2276
+ let parsedSource: GmailContactFieldMetadata["source"] = null;
2277
+ if (source.source !== undefined) {
2278
+ const item = record(source.source, `${label}.source`);
2279
+ exactKeys(item, ["type", "id"], [], `${label}.source`);
2280
+ parsedSource = Object.freeze({
2281
+ type: contactSourceType(item.type, `${label}.source.type`),
2282
+ id: text(item.id, `${label}.source.id`, 512),
2283
+ });
2284
+ }
2285
+ return Object.freeze({
2286
+ primary: optionalBoolean(source.primary, `${label}.primary`),
2287
+ sourcePrimary: optionalBoolean(source.sourcePrimary, `${label}.sourcePrimary`),
2288
+ verified: optionalBoolean(source.verified, `${label}.verified`),
2289
+ source: parsedSource,
2290
+ });
2291
+ }
2292
+
2293
+ function parseContactMetadata(
2294
+ value: unknown,
2295
+ label: string,
2296
+ ): GmailContact["metadata"] {
2297
+ if (value === undefined) return null;
2298
+ const source = record(value, label);
2299
+ exactKeys(source, [], ["sources", "deleted"], label);
2300
+ const sources = source.sources === undefined
2301
+ ? Object.freeze([])
2302
+ : Object.freeze(array(source.sources, `${label}.sources`, 100).map((entry, index) =>
2303
+ parseContactSource(entry, `${label}.sources[${index}]`)));
2304
+ const identities = sources.map((item) => `${item.type}\0${item.id}`);
2305
+ if (new Set(identities).size !== identities.length) {
2306
+ return fail(`${label}.sources`, "contains duplicate source identities");
2307
+ }
2308
+ return Object.freeze({
2309
+ deleted: optionalBoolean(source.deleted, `${label}.deleted`),
2310
+ sources,
2311
+ });
2312
+ }
2313
+
2314
+ function parseEmailAddresses(value: unknown, label: string): GmailContact["emailAddresses"] {
2315
+ if (value === undefined) return Object.freeze([]);
2316
+ const emails = array(value, label, 100).map((entry, index) => {
2317
+ const path = `${label}[${index}]`;
2318
+ const source = record(entry, path);
2319
+ exactKeys(source, ["value"], ["type", "metadata"], path);
2320
+ const candidate = text(source.value, `${path}.value`, 254);
2321
+ return Object.freeze({
2322
+ value: candidate,
2323
+ canonicalValue: isGmailAccountSubject(candidate) ? candidate.toLowerCase() : null,
2324
+ type: optionalPersonText(source.type, `${path}.type`, 128),
2325
+ metadata: parseFieldMetadata(source.metadata, `${path}.metadata`),
2326
+ });
2327
+ });
2328
+ const deduplicated = new Map<string, (typeof emails)[number]>();
2329
+ for (const email of emails) {
2330
+ const key = email.canonicalValue ?? email.value.toLowerCase();
2331
+ const existing = deduplicated.get(key);
2332
+ if (existing === undefined || (existing.metadata?.primary !== true && email.metadata?.primary === true)) {
2333
+ deduplicated.set(key, email);
2334
+ }
2335
+ }
2336
+ return Object.freeze([...deduplicated.values()]);
2337
+ }
2338
+
2339
+ function parsePhoneNumbers(value: unknown, label: string): GmailContact["phoneNumbers"] {
2340
+ if (value === undefined) return Object.freeze([]);
2341
+ const phones = array(value, label, 100).map((entry, index) => {
2342
+ const path = `${label}[${index}]`;
2343
+ const source = record(entry, path);
2344
+ exactKeys(source, ["value"], ["type", "canonicalForm", "metadata"], path);
2345
+ return Object.freeze({
2346
+ value: text(source.value, `${path}.value`, 256),
2347
+ canonicalForm: optionalPersonText(source.canonicalForm, `${path}.canonicalForm`, 256),
2348
+ type: optionalPersonText(source.type, `${path}.type`, 128),
2349
+ metadata: parseFieldMetadata(source.metadata, `${path}.metadata`),
2350
+ });
2351
+ });
2352
+ const deduplicated = new Map<string, (typeof phones)[number]>();
2353
+ for (const phone of phones) {
2354
+ const key = phone.canonicalForm ?? phone.value;
2355
+ const existing = deduplicated.get(key);
2356
+ if (existing === undefined || (existing.metadata?.primary !== true && phone.metadata?.primary === true)) {
2357
+ deduplicated.set(key, phone);
2358
+ }
2359
+ }
2360
+ return Object.freeze([...deduplicated.values()]);
2361
+ }
2362
+
2363
+ function parseOrganizations(value: unknown, label: string): GmailContact["organizations"] {
2364
+ if (value === undefined) return Object.freeze([]);
2365
+ return Object.freeze(array(value, label, 100).map((entry, index) => {
2366
+ const path = `${label}[${index}]`;
2367
+ const source = record(entry, path);
2368
+ exactKeys(source, [], ["name", "title", "department", "type", "current"], path);
2369
+ return Object.freeze({
2370
+ name: optionalPersonText(source.name, `${path}.name`, 2_048),
2371
+ title: optionalPersonText(source.title, `${path}.title`, 2_048),
2372
+ department: optionalPersonText(source.department, `${path}.department`, 2_048),
2373
+ type: optionalPersonText(source.type, `${path}.type`, 128),
2374
+ current: optionalBoolean(source.current, `${path}.current`),
2375
+ });
2376
+ }));
2377
+ }
2378
+
2379
+ function parsePersonDate(
2380
+ value: unknown,
2381
+ label: string,
2382
+ ): NonNullable<GmailContactDate["date"]> {
2383
+ const source = record(value, label);
2384
+ exactKeys(source, [], ["year", "month", "day"], label);
2385
+ const year = source.year === undefined ? 0 : integer(source.year, `${label}.year`, 0, 9_999);
2386
+ const month = source.month === undefined ? 0 : integer(source.month, `${label}.month`, 0, 12);
2387
+ const day = source.day === undefined ? 0 : integer(source.day, `${label}.day`, 0, 31);
2388
+ if (year === 0 && month === 0 && day === 0) return fail(label, "must contain a date component");
2389
+ if (month === 0 && day !== 0) return fail(label, "cannot contain a day without a month");
2390
+ if (year === 0 && month !== 0 && day === 0) {
2391
+ return fail(label, "cannot contain a month without a year or day");
2392
+ }
2393
+ if (month !== 0 && day > calendarMonthDays(year === 0 ? 2_000 : year, month)) {
2394
+ return fail(label, "must contain a valid Gregorian date");
2395
+ }
2396
+ return Object.freeze({ year, month, day });
2397
+ }
2398
+
2399
+ function parseContactDates(
2400
+ value: unknown,
2401
+ label: string,
2402
+ ): readonly GmailContactDate[] {
2403
+ if (value === undefined) return Object.freeze([]);
2404
+ return Object.freeze(array(value, label, 100).map((entry, index) => {
2405
+ const path = `${label}[${index}]`;
2406
+ const source = record(entry, path);
2407
+ exactKeys(source, [], ["date", "text", "metadata"], path);
2408
+ const date = source.date === undefined ? null : parsePersonDate(source.date, `${path}.date`);
2409
+ const textValue = optionalPersonText(source.text, `${path}.text`, 1_024);
2410
+ const text = textValue === "" ? null : textValue;
2411
+ const metadata = parseFieldMetadata(source.metadata, `${path}.metadata`);
2412
+ if (
2413
+ date === null
2414
+ && text === null
2415
+ && metadata?.source?.type !== "CONTACT"
2416
+ ) {
2417
+ return fail(
2418
+ path,
2419
+ "must contain a date, text value, or exact CONTACT source-bound empty observation",
2420
+ );
2421
+ }
2422
+ return Object.freeze({
2423
+ date,
2424
+ text,
2425
+ metadata,
2426
+ });
2427
+ }));
2428
+ }
2429
+
2430
+ function parseContactEvents(
2431
+ value: unknown,
2432
+ label: string,
2433
+ ): NonNullable<GmailContact["events"]> {
2434
+ if (value === undefined) return Object.freeze([]);
2435
+ return Object.freeze(array(value, label, 100).map((entry, index) => {
2436
+ const path = `${label}[${index}]`;
2437
+ const source = record(entry, path);
2438
+ exactKeys(source, ["date"], ["type", "formattedType", "metadata"], path);
2439
+ return Object.freeze({
2440
+ date: parsePersonDate(source.date, `${path}.date`),
2441
+ text: null,
2442
+ metadata: parseFieldMetadata(source.metadata, `${path}.metadata`),
2443
+ type: optionalPersonText(source.type, `${path}.type`, 128),
2444
+ formattedType: optionalPersonText(source.formattedType, `${path}.formattedType`, 256),
2445
+ });
2446
+ }));
2447
+ }
2448
+
2449
+ function googlePhotoUrl(value: unknown, label: string): string | null {
2450
+ const raw = optionalPersonText(value, label, 8_192);
2451
+ if (raw === null || raw === "") return null;
2452
+ let url: URL;
2453
+ try {
2454
+ url = new URL(raw);
2455
+ } catch {
2456
+ return null;
2457
+ }
2458
+ if (
2459
+ url.protocol !== "https:"
2460
+ || url.username !== ""
2461
+ || url.password !== ""
2462
+ || url.port !== ""
2463
+ || url.search !== ""
2464
+ || url.hash !== ""
2465
+ || (
2466
+ !/^lh[1-9]\.googleusercontent\.com$/u.test(url.hostname)
2467
+ && url.hostname !== "profiles.googleusercontent.com"
2468
+ )
2469
+ ) return null;
2470
+ return url.href;
2471
+ }
2472
+
2473
+ function peopleResourceName(value: unknown, label: string): string {
2474
+ const resourceName = text(value, label, 512);
2475
+ if (!/^people\/[A-Za-z0-9_-]{1,256}$/u.test(resourceName)) {
2476
+ return fail(label, "must be an exact People resource name");
2477
+ }
2478
+ return resourceName;
2479
+ }
2480
+
2481
+ function contactResourceName(value: unknown, label: string): string {
2482
+ const resourceName = text(value, label, 512);
2483
+ if (!/^(?:people|otherContacts)\/[A-Za-z0-9_-]{1,256}$/u.test(resourceName)) {
2484
+ return fail(label, "must be an exact contact resource name");
2485
+ }
2486
+ return resourceName;
2487
+ }
2488
+
2489
+ function parseContact(
2490
+ value: unknown,
2491
+ label: string,
2492
+ projection: GmailContactProjection,
2493
+ ): GmailContact {
2494
+ const source = record(value, label);
2495
+ exactKeys(source, ["resourceName"], [
2496
+ "etag",
2497
+ "metadata",
2498
+ "names",
2499
+ "emailAddresses",
2500
+ "phoneNumbers",
2501
+ "organizations",
2502
+ "photos",
2503
+ ...(projection === "dates" ? ["birthdays", "events"] : []),
2504
+ ], label);
2505
+ const resourceName = contactResourceName(source.resourceName, `${label}.resourceName`);
2506
+ let displayName: string | null = null;
2507
+ let selectedName: NonNullable<GmailContact["name"]> | null = null;
2508
+ const parsedNames: NonNullable<GmailContact["name"]>[] = [];
2509
+ if (source.names !== undefined) {
2510
+ const names = array(source.names, `${label}.names`, 100);
2511
+ for (const [index, entry] of names.entries()) {
2512
+ const path = `${label}.names[${index}]`;
2513
+ const name = record(entry, path);
2514
+ exactKeys(name, [], [
2515
+ "displayName",
2516
+ "givenName",
2517
+ ...(projection === "dates" ? ["middleName"] : []),
2518
+ "familyName",
2519
+ ...(projection === "dates" ? ["honorificPrefix", "honorificSuffix"] : []),
2520
+ "metadata",
2521
+ ], path);
2522
+ const candidate = optionalPersonText(name.displayName, `${path}.displayName`, 2_048);
2523
+ const metadata = parseFieldMetadata(name.metadata, `${path}.metadata`);
2524
+ const parsedName = Object.freeze({
2525
+ displayName: candidate === "" ? null : candidate,
2526
+ givenName: optionalPersonText(name.givenName, `${path}.givenName`, 1_024),
2527
+ middleName: projection === "dates"
2528
+ ? optionalPersonText(name.middleName, `${path}.middleName`, 1_024)
2529
+ : null,
2530
+ familyName: optionalPersonText(name.familyName, `${path}.familyName`, 1_024),
2531
+ honorificPrefix: projection === "dates"
2532
+ ? optionalPersonText(name.honorificPrefix, `${path}.honorificPrefix`, 256)
2533
+ : null,
2534
+ honorificSuffix: projection === "dates"
2535
+ ? optionalPersonText(name.honorificSuffix, `${path}.honorificSuffix`, 256)
2536
+ : null,
2537
+ metadata,
2538
+ });
2539
+ if (projection === "dates") parsedNames.push(parsedName);
2540
+ else if (
2541
+ candidate !== null
2542
+ && candidate !== ""
2543
+ && (displayName === null || metadata?.primary === true)
2544
+ ) displayName = candidate;
2545
+ }
2546
+ if (projection === "dates") {
2547
+ const primaryNames = parsedNames.filter((name) =>
2548
+ name.metadata?.primary === true);
2549
+ if (primaryNames.length > 1) {
2550
+ return fail(`${label}.names`, "must not contain multiple primary names");
2551
+ }
2552
+ if (primaryNames.length === 0 && parsedNames.length > 1) {
2553
+ return fail(
2554
+ `${label}.names`,
2555
+ "must contain at most one name when no primary name is marked",
2556
+ );
2557
+ }
2558
+ selectedName = primaryNames[0] ?? parsedNames[0] ?? null;
2559
+ displayName = selectedName?.displayName ?? null;
2560
+ }
2561
+ }
2562
+ let photoUrl: string | null = null;
2563
+ if (source.photos !== undefined) {
2564
+ const photos = array(source.photos, `${label}.photos`, 100);
2565
+ for (const [index, entry] of photos.entries()) {
2566
+ const path = `${label}.photos[${index}]`;
2567
+ const photo = record(entry, path);
2568
+ exactKeys(photo, ["url"], ["default"], path);
2569
+ if (photo.default !== undefined && typeof photo.default !== "boolean") {
2570
+ return fail(`${path}.default`, "must be boolean");
2571
+ }
2572
+ const candidate = googlePhotoUrl(photo.url, `${path}.url`);
2573
+ if (photoUrl === null && candidate !== null && photo.default !== true) photoUrl = candidate;
2574
+ }
2575
+ }
2576
+ return Object.freeze({
2577
+ resourceName,
2578
+ etag: optionalPersonText(source.etag, `${label}.etag`, 1_024),
2579
+ metadata: parseContactMetadata(source.metadata, `${label}.metadata`),
2580
+ displayName,
2581
+ ...(projection === "dates" ? { name: selectedName } : {}),
2582
+ emailAddresses: parseEmailAddresses(source.emailAddresses, `${label}.emailAddresses`),
2583
+ phoneNumbers: parsePhoneNumbers(source.phoneNumbers, `${label}.phoneNumbers`),
2584
+ organizations: parseOrganizations(source.organizations, `${label}.organizations`),
2585
+ photoUrl,
2586
+ ...(projection === "dates" ? {
2587
+ birthdays: parseContactDates(source.birthdays, `${label}.birthdays`),
2588
+ events: parseContactEvents(source.events, `${label}.events`),
2589
+ } : {}),
2590
+ });
2591
+ }
2592
+
2593
+ type PeopleConnectionIndexPage = Readonly<{
2594
+ resourceNames: readonly string[];
2595
+ nextPageToken: string | null;
2596
+ totalItems: number | null;
2597
+ }>;
2598
+
2599
+ function parseOtherContactsPage(value: unknown, maximum: number): GmailContactPage {
2600
+ const source = record(value, "People Other contacts response");
2601
+ exactKeys(source, [], [
2602
+ "otherContacts",
2603
+ "nextPageToken",
2604
+ "totalSize",
2605
+ ], "People Other contacts response");
2606
+ const contacts = source.otherContacts === undefined
2607
+ ? Object.freeze([])
2608
+ : Object.freeze(array(
2609
+ source.otherContacts,
2610
+ "People Other contacts response.otherContacts",
2611
+ maximum,
2612
+ ).map((entry, index) => {
2613
+ const contact = parseContact(
2614
+ entry,
2615
+ `People Other contacts response.otherContacts[${index}]`,
2616
+ "core",
2617
+ );
2618
+ if (!contact.resourceName.startsWith("otherContacts/")) {
2619
+ return fail(
2620
+ `People Other contacts response.otherContacts[${index}].resourceName`,
2621
+ "must identify an Other contact",
2622
+ );
2623
+ }
2624
+ return contact;
2625
+ }));
2626
+ const identities = contacts.map((contact) => contact.resourceName);
2627
+ if (new Set(identities).size !== identities.length) {
2628
+ return fail(
2629
+ "People Other contacts response.otherContacts",
2630
+ "contains duplicate resource names",
2631
+ );
2632
+ }
2633
+ return Object.freeze({
2634
+ contacts,
2635
+ nextPageToken: optionalPageToken(
2636
+ source.nextPageToken,
2637
+ "People Other contacts response.nextPageToken",
2638
+ ),
2639
+ totalItems: optionalInteger(
2640
+ source.totalSize,
2641
+ "People Other contacts response.totalSize",
2642
+ ),
2643
+ });
2644
+ }
2645
+
2646
+ function parseConnectionIndexPage(value: unknown, maximum: number): PeopleConnectionIndexPage {
2647
+ const source = record(value, "People connections response");
2648
+ exactKeys(source, [], [
2649
+ "connections",
2650
+ "nextPageToken",
2651
+ "totalItems",
2652
+ ], "People connections response");
2653
+ const resourceNames = source.connections === undefined
2654
+ ? Object.freeze([])
2655
+ : Object.freeze(array(source.connections, "People connections response.connections", maximum)
2656
+ .map((entry, index) => {
2657
+ const path = `People connections response.connections[${index}]`;
2658
+ const connection = record(entry, path);
2659
+ exactKeys(connection, ["resourceName"], [], path);
2660
+ return peopleResourceName(connection.resourceName, `${path}.resourceName`);
2661
+ }));
2662
+ if (new Set(resourceNames).size !== resourceNames.length) {
2663
+ return fail("People connections response.connections", "contains duplicate resource names");
2664
+ }
2665
+ return Object.freeze({
2666
+ resourceNames,
2667
+ nextPageToken: optionalPageToken(
2668
+ source.nextPageToken,
2669
+ "People connections response.nextPageToken",
2670
+ ),
2671
+ totalItems: optionalInteger(source.totalItems, "People connections response.totalItems"),
2672
+ });
2673
+ }
2674
+
2675
+ function parsePeopleBatch(
2676
+ value: unknown,
2677
+ requestedResourceNames: readonly string[],
2678
+ projection: GmailContactProjection,
2679
+ ): readonly GmailContact[] {
2680
+ const source = record(value, "People batch response");
2681
+ exactKeys(source, ["responses"], [], "People batch response");
2682
+ const requested = new Set(requestedResourceNames);
2683
+ const byRequestedName = new Map<string, GmailContact>();
2684
+ for (const [index, entry] of array(source.responses, "People batch response.responses", 200).entries()) {
2685
+ const path = `People batch response.responses[${index}]`;
2686
+ const response = record(entry, path);
2687
+ exactKeys(response, ["requestedResourceName"], [
2688
+ "httpStatusCode",
2689
+ "status",
2690
+ "person",
2691
+ ], path);
2692
+ const requestedResourceName = peopleResourceName(
2693
+ response.requestedResourceName,
2694
+ `${path}.requestedResourceName`,
2695
+ );
2696
+ if (!requested.has(requestedResourceName)) {
2697
+ return fail(`${path}.requestedResourceName`, "was not requested");
2698
+ }
2699
+ if (byRequestedName.has(requestedResourceName)) {
2700
+ return fail(`${path}.requestedResourceName`, "is duplicated");
2701
+ }
2702
+ let hasStatus = false;
2703
+ if (response.httpStatusCode !== undefined) {
2704
+ hasStatus = true;
2705
+ if (integer(response.httpStatusCode, `${path}.httpStatusCode`, 100, 599) !== 200) {
2706
+ return fail(`${path}.httpStatusCode`, "does not report a successful person response");
2707
+ }
2708
+ }
2709
+ if (response.status !== undefined) {
2710
+ hasStatus = true;
2711
+ const status = record(response.status, `${path}.status`);
2712
+ exactKeys(status, [], ["code"], `${path}.status`);
2713
+ const code = status.code === undefined
2714
+ ? 0
2715
+ : integer(status.code, `${path}.status.code`, 0, Number.MAX_SAFE_INTEGER);
2716
+ if (code !== 0) return fail(`${path}.status.code`, "does not report success");
2717
+ }
2718
+ if (!hasStatus) return fail(path, "must contain an exact per-person status");
2719
+ if (response.person === undefined) return fail(`${path}.person`, "is required for a successful response");
2720
+ const contact = parseContact(response.person, `${path}.person`, projection);
2721
+ if (!contact.resourceName.startsWith("people/")) {
2722
+ return fail(`${path}.person.resourceName`, "must identify a saved contact");
2723
+ }
2724
+ byRequestedName.set(requestedResourceName, contact);
2725
+ }
2726
+ for (const resourceName of requestedResourceNames) {
2727
+ if (!byRequestedName.has(resourceName)) {
2728
+ return fail("People batch response.responses", "is missing a requested resource name");
2729
+ }
2730
+ }
2731
+ const contacts = requestedResourceNames.map((resourceName) => {
2732
+ const contact = byRequestedName.get(resourceName);
2733
+ if (contact === undefined) throw new Error("People batch response lost a validated contact");
2734
+ return contact;
2735
+ });
2736
+ const returnedNames = contacts.map((contact) => contact.resourceName);
2737
+ if (new Set(returnedNames).size !== returnedNames.length) {
2738
+ return fail("People batch response.responses", "resolves multiple requests to one contact identity");
2739
+ }
2740
+ return Object.freeze(contacts);
2741
+ }
2742
+
2743
+ export async function fetchGmailContacts(
2744
+ client: GmailApiClient,
2745
+ input: Readonly<{
2746
+ collection: GmailContactCollection;
2747
+ projection?: GmailContactProjection;
2748
+ limit: number;
2749
+ pageToken: string | null;
2750
+ }>,
2751
+ ): Promise<GmailContactPage> {
2752
+ const limit = integer(input.limit, "People connections limit", 1, 200);
2753
+ const projection = input.projection ?? "core";
2754
+ if (projection !== "core" && projection !== "dates") {
2755
+ return fail("People contact projection", "must be core or dates");
2756
+ }
2757
+ if (input.collection === "other-contacts") {
2758
+ if (projection !== "core") {
2759
+ return fail("People Other contacts projection", "does not support saved-contact dates");
2760
+ }
2761
+ const otherUrl = peopleUrl("/v1/otherContacts");
2762
+ otherUrl.searchParams.set("pageSize", String(limit));
2763
+ otherUrl.searchParams.set(
2764
+ "readMask",
2765
+ "metadata,names,emailAddresses,phoneNumbers,photos",
2766
+ );
2767
+ otherUrl.searchParams.set("sources", "READ_SOURCE_TYPE_CONTACT");
2768
+ otherUrl.searchParams.set("fields", PEOPLE_OTHER_LIST_FIELDS);
2769
+ if (input.pageToken !== null) {
2770
+ otherUrl.searchParams.set(
2771
+ "pageToken",
2772
+ pageToken(input.pageToken, "People Other contacts page token"),
2773
+ );
2774
+ }
2775
+ return parseOtherContactsPage(await peopleGet(client, otherUrl), limit);
2776
+ }
2777
+ if (input.collection !== "contacts") {
2778
+ return fail("People contact collection", "must be contacts or other-contacts");
2779
+ }
2780
+ const url = peopleUrl("/v1/people/me/connections");
2781
+ url.searchParams.set("pageSize", String(limit));
2782
+ // personFields is mandatory even though the exact partial-response mask keeps
2783
+ // this discovery call to stable resource names only.
2784
+ url.searchParams.set("personFields", "metadata");
2785
+ url.searchParams.set("sources", "READ_SOURCE_TYPE_CONTACT");
2786
+ url.searchParams.set("sortOrder", "LAST_MODIFIED_DESCENDING");
2787
+ url.searchParams.set("fields", PEOPLE_CONNECTION_LIST_FIELDS);
2788
+ if (input.pageToken !== null) {
2789
+ url.searchParams.set("pageToken", pageToken(input.pageToken, "People connections page token"));
2790
+ }
2791
+ const page = parseConnectionIndexPage(await peopleGet(client, url), limit);
2792
+ if (page.resourceNames.length === 0) {
2793
+ return Object.freeze({
2794
+ contacts: Object.freeze([]),
2795
+ nextPageToken: page.nextPageToken,
2796
+ totalItems: page.totalItems,
2797
+ });
2798
+ }
2799
+ const batchUrl = peopleUrl("/v1/people:batchGet");
2800
+ for (const resourceName of page.resourceNames) {
2801
+ batchUrl.searchParams.append("resourceNames", resourceName);
2802
+ }
2803
+ batchUrl.searchParams.set(
2804
+ "personFields",
2805
+ projection === "dates" ? PEOPLE_DATES_PERSON_FIELDS : PEOPLE_CORE_PERSON_FIELDS,
2806
+ );
2807
+ batchUrl.searchParams.set("sources", "READ_SOURCE_TYPE_CONTACT");
2808
+ const personProjection = projection === "dates"
2809
+ ? PEOPLE_DATES_PERSON_PROJECTION
2810
+ : PEOPLE_CORE_PERSON_PROJECTION;
2811
+ batchUrl.searchParams.set(
2812
+ "fields",
2813
+ `responses(requestedResourceName,httpStatusCode,status(code),person(${personProjection}))`,
2814
+ );
2815
+ return Object.freeze({
2816
+ contacts: parsePeopleBatch(
2817
+ await peopleGet(client, batchUrl),
2818
+ page.resourceNames,
2819
+ projection,
2820
+ ),
2821
+ nextPageToken: page.nextPageToken,
2822
+ totalItems: page.totalItems,
2823
+ });
2824
+ }
2825
+
2826
+ /** Extract and canonicalize ordinary address-spec values from one RFC header. */
2827
+ export function extractGmailEmailAddresses(value: string | null): readonly string[] {
2828
+ if (value === null) return Object.freeze([]);
2829
+ const header = text(value, "address header", MAX_HEADER_BYTES, true, true);
2830
+ const matches = header.match(/[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+/gu) ?? [];
2831
+ return Object.freeze([...new Set(matches
2832
+ .filter((candidate) => isGmailAccountSubject(candidate))
2833
+ .map((candidate) => candidate.toLowerCase()))]);
2834
+ }