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

messages.ts

Language TypeScript26.5 KB690 lines

packages/gmail-client/src/messages.ts

  1. import {
  2. accessTokenForCredential,
  3. GMAIL_SEND_SCOPE,
  4. GmailAuthError,
  5. type GmailCredential,
  6. } from './oauth.js'
  7. import {
  8. BODY_CAP,
  9. HTML_CAP,
  10. collectAttachments,
  11. decodeB64UrlBytes,
  12. extractBody,
  13. type MessageBody,
  14. } from './body.js'
  15. import { errorMessage, sleep } from '@meridian/shared'
  16. /**
  17. * One message, as much of it as this board is willing to hold.
  18. *
  19. * The list is metadata only. Sender, subject, date: the request asks Gmail for
  20. * `format=metadata`, so no body is fetched to build this list, held in memory, or
  21. * written to KV.
  22. *
  23. * Bodies are readable, and they are fetched one at a time when somebody opens a
  24. * message (see `fetchBody`). That keeps the list small enough to serve on every
  25. * page load and keeps seven people's mail out of the board's storage entirely:
  26. * on-demand reading without the board becoming a copy of everyone's mailbox.
  27. */
  28. export interface InboxMessage {
  29. id: string
  30. threadId: string
  31. /** Which roster mailbox this was read from. */
  32. mailbox: string
  33. /** Lowercased address, plus-tag stripped. The matching and grouping key. */
  34. sender: string
  35. /** As the From header spelled it, e.g. "Upscaler Labs". Display only. */
  36. senderName: string
  37. subject: string
  38. /**
  39. * Gmail's own preview line, ≤140 chars.
  40. *
  41. * This is message content at rest, kept deliberately: the list is meant to
  42. * read like Gmail's (sender, subject, preview). Bodies are still never
  43. * stored; this is the same one-line preview Gmail itself shows under every
  44. * subject, and it costs no extra fetches because messages.get returns it
  45. * even at format=metadata.
  46. */
  47. snippet: string
  48. /** ISO. From Gmail's internalDate, which is authoritative over the Date header. */
  49. date: string
  50. /** Gmail's own labels, kept because CATEGORY_PROMOTIONS seeds the filter list. */
  51. labels: string[]
  52. /** Gmail's UNREAD label, lifted out because the list renders it on every row. */
  53. unread: boolean
  54. }
  55. /** One mailbox's read, succeeded or failed. Never merged into a total. */
  56. export interface MailboxRead {
  57. mailbox: string
  58. ok: boolean
  59. error: string | null
  60. messages: InboxMessage[]
  61. /**
  62. * Can this mailbox's credential send? Observed, never assumed.
  63. *
  64. * Three states, and they are not interchangeable (the prime invariant):
  65. * true Google reported gmail.send among this token's scopes.
  66. * false Google reported the scopes and send was not among them: a
  67. * read-only token, which is what every mailbox is until it is
  68. * re-minted with `pnpm inbox:auth`.
  69. * null we do not know: the token response carried no `scope` field, or
  70. * the read failed before there was anything to ask.
  71. *
  72. * Free: it comes off the token response the sync already makes. The 15-minute
  73. * sync rewrites it, so a re-minted mailbox becomes sendable without a deploy.
  74. */
  75. canSend: boolean | null
  76. /**
  77. * How many messages the window actually holds: counted, not estimated.
  78. *
  79. * Gmail's `resultSizeEstimate` sounds authoritative and is capped near the
  80. * requested page size (with maxResults=500 it reports about 501 for any
  81. * larger window), so it is only right when the result fits in one page,
  82. * which is exactly when it is redundant.
  83. *
  84. * So this counts instead. `messages.list` returns ids only and costs one
  85. * subrequest per 500, which is cheap enough to page to the end even when the
  86. * board will not fetch them all: listing is how you learn the size, fetching
  87. * is what costs. Null only when the list could not be paged to the end.
  88. */
  89. windowTotal: number | null
  90. /**
  91. * True when the window held more than this read fetched: observed, by
  92. * comparing what the list enumerated against what was asked for. Rule 5.
  93. */
  94. truncated: boolean
  95. /**
  96. * Messages the batch asked for and did not get back. Not the same as
  97. * `truncated`: truncated means we stopped asking, this means we asked and
  98. * Gmail did not answer. Both render, differently.
  99. */
  100. failedFetches: number
  101. /**
  102. * What Gmail said about the parts it refused, deduplicated, first few only.
  103. *
  104. * A count is not enough: the same number of failures on every run is not a
  105. * rate limit but the same messages failing each time, for a reason the count
  106. * cannot express. Rule 1: report what happened rather than pick a cause, and
  107. * make the next run able to answer it.
  108. */
  109. failureSample: string[]
  110. }
  111. /**
  112. * Split a From header into address and display name.
  113. *
  114. * Handles `Name <a@b.com>`, a bare `a@b.com`, and a quoted name containing a
  115. * comma (`"Labs, Upscaler" <news@upscaler.vendor.example>`), which a split on
  116. * ',' or '<' alone gets wrong.
  117. */
  118. export function parseFrom(header: string): { sender: string; senderName: string } {
  119. const m = /<([^>]+)>\s*$/.exec(header.trim())
  120. const rawAddr = (m ? m[1]! : header).trim()
  121. const name = m ? header.slice(0, m.index).trim().replace(/^"(.*)"$/, '$1').trim() : ''
  122. return { sender: normalizeAddress(rawAddr), senderName: name }
  123. }
  124. /**
  125. * Lowercase, and drop a `+tag`.
  126. *
  127. * Stripping the tag is load-bearing for grouping, not just tidiness. Stripe
  128. * sends failed-payment notices from
  129. * `failed-payments+acct_1ExampleAcct0000@stripe.com`, a per-account tag. Left
  130. * whole, every account becomes its own "sender" in the list and the filter
  131. * button applies to one of them. Normalized, the vendor is one row.
  132. */
  133. export function normalizeAddress(addr: string): string {
  134. const a = addr.trim().toLowerCase().replace(/^<|>$/g, '')
  135. const at = a.lastIndexOf('@')
  136. if (at < 1) return a
  137. const local = a.slice(0, at).split('+')[0]!
  138. return `${local}${a.slice(at)}`
  139. }
  140. /**
  141. * Split an address-list header (To, Cc) into its entries.
  142. *
  143. * A split on ',' is wrong: a client thread Cc'd to
  144. * `"Ortiz, Lena" <Lena.Ortiz@lakeside-expo.example>` has a display name that
  145. * contains the separator. Splitting on commas turns one recipient into
  146. * `"Ortiz` and `Lena" <Lena.Ortiz@lakeside-expo.example>`: the first is not an
  147. * address at all and the second would be sent to.
  148. *
  149. * So this tracks quoting and angle brackets and splits only on a comma outside
  150. * both. Entries are returned verbatim (name and all); `parseFrom` is what
  151. * reduces one to an address.
  152. */
  153. export function splitAddressList(header: string): string[] {
  154. const out: string[] = []
  155. let cur = ''
  156. let inQuote = false
  157. let inAngle = false
  158. for (const ch of header) {
  159. if (ch === '"' && !inAngle) inQuote = !inQuote
  160. else if (ch === '<' && !inQuote) inAngle = true
  161. else if (ch === '>' && !inQuote) inAngle = false
  162. if (ch === ',' && !inQuote && !inAngle) {
  163. if (cur.trim()) out.push(cur.trim())
  164. cur = ''
  165. continue
  166. }
  167. cur += ch
  168. }
  169. if (cur.trim()) out.push(cur.trim())
  170. return out
  171. }
  172. /**
  173. * Clean Gmail's snippet for a list row.
  174. *
  175. * Snippets arrive HTML-entity-encoded (&#39; &amp;) and, in marketing mail,
  176. * padded with hundreds of zero-width/soft-hyphen characters to game preview
  177. * text: the "͏ ͏ ͏" seen in every promotional row. Same junk class as
  178. * htmlToText strips from bodies.
  179. */
  180. export function cleanSnippet(raw: string | undefined, cap = 140): string {
  181. if (!raw) return ''
  182. const s = raw
  183. .replace(/&nbsp;|&#160;/gi, ' ')
  184. .replace(/&amp;/gi, '&')
  185. .replace(/&lt;/gi, '<')
  186. .replace(/&gt;/gi, '>')
  187. .replace(/&quot;/gi, '"')
  188. .replace(/&#39;|&apos;/gi, "'")
  189. .replace(/[­​-‏⁠]/g, '')
  190. .replace(/\s+/g, ' ')
  191. .trim()
  192. return s.length > cap ? s.slice(0, cap - 1).trimEnd() + '…' : s
  193. }
  194. const API = 'https://gmail.googleapis.com/gmail/v1/users/me/messages'
  195. const BATCH_API = 'https://gmail.googleapis.com/batch/gmail/v1'
  196. /**
  197. * Gmail's batch endpoint: many messages.get in one HTTP request.
  198. *
  199. * A batch is one fetch, so a chunk of messages costs one Cloudflare subrequest
  200. * instead of one each, and one round trip instead of many serial ones, which
  201. * is also where the wall-clock goes. Gmail's own per-message quota is
  202. * unchanged, which is why the chunk size below matters.
  203. *
  204. * Partial failure is counted, not swallowed. One unreadable message does not
  205. * fail the mailbox, but silently returning 97 of 100 would make a truncated
  206. * read look like a complete one. The count comes back and the surface says so
  207. * (rule 5).
  208. *
  209. * Messages per batch, and why it is not 100: at Gmail's documented maximum of
  210. * 100, roughly a third of each large mailbox comes back refused, with every id
  211. * attempted and the losses inside the batch.
  212. *
  213. * The arithmetic explains it exactly. `messages.get` costs 5 quota units and
  214. * Gmail allows 250 units per second per user; a chunk of 100 spends 500 at once,
  215. * twice the ceiling, and the overflow is refused. The documented maximum is a
  216. * limit on the request, not permission to spend the quota.
  217. *
  218. * 40 per chunk is 200 units (inside the ceiling with room for the token
  219. * refresh), and BATCH_PACE_MS keeps successive chunks from stacking. Each
  220. * mailbox is a different Gmail user with its own budget, so the seven still run
  221. * in parallel.
  222. */
  223. const BATCH_CHUNK = 40
  224. /** Spacing between chunks, so 200 units do not land twice in one second. */
  225. const BATCH_PACE_MS = 1_100
  226. /** How many times to re-ask for parts Gmail refused. */
  227. const BATCH_RETRIES = 2
  228. /**
  229. * How long to wait before re-asking, in order.
  230. *
  231. * Sized to the quota, not doubled from the pacing interval. The refusals say,
  232. * in Gmail's own words:
  233. *
  234. * 403 PERMISSION_DENIED: Quota exceeded for quota metric 'Total Query Cost'
  235. * and limit 'Units per MINUTE per user'
  236. *
  237. * A per-minute budget is not repaired by waiting a few seconds; retries inside
  238. * the same exhausted minute are refused for the same reason. These wait long
  239. * enough for the window to actually roll. The sync runs every
  240. * fifteen minutes, so a minute of waiting costs nothing that matters.
  241. */
  242. const RETRY_BACKOFF_MS = [20_000, 60_000]
  243. /**
  244. * How many list pages to walk before giving up on counting the window.
  245. *
  246. * 500 ids a page, so this covers 10,000 messages for 20 subrequests. Past that
  247. * the count comes back null ("we did not finish counting") rather than a
  248. * number that is really "the first ten thousand".
  249. */
  250. const LIST_PAGE_CAP = 20
  251. /** One message as Gmail returns it at format=metadata. */
  252. interface RawMessage {
  253. id: string
  254. threadId: string
  255. internalDate?: string
  256. labelIds?: string[]
  257. snippet?: string
  258. payload?: { headers?: { name: string; value: string }[] }
  259. }
  260. /**
  261. * Build the multipart/mixed body. Each part is a complete HTTP request.
  262. *
  263. * The trailing blank line after the request line is required; a part without
  264. * it is rejected as malformed, with an error that names nothing useful.
  265. */
  266. export function buildBatchBody(ids: string[], boundary: string): string {
  267. const parts = ids.map((id, i) => {
  268. const path =
  269. `/gmail/v1/users/me/messages/${encodeURIComponent(id)}?format=metadata` +
  270. `&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date`
  271. return (
  272. `--${boundary}\r\n` +
  273. `Content-Type: application/http\r\n` +
  274. `Content-ID: <item-${i}>\r\n\r\n` +
  275. `GET ${path}\r\n\r\n`
  276. )
  277. })
  278. return `${parts.join('')}--${boundary}--\r\n`
  279. }
  280. /**
  281. * Pull the message objects out of a multipart/mixed batch response.
  282. *
  283. * Responses are matched by the id in their own body, not by Content-ID order.
  284. * Google documents that parts may come back in any order, and every successful
  285. * body already carries the message's own id, so the mapping is free and cannot
  286. * drift. A part with no parseable message is counted as a failure rather than
  287. * dropped, which is the difference between "this mailbox has 97 messages" and
  288. * "we asked for 100 and got 97".
  289. */
  290. export function parseBatchResponse(
  291. contentType: string,
  292. body: string,
  293. ): { messages: RawMessage[]; failed: number; errors: string[] } {
  294. const m = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType)
  295. const boundary = m?.[1] ?? m?.[2]
  296. // No boundary means this is not a multipart response at all: an error page,
  297. // or a shape change. Every part is a failure; none is invented.
  298. if (!boundary) return { messages: [], failed: 0, errors: ['response was not multipart'] }
  299. const messages: RawMessage[] = []
  300. const errors: string[] = []
  301. let failed = 0
  302. for (const chunk of body.split(`--${boundary}`)) {
  303. const trimmed = chunk.trim()
  304. if (!trimmed || trimmed === '--') continue
  305. // Each part is an HTTP response: status line, headers, blank line, JSON.
  306. const at = trimmed.indexOf('{')
  307. if (at === -1) {
  308. failed++
  309. // Keep the status line. A part with no JSON at all still says what
  310. // happened on its first line, and that line is the difference between
  311. // "rate limited" and "these messages are gone": two failures with
  312. // identical counts and opposite fixes.
  313. errors.push(trimmed.split('\r\n').find((l) => l.startsWith('HTTP/')) ?? 'no body')
  314. continue
  315. }
  316. try {
  317. const parsed = JSON.parse(trimmed.slice(at)) as RawMessage & {
  318. error?: { message?: string; status?: string; code?: number }
  319. }
  320. if (parsed.error || typeof parsed.id !== 'string') {
  321. failed++
  322. errors.push(
  323. parsed.error
  324. ? `${parsed.error.code ?? '?'} ${parsed.error.status ?? ''} ${parsed.error.message ?? ''}`.trim()
  325. : 'a 200 with no message id',
  326. )
  327. } else {
  328. messages.push(parsed)
  329. }
  330. } catch (err) {
  331. failed++
  332. errors.push(`unparseable part: ${errorMessage(err)}`)
  333. }
  334. }
  335. return { messages, failed, errors }
  336. }
  337. /** A batch boundary that cannot collide with the bodies it separates. */
  338. const batchBoundary = (): string => {
  339. const b = new Uint8Array(12)
  340. crypto.getRandomValues(b)
  341. return `batch-${[...b].map((x) => x.toString(16).padStart(2, '0')).join('')}`
  342. }
  343. /** One pass: every id once, paced. Returns what came back and what did not. */
  344. async function batchPass(
  345. ids: string[],
  346. accessToken: string,
  347. f: typeof fetch,
  348. paceMs: number,
  349. ): Promise<{ messages: RawMessage[]; missing: string[]; errors: string[] }> {
  350. const out: RawMessage[] = []
  351. const errors: string[] = []
  352. const got = new Set<string>()
  353. for (let i = 0; i < ids.length; i += BATCH_CHUNK) {
  354. if (i > 0 && paceMs > 0) await sleep(paceMs)
  355. const chunk = ids.slice(i, i + BATCH_CHUNK)
  356. const boundary = batchBoundary()
  357. const res = await f(BATCH_API, {
  358. method: 'POST',
  359. headers: {
  360. authorization: `Bearer ${accessToken}`,
  361. 'content-type': `multipart/mixed; boundary=${boundary}`,
  362. },
  363. body: buildBatchBody(chunk, boundary),
  364. })
  365. // A whole chunk refused is not a failed mailbox (the others may be fine),
  366. // and every id in it stays on the missing list for the retry.
  367. if (!res.ok) {
  368. errors.push(`whole chunk refused: HTTP ${res.status}`)
  369. continue
  370. }
  371. const parsed = parseBatchResponse(res.headers.get('content-type') ?? '', await res.text())
  372. errors.push(...parsed.errors)
  373. for (const m of parsed.messages) {
  374. if (!got.has(m.id)) {
  375. got.add(m.id)
  376. out.push(m)
  377. }
  378. }
  379. }
  380. return { messages: out, missing: ids.filter((id) => !got.has(id)), errors }
  381. }
  382. /**
  383. * Every message, with the refused ones asked for again.
  384. *
  385. * Retries exist because the failures are rate limits, not bad messages. A
  386. * refused part means Gmail was busy, and asking again a moment later works,
  387. * which is exactly the case where giving up would quietly shrink a mailbox.
  388. * What remains after the retries is reported as a count, never swallowed.
  389. */
  390. async function batchGet(
  391. ids: string[],
  392. accessToken: string,
  393. f: typeof fetch,
  394. paceMs: number,
  395. ): Promise<{ messages: RawMessage[]; failed: number; lastErrors: string[] }> {
  396. const all: RawMessage[] = []
  397. let pending = ids
  398. let lastErrors: string[] = []
  399. for (let attempt = 0; attempt <= BATCH_RETRIES && pending.length; attempt++) {
  400. if (attempt > 0 && paceMs > 0) await sleep(RETRY_BACKOFF_MS[attempt - 1] ?? 60_000)
  401. const { messages, missing, errors } = await batchPass(pending, accessToken, f, paceMs)
  402. all.push(...messages)
  403. pending = missing
  404. lastErrors = errors
  405. }
  406. return { messages: all, failed: pending.length, lastErrors }
  407. }
  408. /**
  409. * Read one mailbox: `list` for the ids (paged to the end, to count the
  410. * window), then the batch endpoint for the metadata of the newest `maxMessages`.
  411. *
  412. * Never throws, for a caller looping over mailboxes. A dead token on one mailbox
  413. * is routine (it is exactly what happens the day a role account changes hands),
  414. * and losing the other six because of it would be the worse outcome. The failure
  415. * comes back as `ok: false` with its reason, and the surface is required to say
  416. * so rather than render an inbox with nothing in it.
  417. */
  418. export async function readMailbox(
  419. mailbox: string,
  420. sa: GmailCredential,
  421. opts: {
  422. maxMessages?: number
  423. query?: string
  424. fetchImpl?: typeof fetch
  425. /**
  426. * Milliseconds between batch chunks. Defaults to the real pacing; tests
  427. * pass 0 so a suite that must stay offline and fast is not also waiting out
  428. * a rate limiter it is not exercising.
  429. */
  430. paceMs?: number
  431. /**
  432. * Walk the id list to the end to learn the window size, or stop as soon as
  433. * `maxMessages` ids are in hand.
  434. *
  435. * True for the sync, which wants the count and can afford up to twenty list
  436. * pages. False for a live search, which runs on the request path with a
  437. * person waiting: twenty pages × seven mailboxes is 140 subrequests and
  438. * several seconds spent counting results nobody asked to count. A search
  439. * says "the first N matches", not "N of M".
  440. */
  441. countWindow?: boolean
  442. } = {},
  443. ): Promise<MailboxRead> {
  444. const f = opts.fetchImpl ?? fetch
  445. const max = opts.maxMessages ?? 100
  446. const paceMs = opts.paceMs ?? BATCH_PACE_MS
  447. const countWindow = opts.countWindow ?? true
  448. // Inbox only, recent only. Archived mail is not a queue anyone is looking at.
  449. const query = opts.query ?? 'in:inbox newer_than:30d'
  450. try {
  451. const { accessToken, scopes } = await accessTokenForCredential(mailbox, sa, f)
  452. const auth = { authorization: `Bearer ${accessToken}` }
  453. // The list is paged to the end; the fetch is not. They cost very different
  454. // things: `messages.list` returns ids only, one subrequest per 500, so
  455. // walking the whole window is nearly free, and it is the only way to learn
  456. // how big the window actually is (see `windowTotal`).
  457. const ids: string[] = []
  458. let pageToken: string | undefined
  459. let pages = 0
  460. do {
  461. const params = new URLSearchParams({ maxResults: '500', q: query })
  462. if (pageToken) params.set('pageToken', pageToken)
  463. const listRes = await f(`${API}?${params.toString()}`, { headers: auth })
  464. if (!listRes.ok) throw new Error(`list failed: HTTP ${listRes.status}`)
  465. const list = (await listRes.json()) as {
  466. messages?: { id: string }[]
  467. nextPageToken?: string
  468. }
  469. for (const m of list.messages ?? []) ids.push(m.id)
  470. pageToken = list.nextPageToken
  471. pages++
  472. } while (pageToken && pages < LIST_PAGE_CAP && (countWindow || ids.length < max))
  473. // Null rather than a wrong number: if the walk stopped at the page cap we
  474. // do not know the total, and saying so is the whole point of this field.
  475. // A leftover pageToken is the only thing that matters here: it means the
  476. // walk stopped early, whether at the page cap or because a search asked not
  477. // to count. Null then: "we did not finish counting" is a different fact
  478. // from any total. When the list genuinely ended, the count is exact and
  479. // free even for a search that never asked for it.
  480. const windowTotal = pageToken ? null : ids.length
  481. // Gmail returns the list newest-first, so the cap keeps the newest.
  482. const wanted = ids.slice(0, max)
  483. const truncated = ids.length > wanted.length || pageToken !== undefined
  484. // One fetch per chunk of messages, not one per message. See BATCH_CHUNK.
  485. const { messages: raw, failed, lastErrors } = await batchGet(wanted, accessToken, f, paceMs)
  486. const messages: InboxMessage[] = raw.map((m) => {
  487. const header = (n: string): string =>
  488. m.payload?.headers?.find((h) => h.name.toLowerCase() === n)?.value ?? ''
  489. const { sender, senderName } = parseFrom(header('from'))
  490. return {
  491. id: m.id,
  492. threadId: m.threadId,
  493. mailbox,
  494. sender,
  495. senderName,
  496. subject: header('subject'),
  497. snippet: cleanSnippet(m.snippet),
  498. // internalDate over the Date header: the header is set by the sender and
  499. // is routinely wrong or absent, and this list sorts by it.
  500. date: m.internalDate
  501. ? new Date(Number(m.internalDate)).toISOString()
  502. : new Date(0).toISOString(),
  503. labels: m.labelIds ?? [],
  504. unread: (m.labelIds ?? []).includes('UNREAD'),
  505. }
  506. })
  507. // Gmail returns batch parts in no guaranteed order, so sort rather than
  508. // trusting arrival. The page sorts again, but a list that is only sorted
  509. // downstream is one bad refactor away from arriving shuffled.
  510. messages.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))
  511. return {
  512. mailbox,
  513. ok: true,
  514. error: null,
  515. messages,
  516. canSend: scopes === null ? null : scopes.includes(GMAIL_SEND_SCOPE),
  517. windowTotal,
  518. truncated,
  519. failedFetches: failed,
  520. failureSample: [...new Set(lastErrors)].slice(0, 3),
  521. }
  522. } catch (err) {
  523. const reason = errorMessage(err)
  524. // canSend stays null on a failed read: we never got far enough to learn
  525. // anything about the token's scopes, and "failed" must not be reported as
  526. // "read-only"; they need different fixes.
  527. return {
  528. mailbox,
  529. ok: false,
  530. error: reason,
  531. messages: [],
  532. canSend: null,
  533. windowTotal: null,
  534. truncated: false,
  535. failedFetches: 0,
  536. failureSample: [],
  537. }
  538. }
  539. }
  540. /**
  541. * One message, in full, right now.
  542. *
  543. * Called from the request path rather than the cron, so it costs a token
  544. * refresh plus one Gmail call per open: about a fifth of a second, and only for
  545. * the message someone actually clicked.
  546. *
  547. * Returns `{ error }` when the id is unknown or unreadable. The caller renders
  548. * that as "could not be read" and never as an empty message: a body we failed to fetch
  549. * and a body that is genuinely blank are different facts.
  550. */
  551. export async function fetchBody(
  552. mailbox: string,
  553. id: string,
  554. sa: GmailCredential,
  555. fetchImpl: typeof fetch = fetch,
  556. ): Promise<MessageBody | { error: string }> {
  557. try {
  558. const { accessToken } = await accessTokenForCredential(mailbox, sa, fetchImpl)
  559. const res = await fetchImpl(`${API}/${encodeURIComponent(id)}?format=full`, {
  560. headers: { authorization: `Bearer ${accessToken}` },
  561. })
  562. if (!res.ok) return { error: `Gmail returned HTTP ${res.status} for that message.` }
  563. const m = (await res.json()) as {
  564. id: string
  565. threadId?: string
  566. internalDate?: string
  567. labelIds?: string[]
  568. payload?: Parameters<typeof extractBody>[0] & { headers?: { name: string; value: string }[] }
  569. }
  570. const header = (n: string): string =>
  571. m.payload?.headers?.find((h) => h.name.toLowerCase() === n)?.value ?? ''
  572. const { sender, senderName } = parseFrom(header('from'))
  573. const { text, kind, html } = extractBody(m.payload)
  574. // Attachments: name, mime type, size and Gmail's handle, enough for the
  575. // page to say how big a file is and for /inbox/attachment to fetch it. See
  576. // the Attachment interface for why an index rather than an id addresses a
  577. // download.
  578. const attachments = collectAttachments(m.payload)
  579. return {
  580. id: m.id,
  581. mailbox,
  582. sender,
  583. senderName,
  584. subject: header('subject'),
  585. to: header('to'),
  586. date: m.internalDate ? new Date(Number(m.internalDate)).toISOString() : new Date(0).toISOString(),
  587. text: text.slice(0, BODY_CAP),
  588. kind,
  589. truncated: text.length > BODY_CAP,
  590. html: html ? html.slice(0, HTML_CAP) : null,
  591. htmlTruncated: html.length > HTML_CAP,
  592. unread: (m.labelIds ?? []).includes('UNREAD'),
  593. attachments,
  594. // Threading and reply-addressing. format=full already returns every
  595. // header, so these cost nothing extra, and they are read here, on the
  596. // server, because the reply's recipient must not come from a form field.
  597. // An absent header is null (or '' for the two string fields), never
  598. // undefined: the caller branches on it and `undefined` would read as a
  599. // bug rather than as "the sender did not send one".
  600. threadId: m.threadId ?? '',
  601. messageId: header('message-id') || null,
  602. references: header('references') || null,
  603. replyTo: header('reply-to'),
  604. cc: header('cc'),
  605. }
  606. } catch (err) {
  607. return { error: errorMessage(err) }
  608. }
  609. }
  610. /**
  611. * How big an attachment this board will hand back.
  612. *
  613. * A memory limit, not a policy one, which is why it is larger than the send
  614. * cap in config/inbox.ts. What a client attaches is not our choice; what the
  615. * Worker can hold is. The chain for one download: Gmail answers with base64
  616. * (4/3 of the bytes) inside a JSON string, which V8 holds as UTF-16 (×2), and
  617. * the decoded bytes exist alongside it, so a 10 MB file peaks near 40 MB
  618. * against a Worker's 128 MB. Above this the page says the size and sends the
  619. * reader to Gmail, which is a true answer; a truncated file would not be.
  620. */
  621. export const ATTACHMENT_DOWNLOAD_CAP = 10 * 1024 * 1024
  622. /**
  623. * One attachment's bytes.
  624. *
  625. * Never throws, and returns the size it actually got rather than trusting the
  626. * size the message listing reported: the caller checks the cap before calling
  627. * (from the part's declared size) and this returns what arrived, so a message
  628. * that under-declares a part cannot walk past the ceiling unnoticed.
  629. */
  630. export async function fetchAttachment(
  631. mailbox: string,
  632. messageId: string,
  633. attachmentId: string,
  634. sa: GmailCredential,
  635. fetchImpl: typeof fetch = fetch,
  636. ): Promise<{ bytes: Uint8Array } | { error: string }> {
  637. try {
  638. const { accessToken } = await accessTokenForCredential(mailbox, sa, fetchImpl)
  639. const res = await fetchImpl(
  640. `${API}/${encodeURIComponent(messageId)}/attachments/${encodeURIComponent(attachmentId)}`,
  641. { headers: { authorization: `Bearer ${accessToken}` } },
  642. )
  643. if (!res.ok) return { error: `Gmail returned HTTP ${res.status} for that attachment.` }
  644. const body = (await res.json()) as { data?: string; size?: number }
  645. if (!body.data) return { error: 'Gmail returned that attachment with no data.' }
  646. const bytes = decodeB64UrlBytes(body.data)
  647. if (bytes.byteLength > ATTACHMENT_DOWNLOAD_CAP) {
  648. return {
  649. error: `That attachment is ${bytes.byteLength.toLocaleString()} bytes, over this board's ${ATTACHMENT_DOWNLOAD_CAP.toLocaleString()}-byte download ceiling. It is in Gmail.`,
  650. }
  651. }
  652. return { bytes }
  653. } catch (err) {
  654. return {
  655. error: errorMessage(err),
  656. }
  657. }
  658. }