← 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 · 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
}
/**
* 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 }