← 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 · JavaScriptimport { 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(/"/g, '"')
.replace(/&/g, '&')
.replace(/'/g, "'")
.replace(/</g, '<')
.replace(/>/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(/"/g, '"')
.replace(/&/g, '&')
.replace(/'/g, "'")
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\\"/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 }