mattrguerra/meridian-ops-boardmainPrivate on GitHub, published here in full

body.ts

Language TypeScript10.2 KB260 lines

packages/gmail-client/src/body.ts

  1. /**
  2. * Message bodies: fetch one, and turn Gmail's MIME tree into readable text.
  3. *
  4. * Fetched on demand, not stored. The list in KV stays metadata-only and a body
  5. * is pulled from Gmail when someone opens that message. Three reasons, in order
  6. * of how badly each would bite:
  7. *
  8. * A KV value caps at 25 MiB and the whole value is read on every /inbox
  9. * request. Seven mailboxes × 100 messages with bodies would blow past the cap
  10. * and make the list page slow for everyone to serve one person's read.
  11. *
  12. * Bodies at rest in the board's own storage is a much larger thing to hold
  13. * than a subject line. On demand means the board never accumulates seven
  14. * people's mail.
  15. *
  16. * A body read now is the body as it is now.
  17. *
  18. * HTML is carried, but only the frame route may render it. A sender's markup
  19. * is an XSS surface handed to anyone who can email the company, so the HTML is
  20. * never injected into a board page. It is served
  21. * by `/inbox/message/frame` as its own document with its own CSP (no scripts,
  22. * no forms) and displayed through a sandboxed iframe without `allow-scripts`.
  23. * Anything that puts `html` into a page the board's scripts run on
  24. * reintroduces exactly that surface. The text conversion
  25. * stays as the fallback for plain messages and failed frames.
  26. */
  27. /** Gmail's URL-safe base64, which atob() does not accept as-is. */
  28. export function decodeB64Url(data: string): string {
  29. const b64 = data.replace(/-/g, '+').replace(/_/g, '/')
  30. const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4)
  31. const bin = atob(padded)
  32. // The payload is UTF-8; atob gives latin-1 bytes, so re-decode or accented
  33. // characters and emoji arrive as mojibake.
  34. return new TextDecoder('utf-8').decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)))
  35. }
  36. /**
  37. * The same base64url, decoded to bytes rather than text.
  38. *
  39. * Separate from decodeB64Url because an attachment is not text: running a PDF
  40. * through TextDecoder('utf-8') replaces every invalid sequence with U+FFFD and
  41. * hands back a file that opens as garbage.
  42. */
  43. export function decodeB64UrlBytes(data: string): Uint8Array {
  44. const b64 = data.replace(/-/g, '+').replace(/_/g, '/')
  45. const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4)
  46. const bin = atob(padded)
  47. const out = new Uint8Array(bin.length)
  48. for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
  49. return out
  50. }
  51. /** Tags whose content is not readable text and must go with them. */
  52. const DROP_BLOCKS = /<(script|style|head|noscript)\b[^>]*>[\s\S]*?<\/\1>/gi
  53. /**
  54. * HTML to text. Same shape as stripHtml in the Ninety client, plus the two
  55. * things email needs that a to-do description does not.
  56. */
  57. export function htmlToText(html: string): string {
  58. return html
  59. // Drop script/style CONTENT, not just their tags. Stripping tags alone
  60. // leaves a wall of CSS in the middle of the message.
  61. .replace(DROP_BLOCKS, ' ')
  62. .replace(/<\s*(br|\/li|\/p|\/div|\/tr|\/h[1-6])\s*\/?>/gi, '\n')
  63. .replace(/<\s*li[^>]*>/gi, '- ')
  64. .replace(/<[^>]+>/g, '')
  65. .replace(/&nbsp;|&#160;| /gi, ' ')
  66. .replace(/&amp;/gi, '&')
  67. .replace(/&lt;/gi, '<')
  68. .replace(/&gt;/gi, '>')
  69. .replace(/&quot;/gi, '"')
  70. .replace(/&#39;|&apos;/gi, "'")
  71. // Marketing HTML is padded with zero-width and soft-hyphen filler to game
  72. // preview text: the "͏ ͏ ͏" in every snippet. Left in, a message reads as
  73. // hundreds of blank lines.
  74. .replace(/[­​-‏⁠]/g, '')
  75. .replace(/[ \t]+/g, ' ')
  76. .replace(/\n[ \t]+/g, '\n')
  77. .replace(/\n{3,}/g, '\n\n')
  78. .trim()
  79. }
  80. interface Part {
  81. mimeType?: string
  82. filename?: string
  83. body?: { data?: string; size?: number; attachmentId?: string }
  84. parts?: Part[]
  85. headers?: { name: string; value: string }[]
  86. }
  87. /**
  88. * One attachment on an incoming message.
  89. *
  90. * More than a name, because a download route needs three things a name cannot
  91. * carry: Gmail's `attachmentId` to fetch it, the size to decide whether it is
  92. * inside the cap before fetching anything, and the mime type.
  93. *
  94. * `size` and `attachmentId` obey the prime invariant. Gmail reports a size for
  95. * every part it has one for; when it reports none the field is `null` and the
  96. * page says "size not reported", never 0, since a 0 would read as an empty
  97. * file and would pass a size check it was never measured against.
  98. */
  99. export interface Attachment {
  100. /**
  101. * Position in the MIME walk, from 0: the download route's address.
  102. *
  103. * The route takes this and not `attachmentId`: Gmail's ids are hundreds of
  104. * characters and change between reads of the same message, so a URL built
  105. * from one is long and stale. The index is stable for a given message
  106. * because the walk order is, and the route re-reads the message and re-walks
  107. * it before fetching anything.
  108. */
  109. index: number
  110. /** The filename as the sender spelled it. Never trusted as a path. */
  111. name: string
  112. mimeType: string
  113. /** Bytes, from Gmail's `body.size`. Null when Gmail reported none. */
  114. size: number | null
  115. /** Gmail's handle for the bytes. Null when the part carries none. */
  116. attachmentId: string | null
  117. /**
  118. * An inline part: a `cid:` image the HTML references, not a file anyone
  119. * attached. Signature logos arrive as several of these, and counting them as
  120. * attachments on the message page would be true and useless.
  121. */
  122. inline: boolean
  123. }
  124. /**
  125. * Every attachment part, in walk order.
  126. *
  127. * Inline is detected, not guessed: `Content-Disposition: inline` or the
  128. * presence of a `Content-ID` is what a mail client uses, and both come free at
  129. * format=full. A part with a filename and neither is a real attachment.
  130. */
  131. export function collectAttachments(payload: Part | undefined): Attachment[] {
  132. const out: Attachment[] = []
  133. const walk = (p: Part): void => {
  134. if (p.filename) {
  135. const header = (n: string): string =>
  136. p.headers?.find((h) => h.name.toLowerCase() === n)?.value ?? ''
  137. out.push({
  138. index: out.length,
  139. name: p.filename,
  140. mimeType: (p.mimeType ?? 'application/octet-stream').toLowerCase(),
  141. // `?? null`, not `?? 0`: see the interface. Gmail sends 0 for an empty
  142. // part and omits the field when it has nothing to say; those are
  143. // different facts and the page renders them differently.
  144. size: typeof p.body?.size === 'number' ? p.body.size : null,
  145. attachmentId: p.body?.attachmentId ?? null,
  146. inline: /(^|;|\s)inline(;|$|\s)/i.test(header('content-disposition')) || Boolean(header('content-id')),
  147. })
  148. }
  149. for (const child of p.parts ?? []) walk(child)
  150. }
  151. if (payload) walk(payload)
  152. return out
  153. }
  154. /**
  155. * Walk the MIME tree for the best readable body.
  156. *
  157. * Prefers text/plain, falls back to text/html converted to text. Skips anything
  158. * with a filename; that is an attachment, not the message.
  159. */
  160. export function extractBody(
  161. payload: Part | undefined,
  162. ): { text: string; kind: 'plain' | 'html' | 'none'; html: string } {
  163. if (!payload) return { text: '', kind: 'none', html: '' }
  164. let plain = ''
  165. let html = ''
  166. const walk = (p: Part): void => {
  167. if (p.filename) return // attachment
  168. const type = (p.mimeType ?? '').toLowerCase()
  169. const data = p.body?.data
  170. if (data) {
  171. if (type === 'text/plain' && !plain) plain = decodeB64Url(data)
  172. else if (type === 'text/html' && !html) html = decodeB64Url(data)
  173. }
  174. for (const child of p.parts ?? []) walk(child)
  175. }
  176. walk(payload)
  177. // The raw HTML rides along even when text/plain wins the text field: the
  178. // frame route renders the HTML part, the text is the fallback and the search
  179. // haystack. See the header for why the HTML must only ever reach the frame.
  180. if (plain.trim()) return { text: plain.trim(), kind: 'plain', html: html.trim() }
  181. if (html.trim()) return { text: htmlToText(html), kind: 'html', html: html.trim() }
  182. return { text: '', kind: 'none', html: '' }
  183. }
  184. /** How much of a body to keep. Enough to read; not enough to be a mail client. */
  185. export const BODY_CAP = 20_000
  186. /**
  187. * The HTML cap is much larger because markup is mostly overhead: a marketing
  188. * email's 200KB is tables and inline CSS around two paragraphs, and cutting it
  189. * at text size would truncate mid-tag and render garbage.
  190. */
  191. export const HTML_CAP = 400_000
  192. export interface MessageBody {
  193. id: string
  194. mailbox: string
  195. sender: string
  196. senderName: string
  197. subject: string
  198. date: string
  199. to: string
  200. text: string
  201. kind: 'plain' | 'html' | 'none'
  202. /** True when the body was cut. Said on the page, never silently truncated. */
  203. truncated: boolean
  204. /**
  205. * The sender's HTML, capped, or null when the message has no HTML part.
  206. * Only the /inbox/message/frame route may put this in a response; see the
  207. * file header. Board pages link to the frame; they never embed this string.
  208. */
  209. html: string | null
  210. htmlTruncated: boolean
  211. /** Gmail's own UNREAD label; the open-message page offers mark-read from it. */
  212. unread: boolean
  213. /**
  214. * Every attachment part, with the size and handle a download needs. Empty
  215. * array means "we read the MIME tree and there are none": a real zero, not
  216. * an absence.
  217. */
  218. attachments: Attachment[]
  219. /**
  220. * Gmail's thread id, meaningful only inside this mailbox.
  221. *
  222. * This and the fields below are threading and reply-addressing. They are
  223. * read from the original message on the server and are the only source of
  224. * the reply's recipient, subject and thread; the compose form carries no To,
  225. * no Subject and no thread id. A form field a person can edit is a form field
  226. * an attacker can edit, and the one irreversible thing this board does must
  227. * not take its recipient from the request that triggers it.
  228. *
  229. * Thread ids are per-mailbox, so passing `hello@`'s id on a send through
  230. * `accounts@`'s token is rejected by Gmail. See decideReply(): it is sent
  231. * only when replying from the mailbox the message arrived in.
  232. */
  233. threadId: string
  234. /** The Message-ID header, angle brackets as sent. Null when absent (rare). */
  235. messageId: string | null
  236. /** The References header verbatim, or null. */
  237. references: string | null
  238. /** Reply-To verbatim, '' if absent. Beats From as the reply recipient. */
  239. replyTo: string
  240. /**
  241. * Cc verbatim, '' if absent.
  242. *
  243. * Also the source of a reply-all's Cc list, derived on the server by
  244. * `decideReply` from this field and `to`, and never from the form.
  245. */
  246. cc: string
  247. }