Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← Public packages

@noah/hey-email

HEY.com email integration with Kody: search threads, read messages/attachments, create drafts, and send with explicit confirmation.

src/attachments.js

162 lines · 4.9 KB · JavaScript
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
}

/**
 * List PDF attachments on a HEY message and download their bytes.
 *
 * @param {{
 *   messageId?: number|string,
 *   entryId?: number|string,
 *   content?: string,
 *   message?: { content?: string },
 * }} input
 * @returns {Promise<Array<{ filename: string, contentType: string, bytes: Uint8Array, url: string, filesize?: number }>>}
 *
 * @example
 * import listAndDownloadPdfs from 'kody:@noah/hey-email/attachments'
 * const pdfs = await listAndDownloadPdfs({ messageId: 123 })
 */
export default async function listAndDownloadPdfs(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 || ''
  }

  const attachments = parseTrixAttachments(content)
  const pdfs = attachments.filter((a) => {
    const ct = String(a.contentType || a.content_type || '').toLowerCase()
    const name = String(a.filename || a.fileName || a.url || '').toLowerCase()
    return ct.includes('pdf') || name.endsWith('.pdf') || /\.pdf(\?|$)/i.test(String(a.url || ''))
  })

  const results = []
  for (const a of pdfs) {
    const path = String(a.url || '')
    const url = path.startsWith('http') ? path : `${BASE}${path.startsWith('/') ? '' : '/'}${path}`
    const bytes = await heyFetchBinary(path.startsWith('http') ? url : path)
    results.push({
      filename: a.filename || a.fileName || 'attachment.pdf',
      contentType: a.contentType || a.content_type || 'application/pdf',
      bytes,
      url,
      filesize: a.filesize ?? a.fileSize ?? bytes.byteLength,
    })
  }
  return results
}

export { getMessage }