body.ts
Language TypeScript10.2 KB260 lines
/*** Message bodies: fetch one, and turn Gmail's MIME tree into readable text.** Fetched on demand, not stored. The list in KV stays metadata-only and a body* is pulled from Gmail when someone opens that message. Three reasons, in order* of how badly each would bite:** A KV value caps at 25 MiB and the whole value is read on every /inbox* request. Seven mailboxes × 100 messages with bodies would blow past the cap* and make the list page slow for everyone to serve one person's read.** Bodies at rest in the board's own storage is a much larger thing to hold* than a subject line. On demand means the board never accumulates seven* people's mail.** A body read now is the body as it is now.** HTML is carried, but only the frame route may render it. A sender's markup* is an XSS surface handed to anyone who can email the company, so the HTML is* never injected into a board page. It is served* by `/inbox/message/frame` as its own document with its own CSP (no scripts,* no forms) and displayed through a sandboxed iframe without `allow-scripts`.* Anything that puts `html` into a page the board's scripts run on* reintroduces exactly that surface. The text conversion* stays as the fallback for plain messages and failed frames.*//** Gmail's URL-safe base64, which atob() does not accept as-is. */export function decodeB64Url(data: string): string {const b64 = data.replace(/-/g, '+').replace(/_/g, '/')const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4)const bin = atob(padded)// The payload is UTF-8; atob gives latin-1 bytes, so re-decode or accented// characters and emoji arrive as mojibake.return new TextDecoder('utf-8').decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)))}/*** The same base64url, decoded to bytes rather than text.** Separate from decodeB64Url because an attachment is not text: running a PDF* through TextDecoder('utf-8') replaces every invalid sequence with U+FFFD and* hands back a file that opens as garbage.*/export function decodeB64UrlBytes(data: string): Uint8Array {const b64 = data.replace(/-/g, '+').replace(/_/g, '/')const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4)const bin = atob(padded)const out = new Uint8Array(bin.length)for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)return out}/** Tags whose content is not readable text and must go with them. */const DROP_BLOCKS = /<(script|style|head|noscript)\b[^>]*>[\s\S]*?<\/\1>/gi/*** HTML to text. Same shape as stripHtml in the Ninety client, plus the two* things email needs that a to-do description does not.*/export function htmlToText(html: string): string {return html// Drop script/style CONTENT, not just their tags. Stripping tags alone// leaves a wall of CSS in the middle of the message..replace(DROP_BLOCKS, ' ').replace(/<\s*(br|\/li|\/p|\/div|\/tr|\/h[1-6])\s*\/?>/gi, '\n').replace(/<\s*li[^>]*>/gi, '- ').replace(/<[^>]+>/g, '').replace(/ | | /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>').replace(/"/gi, '"').replace(/'|'/gi, "'")// Marketing HTML is padded with zero-width and soft-hyphen filler to game// preview text: the "͏ ͏ ͏" in every snippet. Left in, a message reads as// hundreds of blank lines..replace(/[-]/g, '').replace(/[ \t]+/g, ' ').replace(/\n[ \t]+/g, '\n').replace(/\n{3,}/g, '\n\n').trim()}interface Part {mimeType?: stringfilename?: stringbody?: { data?: string; size?: number; attachmentId?: string }parts?: Part[]headers?: { name: string; value: string }[]}/*** One attachment on an incoming message.** More than a name, because a download route needs three things a name cannot* carry: Gmail's `attachmentId` to fetch it, the size to decide whether it is* inside the cap before fetching anything, and the mime type.** `size` and `attachmentId` obey the prime invariant. Gmail reports a size for* every part it has one for; when it reports none the field is `null` and the* page says "size not reported", never 0, since a 0 would read as an empty* file and would pass a size check it was never measured against.*/export interface Attachment {/*** Position in the MIME walk, from 0: the download route's address.** The route takes this and not `attachmentId`: Gmail's ids are hundreds of* characters and change between reads of the same message, so a URL built* from one is long and stale. The index is stable for a given message* because the walk order is, and the route re-reads the message and re-walks* it before fetching anything.*/index: number/** The filename as the sender spelled it. Never trusted as a path. */name: stringmimeType: string/** Bytes, from Gmail's `body.size`. Null when Gmail reported none. */size: number | null/** Gmail's handle for the bytes. Null when the part carries none. */attachmentId: string | null/*** An inline part: a `cid:` image the HTML references, not a file anyone* attached. Signature logos arrive as several of these, and counting them as* attachments on the message page would be true and useless.*/inline: boolean}/*** Every attachment part, in walk order.** Inline is detected, not guessed: `Content-Disposition: inline` or the* presence of a `Content-ID` is what a mail client uses, and both come free at* format=full. A part with a filename and neither is a real attachment.*/export function collectAttachments(payload: Part | undefined): Attachment[] {const out: Attachment[] = []const walk = (p: Part): void => {if (p.filename) {const header = (n: string): string =>p.headers?.find((h) => h.name.toLowerCase() === n)?.value ?? ''out.push({index: out.length,name: p.filename,mimeType: (p.mimeType ?? 'application/octet-stream').toLowerCase(),// `?? null`, not `?? 0`: see the interface. Gmail sends 0 for an empty// part and omits the field when it has nothing to say; those are// different facts and the page renders them differently.size: typeof p.body?.size === 'number' ? p.body.size : null,attachmentId: p.body?.attachmentId ?? null,inline: /(^|;|\s)inline(;|$|\s)/i.test(header('content-disposition')) || Boolean(header('content-id')),})}for (const child of p.parts ?? []) walk(child)}if (payload) walk(payload)return out}/*** Walk the MIME tree for the best readable body.** Prefers text/plain, falls back to text/html converted to text. Skips anything* with a filename; that is an attachment, not the message.*/export function extractBody(payload: Part | undefined,): { text: string; kind: 'plain' | 'html' | 'none'; html: string } {if (!payload) return { text: '', kind: 'none', html: '' }let plain = ''let html = ''const walk = (p: Part): void => {if (p.filename) return // attachmentconst type = (p.mimeType ?? '').toLowerCase()const data = p.body?.dataif (data) {if (type === 'text/plain' && !plain) plain = decodeB64Url(data)else if (type === 'text/html' && !html) html = decodeB64Url(data)}for (const child of p.parts ?? []) walk(child)}walk(payload)// The raw HTML rides along even when text/plain wins the text field: the// frame route renders the HTML part, the text is the fallback and the search// haystack. See the header for why the HTML must only ever reach the frame.if (plain.trim()) return { text: plain.trim(), kind: 'plain', html: html.trim() }if (html.trim()) return { text: htmlToText(html), kind: 'html', html: html.trim() }return { text: '', kind: 'none', html: '' }}/** How much of a body to keep. Enough to read; not enough to be a mail client. */export const BODY_CAP = 20_000/*** The HTML cap is much larger because markup is mostly overhead: a marketing* email's 200KB is tables and inline CSS around two paragraphs, and cutting it* at text size would truncate mid-tag and render garbage.*/export const HTML_CAP = 400_000export interface MessageBody {id: stringmailbox: stringsender: stringsenderName: stringsubject: stringdate: stringto: stringtext: stringkind: 'plain' | 'html' | 'none'/** True when the body was cut. Said on the page, never silently truncated. */truncated: boolean/*** The sender's HTML, capped, or null when the message has no HTML part.* Only the /inbox/message/frame route may put this in a response; see the* file header. Board pages link to the frame; they never embed this string.*/html: string | nullhtmlTruncated: boolean/** Gmail's own UNREAD label; the open-message page offers mark-read from it. */unread: boolean/*** Every attachment part, with the size and handle a download needs. Empty* array means "we read the MIME tree and there are none": a real zero, not* an absence.*/attachments: Attachment[]/*** Gmail's thread id, meaningful only inside this mailbox.** This and the fields below are threading and reply-addressing. They are* read from the original message on the server and are the only source of* the reply's recipient, subject and thread; the compose form carries no To,* no Subject and no thread id. A form field a person can edit is a form field* an attacker can edit, and the one irreversible thing this board does must* not take its recipient from the request that triggers it.** Thread ids are per-mailbox, so passing `hello@`'s id on a send through* `accounts@`'s token is rejected by Gmail. See decideReply(): it is sent* only when replying from the mailbox the message arrived in.*/threadId: string/** The Message-ID header, angle brackets as sent. Null when absent (rare). */messageId: string | null/** The References header verbatim, or null. */references: string | null/** Reply-To verbatim, '' if absent. Beats From as the reply recipient. */replyTo: string/*** Cc verbatim, '' if absent.** Also the source of a reply-all's Cc list, derived on the server by* `decideReply` from this field and `to`, and never from the form.*/cc: string}