← Public packages
@danj/hey-email
HEY.com email for Kody: search threads, read messages, download PDF attachments, and write drafts. Sending is barred until you allow it.
src/client.js
461 lines · 14.5 KB · JavaScriptimport { kody } from 'kody:runtime'
const BASE = 'https://app.hey.com'
const TOKEN_URL = 'https://app.hey.com/oauth/tokens'
const MAX_REDIRECTS = 5
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308])
// Fetch request-body-header names, plus Content-Length which a rewritten
// GET must not replay from the original init.
const REQUEST_BODY_HEADERS = [
'Content-Encoding',
'Content-Language',
'Content-Location',
'Content-Type',
'Content-Length',
]
// Public OAuth client id of the official HEY CLI (PKCE public client, no
// client secret). It identifies the app, not the user; every user brings
// their own tokens through the heyToken / heyRefreshToken user secrets.
const CLIENT_ID = 'khMWSVDVSq78oyKA3KtxmYRv'
/**
* Resolve a request path against app.hey.com and refuse any other origin.
* Attachment URLs come from untrusted email HTML, so the first credentialed
* request must stay on app.hey.com. Redirect hops are checked separately:
* Workers forwards Authorization when redirect mode is "follow".
*
* @param {string} path
* @returns {string}
*/
function resolveHeyUrl(path) {
const raw = String(path || '')
const url = raw.startsWith('http')
? new URL(raw)
: new URL(`${BASE}${raw.startsWith('/') ? '' : '/'}${raw}`)
if (url.origin !== BASE) {
throw new Error(`Refusing to send HEY credentials to ${url.origin}`)
}
url.username = ''
url.password = ''
return url.toString()
}
/**
* Decide where a 3xx Location may go.
* Same-origin hops keep the bearer. Cross-origin hops are either refused
* (JSON and token refresh) or followed with no Authorization (PDF storage).
*
* @param {string} currentUrl
* @param {string} location
* @param {{ allowCrossOrigin?: boolean }} [opts]
* @returns {{ url: string, authenticated: boolean }}
*/
function resolveRedirect(currentUrl, location, opts = {}) {
let next
try {
next = new URL(location, currentUrl)
} catch {
throw new Error('HEY redirect had an invalid Location')
}
next.username = ''
next.password = ''
next.hash = ''
if (next.protocol !== 'https:') {
throw new Error(`Refusing to follow HEY redirect to ${next.origin}`)
}
if (next.origin === BASE) {
return { url: next.toString(), authenticated: true }
}
if (!opts.allowCrossOrigin) {
throw new Error(`Refusing to follow HEY redirect to ${next.origin}`)
}
return { url: next.toString(), authenticated: false }
}
function stripRequestBodyHeaders(headers) {
for (const name of REQUEST_BODY_HEADERS) headers.delete(name)
}
/**
* Fetch redirect method/body rules for same-origin hops:
* 301/302 rewrite POST to GET; 303 rewrites every method except GET/HEAD.
* PUT and HEAD keep their method on 301/302. 307/308 always replay.
* https://fetch.spec.whatwg.org/#http-redirect-fetch
*
* @param {number} status
* @param {string} method
*/
function shouldRewriteToGet(status, method) {
if (status === 301 || status === 302) return method === 'POST'
if (status === 303) return method !== 'GET' && method !== 'HEAD'
return false
}
function httpError(message, status, extra = {}) {
const err = new Error(message)
err.status = status
for (const [key, value] of Object.entries(extra)) err[key] = value
return err
}
function tokenPersistSecrets() {
return [
{ name: 'heyToken', scope: 'user' },
{ name: 'heyRefreshToken', scope: 'user' },
]
}
const PERSIST_ATTEMPTS = 2
function requireSecretSetMany() {
if (typeof kody.secretSetMany === 'function') return
throw httpError(
'HEY token refresh requires kody.secretSetMany to assert and persist heyToken / heyRefreshToken atomically. This Kody runtime does not expose it.',
undefined,
{ code: 'HEY_TOKEN_PERSIST_UNSUPPORTED' }
)
}
/**
* Fail before HEY consumes the refresh grant when this package cannot write
* the rotated secrets. secretSetMany({ assertOnly: true }) is the supported
* authorization-only check; user-secret secretSet rejects a missing value.
*/
async function assertCanPersistRotatedTokens() {
requireSecretSetMany()
try {
await kody.secretSetMany({
assertOnly: true,
secrets: tokenPersistSecrets(),
})
} catch (err) {
if (err?.code === 'HEY_TOKEN_PERSIST_UNSUPPORTED') throw err
throw httpError(
'HEY token refresh aborted: this package cannot write heyToken / heyRefreshToken. Ask the owner to approve write access on the link kody.secretLock returns. The refresh grant was not consumed.',
err?.status,
{ cause: err }
)
}
}
function rotationUnsavedError(cause) {
return httpError(
'HEY token refresh succeeded at the token endpoint but could not persist the rotated heyToken / heyRefreshToken. The refresh grant was already consumed. Re-authenticate HEY and update both user secrets; retrying with the saved refresh token will fail.',
cause?.status,
{ cause, code: 'HEY_TOKEN_ROTATION_UNSAVED' }
)
}
/**
* Persist the token pair HEY already returned. Retry the same batch without
* another OAuth exchange so a transient secretSetMany failure does not spend
* a second refresh grant. If saving still fails, the in-memory pair is lost
* and the saved grant is already consumed.
*
* @param {{ access_token: string, refresh_token?: string }} tokens
*/
async function persistRotatedTokens(tokens) {
requireSecretSetMany()
const accessSecret = {
name: 'heyToken',
scope: 'user',
value: tokens.access_token,
}
const refreshSecret = tokens.refresh_token
? {
name: 'heyRefreshToken',
scope: 'user',
value: tokens.refresh_token,
description: 'HEY OAuth refresh token (auto-rotated on 401)',
}
: null
const secrets = refreshSecret ? [refreshSecret, accessSecret] : [accessSecret]
let lastErr
for (let attempt = 0; attempt < PERSIST_ATTEMPTS; attempt++) {
try {
await kody.secretSetMany({ secrets })
return
} catch (err) {
lastErr = err
}
}
throw rotationUnsavedError(lastErr)
}
/**
* Exchange the heyRefreshToken user secret for a fresh access token and
* persist it back to the heyToken user secret (rotating the refresh token
* too when HEY returns a new one). Called automatically on HEY 401.
*
* HEY's token endpoint requires the CLI's install_id alongside the refresh
* grant; it lives in the heyInstallId user secret (read-only for the package).
* Writing the rotated tokens back needs the package's allowed_packages grant
* on heyToken / heyRefreshToken; the owner approves that once on the link
* kody.secretLock returns.
*
* @returns {Promise<string>} the new access token
*/
async function refreshAccessToken() {
await assertCanPersistRotatedTokens()
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: '{{secret:heyRefreshToken}}',
client_id: CLIENT_ID,
install_id: '{{secret:heyInstallId}}',
})
const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
'User-Agent': 'kody-hey-email',
},
body: body.toString(),
redirect: 'manual',
})
if (res.status >= 300 && res.status < 400) {
throw new Error(
`HEY token refresh refused to follow a redirect (HTTP ${res.status}).`
)
}
if (!res.ok) {
throw httpError(
`HEY token refresh failed (HTTP ${res.status}). The saved HEY refresh token is invalid or expired; re-authenticate HEY and update the heyToken / heyRefreshToken user secrets.`,
res.status
)
}
const tokens = await res.json()
if (!tokens || !tokens.access_token) {
throw new Error('HEY token refresh returned no access_token')
}
await persistRotatedTokens(tokens)
return tokens.access_token
}
function shouldRefreshHeyToken(err) {
return Boolean(err && err.status === 401 && err.authenticated === true)
}
/**
* Run fn once; on a credentialed HEY 401, refresh the access token and run
* it exactly once more. A storage 401 (unauthenticated hop) does not rotate
* HEY tokens. A second 401 propagates instead of looping.
*
* @param {() => Promise<any>} fn
*/
async function withAutoRefresh(fn) {
try {
return await fn()
} catch (err) {
if (shouldRefreshHeyToken(err)) {
await refreshAccessToken()
return await fn()
}
throw err
}
}
/**
* Fetch with redirect: "manual" so the bearer cannot ride a cross-origin hop.
* Workers forwards Authorization, Cookie, and the body when mode is "follow".
* Same-origin hops follow the Fetch method/body rules. PDF downloads may
* continue off app.hey.com, but only as an unauthenticated GET.
*
* @param {string} url
* @param {RequestInit} init
* @param {{ binary?: boolean }} [opts]
* @returns {Promise<{ response: Response, authenticated: boolean }>}
*/
async function fetchHey(url, init, opts = {}) {
let current = url
let authenticated = true
let method = String(init.method || 'GET').toUpperCase()
let body = init.body
const headers = new Headers(init.headers || {})
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
if (authenticated) {
if (!headers.has('Authorization')) {
headers.set('Authorization', 'Bearer {{secret:heyToken}}')
}
} else {
headers.delete('Authorization')
headers.delete('Cookie')
}
if (!headers.has('Accept')) {
headers.set('Accept', opts.binary ? '*/*' : 'application/json')
}
const response = await fetch(current, {
...init,
method,
body,
headers: new Headers(headers),
redirect: 'manual',
})
if (!REDIRECT_STATUSES.has(response.status)) {
return { response, authenticated }
}
if (hop === MAX_REDIRECTS) throw new Error('Too many HEY redirects')
const loc = response.headers.get('location')
if (!loc) {
throw new Error(`HEY redirect (HTTP ${response.status}) missing Location`)
}
try {
await response.body?.cancel()
} catch {
// ignore cancel failures
}
const next = resolveRedirect(current, loc, { allowCrossOrigin: Boolean(opts.binary) })
// Off-origin binary hops are always an anonymous GET, including 307/308.
// Same-origin hops keep Fetch method/body semantics (PUT/HEAD stay put
// on 301/302; only POST rewrites there; 303 rewrites to GET).
if (!next.authenticated) {
method = 'GET'
body = undefined
stripRequestBodyHeaders(headers)
headers.delete('Authorization')
headers.delete('Cookie')
} else if (shouldRewriteToGet(response.status, method)) {
method = 'GET'
body = undefined
stripRequestBodyHeaders(headers)
}
current = next.url
authenticated = next.authenticated
}
throw new Error('Too many HEY redirects')
}
async function doJsonRequest(url, path, init) {
const { response, authenticated } = await fetchHey(url, init)
const text = await response.text()
let data = text
try {
data = text ? JSON.parse(text) : null
} catch {
// keep text
}
if (!response.ok) {
throw httpError(`HEY ${response.status} ${path}`, response.status, {
authenticated,
body: typeof data === 'string' ? data.slice(0, 500) : data,
})
}
return { status: response.status, headers: response.headers, data }
}
/**
* Authenticated fetch to app.hey.com using the heyToken secret.
* On HEY 401 the access token is refreshed automatically via the
* heyRefreshToken secret and the request is retried once.
* @param {string} path
* @param {RequestInit} [init]
*/
export async function heyFetch(path, init = {}) {
const { data } = await heyRequest(path, init)
return data
}
/**
* Same as heyFetch but keeps status + response headers (Location on draft create).
* Auto-refreshes on HEY 401 like heyFetch.
* @param {string} path
* @param {RequestInit} [init]
* @returns {Promise<{ status: number, headers: Headers, data: any }>}
*/
export async function heyRequest(path, init = {}) {
const url = resolveHeyUrl(path)
return withAutoRefresh(() => doJsonRequest(url, path, init))
}
function binaryTooLarge(rawSize, maxBytes, path) {
return httpError(`HEY binary exceeded ${maxBytes} bytes ${path}`, undefined, {
code: 'HEY_BINARY_TOO_LARGE',
rawSize,
maxBytes,
})
}
async function readBodyCapped(response, maxBytes, path) {
const reader = response.body?.getReader()
if (!reader) {
const buf = await response.arrayBuffer()
if (buf.byteLength > maxBytes) throw binaryTooLarge(buf.byteLength, maxBytes, path)
return new Uint8Array(buf)
}
const chunks = []
let total = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
const next = total + value.byteLength
if (next > maxBytes) {
try {
await reader.cancel()
} catch {
// ignore cancel failures
}
throw binaryTooLarge(next, maxBytes, path)
}
chunks.push(value)
total = next
}
const out = new Uint8Array(total)
let offset = 0
for (const chunk of chunks) {
out.set(chunk, offset)
offset += chunk.byteLength
}
return out
}
async function doBinaryRequest(url, path, init, maxBytes) {
const { response, authenticated } = await fetchHey(url, init, { binary: true })
if (!response.ok) {
let body = null
try {
body = (await response.text()).slice(0, 500)
} catch {
body = null
}
throw httpError(`HEY binary ${response.status} ${path}`, response.status, {
authenticated,
body,
})
}
if (Number.isFinite(maxBytes) && maxBytes >= 0) {
const lengthHeader = response.headers.get('content-length')
if (lengthHeader != null && lengthHeader !== '') {
const contentLength = Number(lengthHeader)
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
try {
await response.body?.cancel()
} catch {
// ignore cancel failures
}
throw binaryTooLarge(contentLength, maxBytes, path)
}
}
return readBodyCapped(response, maxBytes, path)
}
const buf = await response.arrayBuffer()
return new Uint8Array(buf)
}
/**
* Authenticated binary fetch (PDF attachments).
* Cross-origin redirects are followed without the bearer token.
* Auto-refreshes on HEY 401 like heyRequest.
* Pass maxBytes to refuse before buffering a body over that size:
* Content-Length on the final response, then a stream cancel at maxBytes + 1.
* @param {string} path
* @param {RequestInit & { maxBytes?: number }} [init]
* @returns {Promise<Uint8Array>}
*/
export async function heyFetchBinary(path, init = {}) {
const { maxBytes, ...requestInit } = init
const url = resolveHeyUrl(path)
return withAutoRefresh(() => doBinaryRequest(url, path, requestInit, maxBytes))
}
export { BASE, resolveHeyUrl, resolveRedirect }