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/attachments.js

321 lines · 9.8 KB · JavaScript
Raw
import { BASE, heyFetchBinary } from './client.js'
import getMessage from './message.js'

/**
 * Parse trix figure attachment JSON from HEY message HTML content.
 * Decodes `"` → `"` then JSON.parse.
 *
 * @param {string} html
 * @returns {Array<{ url: string, filename?: string, contentType?: string, filesize?: number }>}
 */
export function parseTrixAttachments(html) {
  const text = String(html || '')
  const out = []
  const re = /data-trix-attachment="([^"]+)"/gi
  let m
  while ((m = re.exec(text))) {
    let raw = m[1]
    raw = raw
      .replace(/&quot;/g, '"')
      .replace(/&amp;/g, '&')
      .replace(/&#39;/g, "'")
      .replace(/&lt;/g, '<')
      .replace(/&gt;/g, '>')
    try {
      const obj = JSON.parse(raw)
      if (obj && typeof obj === 'object' && obj.url) out.push(obj)
      // Nested HTML (Store emails wrap the PDF inside a text/html trix blob)
      if (obj?.content && typeof obj.content === 'string') {
        out.push(...parseActionTextAttachments(obj.content))
        out.push(...parseRailsPdfUrls(obj.content))
      }
    } catch {
      // skip malformed
    }
  }
  // Also scan the outer HTML for action-text-attachment / rails PDF urls
  out.push(...parseActionTextAttachments(text))
  out.push(...parseRailsPdfUrls(text))
  return dedupeByUrl(out)
}

/**
 * Parse <action-text-attachment ...> tags (may be entity-escaped).
 * @param {string} html
 */
export function parseActionTextAttachments(html) {
  const text = unescapeHtml(String(html || ''))
  const out = []
  const re = /<action-text-attachment\b([^>]*)>/gi
  let m
  while ((m = re.exec(text))) {
    const attrs = m[1]
    const url = attr(attrs, 'url')
    if (!url) continue
    out.push({
      url,
      filename: attr(attrs, 'filename') || undefined,
      contentType: attr(attrs, 'content-type') || attr(attrs, 'contentType') || undefined,
      filesize: Number(attr(attrs, 'filesize') || attr(attrs, 'fileSize') || 0) || undefined,
    })
  }
  return out
}

/**
 * Fallback: any ActiveStorage redirect URL ending in .pdf
 * @param {string} html
 */
export function parseRailsPdfUrls(html) {
  const text = unescapeHtml(String(html || ''))
  const out = []
  const re = /(\/rails\/active_storage\/blobs\/redirect\/[^\s"'<>]+\.pdf)/gi
  let m
  while ((m = re.exec(text))) {
    const url = m[1]
    const filename = url.split('/').pop() || 'attachment.pdf'
    out.push({ url, filename, contentType: 'application/pdf' })
  }
  return out
}

/** @param {string} s */
function unescapeHtml(s) {
  return s
    .replace(/&quot;/g, '"')
    .replace(/&amp;/g, '&')
    .replace(/&#39;/g, "'")
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/\\&quot;/g, '"')
    .replace(/\\"/g, '"')
}

/** @param {string} attrs @param {string} name */
function attr(attrs, name) {
  const re = new RegExp(`${name}\\s*=\\s*["']([^"']+)["']`, 'i')
  const m = attrs.match(re)
  return m?.[1]
}

/** @param {Array<{url:string}>} items */
function dedupeByUrl(items) {
  const seen = new Set()
  const out = []
  for (const a of items) {
    const key = String(a.url || '')
    if (!key || seen.has(key)) continue
    seen.add(key)
    out.push(a)
  }
  return out
}

const MAX_RAW_BYTES = 24 * 1024 * 1024

async function resolveContent(input = {}) {
  const messageId = input.messageId ?? input.entryId
  let content = input.content
  if (!content && input.message?.content) content = input.message.content
  if (!content) {
    if (messageId == null) throw new Error('messageId or content is required')
    const message = await getMessage({ messageId })
    content = message?.content || message?.body || ''
  }
  return content
}

function isPdf(attachment) {
  const ct = String(attachment.contentType || attachment.content_type || '').toLowerCase()
  const name = String(attachment.filename || attachment.fileName || attachment.url || '').toLowerCase()
  return ct.includes('pdf') || name.endsWith('.pdf') || /\.pdf(\?|$)/i.test(String(attachment.url || ''))
}

function absoluteUrl(path) {
  const raw = String(path || '')
  return raw.startsWith('http') ? raw : `${BASE}${raw.startsWith('/') ? '' : '/'}${raw}`
}

function listedPdfs(content) {
  return parseTrixAttachments(content).filter(isPdf).map((attachment, index) => {
    const path = String(attachment.url || '')
    return {
      index,
      filename: attachment.filename || attachment.fileName || 'attachment.pdf',
      contentType: attachment.contentType || attachment.content_type || 'application/pdf',
      url: absoluteUrl(path),
      filesize: attachment.filesize ?? attachment.fileSize,
      path,
    }
  })
}

function publicPdf(item) {
  return {
    index: item.index,
    filename: item.filename,
    contentType: item.contentType,
    url: item.url,
    filesize: item.filesize,
  }
}

function encodedSizeOf(rawSize) {
  if (!Number.isFinite(rawSize) || rawSize <= 0) return 0
  return 4 * Math.ceil(rawSize / 3)
}

function downloadEnvelope(item, filesize, encodedSize) {
  return {
    filename: item.filename,
    contentType: item.contentType,
    url: item.url,
    filesize,
    base64: '',
  }
}

function envelopeSizeOf(item, filesize, encodedSize) {
  return JSON.stringify(downloadEnvelope(item, filesize, encodedSize)).length + encodedSize
}

function rawCapError(filename, rawSize) {
  return new Error(
    `PDF ${filename} raw size is ${rawSize} bytes, over the 24 MiB cap (${MAX_RAW_BYTES} bytes)`,
  )
}

function budgetError(filename, envelopeSize, maxEncodedBytes) {
  return new Error(
    `PDF ${filename} encoded size is ${envelopeSize} bytes, over the maxEncodedBytes budget of ${maxEncodedBytes}. Raise responseLimit to at least ${envelopeSize}.`,
  )
}

function refuseSize(item, rawSize, maxEncodedBytes) {
  if (rawSize > MAX_RAW_BYTES) throw rawCapError(item.filename, rawSize)
  const encodedSize = encodedSizeOf(rawSize)
  const envelopeSize = envelopeSizeOf(item, rawSize, encodedSize)
  if (envelopeSize > maxEncodedBytes) throw budgetError(item.filename, envelopeSize, maxEncodedBytes)
}

function bytesToBase64(bytes) {
  const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
  let binary = ''
  const chunkSize = 0x8000
  for (let i = 0; i < u8.length; i += chunkSize) {
    binary += String.fromCharCode(...u8.subarray(i, i + chunkSize))
  }
  return btoa(binary)
}

function hasFilenameSelector(input) {
  return input.filename != null && String(input.filename) !== ''
}

function requireSelector(input) {
  const hasFilename = hasFilenameSelector(input)
  const hasIndex = input.index != null
  if (!hasFilename && !hasIndex) throw new Error('filename or index is required')
  if (!hasFilename && (!Number.isInteger(input.index) || input.index < 0)) {
    throw new Error('index must be a non-negative integer')
  }
}

function pickPdf(pdfs, input) {
  if (hasFilenameSelector(input)) {
    const filename = String(input.filename)
    const matches = pdfs.filter((pdf) => pdf.filename === filename)
    if (matches.length === 0) throw new Error(`no PDF named ${filename}`)
    if (matches.length > 1) {
      throw new Error(`multiple PDFs named ${filename}; pass index to choose one`)
    }
    return matches[0]
  }
  const index = input.index
  if (!Number.isInteger(index) || index < 0) {
    throw new Error('index must be a non-negative integer')
  }
  const match = pdfs[index]
  if (!match) throw new Error(`no PDF at index ${index}`)
  return match
}

/**
 * List PDF attachments on a HEY message. Metadata only: no bodies.
 *
 * @param {{
 *   messageId?: number|string,
 *   entryId?: number|string,
 *   content?: string,
 *   message?: { content?: string },
 * }} input
 * @returns {Promise<Array<{ index: number, filename: string, contentType: string, url: string, filesize?: number }>>}
 *
 * @example
 * import listPdfs from 'kody:@danj/hey-email/attachments'
 * const pdfs = await listPdfs({ messageId: 123 })
 */
export async function listPdfs(input = {}) {
  const content = await resolveContent(input)
  return listedPdfs(content).map(publicPdf)
}

/**
 * Download one PDF from a HEY message as base64. Pass filename or index from
 * listPdfs, and maxEncodedBytes as the execute responseLimit the caller set.
 * maxEncodedBytes is a cap on the whole returned value, not only the base64.
 *
 * @param {{
 *   messageId?: number|string,
 *   entryId?: number|string,
 *   content?: string,
 *   message?: { content?: string },
 *   filename?: string,
 *   index?: number,
 *   maxEncodedBytes: number,
 * }} input
 * @returns {Promise<{ filename: string, contentType: string, url: string, filesize: number, base64: string }>}
 *
 * @example
 * import { downloadPdf } from 'kody:@danj/hey-email/attachments'
 * const pdf = await downloadPdf({ messageId: 123, filename: 'receipt.pdf', maxEncodedBytes: 102400 })
 */
export async function downloadPdf(input = {}) {
  const { maxEncodedBytes } = input
  if (typeof maxEncodedBytes !== 'number' || !Number.isFinite(maxEncodedBytes) || maxEncodedBytes < 0) {
    throw new Error('maxEncodedBytes is required')
  }
  requireSelector(input)

  const content = await resolveContent(input)
  const item = pickPdf(listedPdfs(content), input)
  if (Number.isFinite(item.filesize)) refuseSize(item, item.filesize, maxEncodedBytes)

  const fetchPath = String(item.path || '').startsWith('http') ? item.url : item.path
  let bytes
  try {
    bytes = await heyFetchBinary(fetchPath, { maxBytes: MAX_RAW_BYTES })
  } catch (error) {
    if (error?.code === 'HEY_BINARY_TOO_LARGE') {
      throw rawCapError(item.filename, error.rawSize)
    }
    throw error
  }
  const rawSize = bytes.byteLength
  refuseSize(item, rawSize, maxEncodedBytes)

  const base64 = bytesToBase64(bytes)
  const result = {
    filename: item.filename,
    contentType: item.contentType,
    url: item.url,
    filesize: rawSize,
    base64,
  }
  const envelopeSize = JSON.stringify(result).length
  if (envelopeSize > maxEncodedBytes) throw budgetError(item.filename, envelopeSize, maxEncodedBytes)
  return result
}

export default listPdfs
export { getMessage }