Skip to content
← 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 · JavaScript
Raw
import { 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 }