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(/"/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 tags (may be entity-escaped). * @param {string} html */ export function parseActionTextAttachments(html) { const text = unescapeHtml(String(html || '')) const out = [] const re = /]*)>/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>} * * @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 }