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

send.ts

Language TypeScript16.7 KB447 lines

packages/gmail-client/src/send.ts

  1. /**
  2. * Sending mail: the only outbound write this board makes (rule 12).
  3. *
  4. * Everything else here reads: five sources, seven mailboxes, no write-back.
  5. * Sending exists because most addresses are role mailboxes that change hands,
  6. * and the board is where the team already sees them together.
  7. *
  8. * A send cannot be taken back, so the shape of this module is built around
  9. * that one fact:
  10. *
  11. * Nothing here decides who to write to. `decideReply` and `decideNew` in the
  12. * Worker settle every recipient; this module is handed a finished
  13. * `OutgoingMessage` and encodes it. For a reply that recipient still comes
  14. * off the original message on the server. For a new message it necessarily
  15. * comes from the form, because there is no
  16. * original to read it from; what guards that is validation, the count cap
  17. * and the confirmation step in the Worker, not this file.
  18. *
  19. * `sendMessage` never throws. Same contract as `readMailbox`, for the same
  20. * reason: the caller has an audit record to write either way, and a thrown
  21. * error halfway through that is a send whose outcome nobody recorded.
  22. *
  23. * A failure names candidates and picks none (rule 1). `SendResult.candidates`
  24. * is a list, in a structured field, so the page can print all of them.
  25. *
  26. * Nothing that identifies the person behind a shared mailbox goes into the
  27. * outgoing mail, not even an `X-` header: every mail client shows raw
  28. * headers under "Show original". Who sent what is recorded where a recipient
  29. * cannot reach it: `inbox:sent:*` and the mailbox's own Sent folder.
  30. *
  31. * Attachments: the caps live in config/inbox.ts (they are policy, and the arithmetic behind
  32. * them is written out there); the encoder takes bytes it is given and does two
  33. * things carefully:
  34. *
  35. * A filename is not a header value. It arrives from whoever picked the file,
  36. * it goes into `Content-Disposition`, and CR, LF and `"` in it would end the
  37. * parameter and start something else. `mimeParam` strips those and RFC
  38. * 2047-encodes anything non-ASCII.
  39. *
  40. * A declared MIME type is not trusted either. It is a header too, so it goes
  41. * through the same sanitiser, and anything that does not look like
  42. * `type/subtype` becomes application/octet-stream rather than being passed
  43. * through.
  44. *
  45. * What Gmail rejects is Gmail's to reject. There is no extension blocklist
  46. * here: Gmail refuses .exe and friends itself, and its refusal is reported with
  47. * candidates (rule 3: the source's own semantics beat a hand-maintained list
  48. * that lags it).
  49. *
  50. * Runtime-neutral: no `node:` imports, because the Worker bundles this (rule 7).
  51. */
  52. import {
  53. accessTokenForCredential,
  54. b64urlText,
  55. GmailAuthError,
  56. type GmailCredential,
  57. } from './oauth.js'
  58. import { htmlToText } from './body.js'
  59. import { errorMessage } from '@meridian/shared'
  60. const SEND_URL = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send'
  61. /**
  62. * One file going out, already read into memory by the Worker.
  63. *
  64. * `bytes` and not a base64 string: the encoder needs to know the real length
  65. * to check nothing, but the caller does, and a string would make the size
  66. * checks in the Worker apply to the encoded form rather than the file, which
  67. * is the kind of off-by-a-third that makes a cap mean something other than
  68. * what it says.
  69. */
  70. export interface OutgoingAttachment {
  71. name: string
  72. mimeType: string
  73. bytes: Uint8Array
  74. }
  75. export interface OutgoingMessage {
  76. /** The sending mailbox's own address. Each token sends only as itself. */
  77. from: string
  78. /**
  79. * The recipients, one header's worth, already resolved by the caller:
  80. * from the original for a reply, from the validated form for a new message.
  81. */
  82. to: string
  83. /**
  84. * Cc, or null for none. Null and '' must not both be passed for "none":
  85. * the encoder omits the header when this is null, and an empty Cc header is
  86. * legal but reads, in a Sent folder and in an audit row, as a Cc that was
  87. * sent to nobody rather than as no Cc at all.
  88. */
  89. cc: string | null
  90. /** Already through `replySubject` for a reply; verbatim for a new message. */
  91. subject: string
  92. /** What the person typed. Plain text; the compose box is a textarea. */
  93. text: string
  94. /** That mailbox's signature, or null when none is set or the store failed. */
  95. signatureHtml: string | null
  96. /** The original's Message-ID, or null when it had none. */
  97. inReplyTo: string | null
  98. /** Original References plus the original's Message-ID, or null. */
  99. references: string | null
  100. /**
  101. * Gmail's thread id, only when replying from the mailbox the message
  102. * arrived in. Thread ids are per-mailbox; sending one mailbox's id through
  103. * another's token is rejected. The caller enforces this, and a test pins it.
  104. */
  105. threadId: string | null
  106. /**
  107. * Extra headers, if a caller ever needs one. Empty in production: see the
  108. * file header on why no attribution header is sent. The parameter stays
  109. * because the encoder should not be the thing that has an opinion; nothing
  110. * in this repo passes it.
  111. */
  112. extraHeaders?: Record<string, string>
  113. /**
  114. * Files. Empty array means none; the encoder then produces a plain
  115. * multipart/alternative message with no mixed wrapper.
  116. */
  117. attachments: OutgoingAttachment[]
  118. }
  119. /** `Re: ` once, never twice. `RE:`, `re:` and leading space all count as one. */
  120. export function replySubject(subject: string): string {
  121. const s = subject.trim()
  122. if (!s) return 'Re: (no subject)'
  123. return /^re\s*:/i.test(s) ? s : `Re: ${s}`
  124. }
  125. /** Printable ASCII only, per RFC 2822: space through tilde. */
  126. const isAsciiPrintable = (s: string): boolean => /^[\x20-\x7e]*$/.test(s)
  127. /**
  128. * A header value that cannot inject a header.
  129. *
  130. * CR and LF are what turn one header into two, and this is the one place in
  131. * the repo where a stray newline in a string would let a value forge a `Bcc:`.
  132. * The subject comes from a message someone else sent us, so it is not ours to
  133. * trust however unlikely a folded value looks.
  134. */
  135. const oneLine = (s: string): string => s.replace(/[\r\n]+/g, ' ').trim()
  136. /**
  137. * RFC 2047 for a header that is not pure ASCII: an accented client name, an
  138. * emoji in a subject line. ASCII passes through untouched, because encoding it
  139. * anyway makes every subject unreadable in the raw source for no gain.
  140. */
  141. export function encodeHeaderWord(s: string): string {
  142. const v = oneLine(s)
  143. return isAsciiPrintable(v) ? v : `=?UTF-8?B?${b64Std(v)}?=`
  144. }
  145. /** UTF-8 -> standard base64 (padded). btoa alone throws above U+00FF. */
  146. const b64Std = (s: string): string => {
  147. const bytes = new TextEncoder().encode(s)
  148. let bin = ''
  149. for (const b of bytes) bin += String.fromCharCode(b)
  150. return btoa(bin)
  151. }
  152. /** Base64 wrapped at 76 columns, which is what a MIME body part wants. */
  153. const b64Body = (s: string): string => (b64Std(s).match(/.{1,76}/g) ?? ['']).join('\r\n')
  154. /**
  155. * The same, for bytes that are not text.
  156. *
  157. * Chunked through `String.fromCharCode`: the spread form blows the call stack
  158. * somewhere past a hundred thousand arguments, which for a 3 MB attachment is
  159. * not an edge case but every time. (The HTML-cap test in body.test.ts hit the
  160. * same wall from the other direction and chunks for the same reason.)
  161. */
  162. const b64Bytes = (bytes: Uint8Array): string => {
  163. let bin = ''
  164. for (let i = 0; i < bytes.length; i += 8192) {
  165. bin += String.fromCharCode(...bytes.subarray(i, i + 8192))
  166. }
  167. return (btoa(bin).match(/.{1,76}/g) ?? ['']).join('\r\n')
  168. }
  169. /**
  170. * A quoted MIME parameter value (a filename, mostly).
  171. *
  172. * CR, LF and `"` all end the parameter early, and a filename is chosen by
  173. * whoever picked the file: on a reply-with-attachment that is a colleague, but
  174. * on any path where a name could come from a message we received it is a
  175. * stranger. Backslash goes too, because a trailing one escapes the closing
  176. * quote. Non-ASCII is RFC 2047-encoded rather than dropped, so an accented
  177. * filename survives instead of arriving as `_____.pdf`.
  178. */
  179. export function mimeParam(value: string): string {
  180. const clean = value.replace(/[\r\n"\\]/g, '').trim()
  181. return encodeHeaderWord(clean || 'attachment')
  182. }
  183. /**
  184. * A mime type we are willing to write into a header.
  185. *
  186. * Anything that is not plainly `type/subtype` becomes octet-stream. The type
  187. * is a hint to the recipient's client about a file it already has; a
  188. * pass-through of whatever a browser or a sender put in the field is a header
  189. * value nobody checked.
  190. */
  191. export function safeMimeType(t: string): string {
  192. const v = oneLine(t).toLowerCase().split(';')[0]!.trim()
  193. return /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(v) ? v : 'application/octet-stream'
  194. }
  195. /**
  196. * Typed text -> the HTML part.
  197. *
  198. * `<br>` rather than `white-space:pre-wrap`. Both were specified; using both
  199. * double-spaces every line, and of the two `<br>` is the one Outlook renders.
  200. */
  201. export function textToHtml(text: string): string {
  202. const escaped = text
  203. .replace(/&/g, '&amp;')
  204. .replace(/</g, '&lt;')
  205. .replace(/>/g, '&gt;')
  206. .replace(/"/g, '&quot;')
  207. .replace(/'/g, '&#39;')
  208. return `<div>${escaped.replace(/\r\n|\r|\n/g, '<br>')}</div>`
  209. }
  210. /** A MIME boundary that cannot collide with the body. */
  211. const boundary = (): string => {
  212. const b = new Uint8Array(16)
  213. crypto.getRandomValues(b)
  214. return `----meridian-${[...b].map((x) => x.toString(16).padStart(2, '0')).join('')}`
  215. }
  216. /**
  217. * The whole message, RFC 2822, CRLF throughout.
  218. *
  219. * multipart/alternative with both parts base64-encoded. Base64 rather than
  220. * quoted-printable is the reason this file needs no QP encoder: it makes 8-bit
  221. * UTF-8 and long lines legal in one step, and every mail client has read it
  222. * since 1996.
  223. */
  224. export function buildRawMessage(msg: OutgoingMessage, date: Date = new Date()): string {
  225. const alt = boundary()
  226. const files = msg.attachments ?? []
  227. // With files the message grows a layer, and only then: multipart/mixed
  228. // wrapping the multipart/alternative part. A mixed wrapper around a single
  229. // alternative part with no files is legal and renders identically, but it
  230. // would add bytes to every reply for no reason, and this is the one output in
  231. // the repo nobody can inspect after the fact.
  232. const mixed = files.length ? boundary() : null
  233. const headers: string[] = [
  234. `From: ${oneLine(msg.from)}`,
  235. `To: ${oneLine(msg.to)}`,
  236. ...(msg.cc ? [`Cc: ${oneLine(msg.cc)}`] : []),
  237. `Subject: ${encodeHeaderWord(msg.subject)}`,
  238. // toUTCString ends in "GMT"; RFC 2822 wants a numeric offset.
  239. `Date: ${date.toUTCString().replace(/GMT$/, '+0000')}`,
  240. 'MIME-Version: 1.0',
  241. ]
  242. if (msg.inReplyTo) headers.push(`In-Reply-To: ${oneLine(msg.inReplyTo)}`)
  243. if (msg.references) headers.push(`References: ${oneLine(msg.references)}`)
  244. for (const [k, v] of Object.entries(msg.extraHeaders ?? {})) {
  245. headers.push(`${oneLine(k)}: ${oneLine(v)}`)
  246. }
  247. headers.push(
  248. mixed
  249. ? `Content-Type: multipart/mixed; boundary="${mixed}"`
  250. : `Content-Type: multipart/alternative; boundary="${alt}"`,
  251. )
  252. // The signature's text form comes from the same htmlToText the reader sees,
  253. // so the plain-text part is not a second rendering to keep in step. "-- " is
  254. // the RFC 3676 signature separator; the trailing space is part of it.
  255. const sigText = msg.signatureHtml ? `\r\n\r\n-- \r\n${htmlToText(msg.signatureHtml)}` : ''
  256. const plain = `${msg.text}${sigText}`
  257. const html = `${textToHtml(msg.text)}${msg.signatureHtml ? `<br><br>${msg.signatureHtml}` : ''}`
  258. const bodyParts = [
  259. `--${alt}`,
  260. 'Content-Type: text/plain; charset=UTF-8',
  261. 'Content-Transfer-Encoding: base64',
  262. '',
  263. b64Body(plain),
  264. `--${alt}`,
  265. 'Content-Type: text/html; charset=UTF-8',
  266. 'Content-Transfer-Encoding: base64',
  267. '',
  268. b64Body(html),
  269. `--${alt}--`,
  270. ]
  271. if (!mixed) return [headers.join('\r\n'), '', ...bodyParts, ''].join('\r\n')
  272. const fileParts = files.flatMap((f) => {
  273. const name = mimeParam(f.name)
  274. return [
  275. `--${mixed}`,
  276. `Content-Type: ${safeMimeType(f.mimeType)}; name="${name}"`,
  277. `Content-Disposition: attachment; filename="${name}"`,
  278. 'Content-Transfer-Encoding: base64',
  279. '',
  280. b64Bytes(f.bytes),
  281. ]
  282. })
  283. return [
  284. headers.join('\r\n'),
  285. '',
  286. `--${mixed}`,
  287. `Content-Type: multipart/alternative; boundary="${alt}"`,
  288. '',
  289. ...bodyParts,
  290. ...fileParts,
  291. `--${mixed}--`,
  292. '',
  293. ].join('\r\n')
  294. }
  295. /** Gmail's `raw` field is base64url of the whole RFC 2822 message. */
  296. export const rawToB64Url = (raw: string): string => b64urlText(raw)
  297. export type SendResult =
  298. | { ok: true; id: string; threadId: string }
  299. | { ok: false; status: number | null; error: string; candidates: string[] }
  300. /**
  301. * What could produce this status, all of it, none of it chosen.
  302. *
  303. * Every entry is something a person can go and check. The list is the answer;
  304. * a ranked guess would be the thing rule 1 exists to prevent.
  305. */
  306. function candidatesFor(status: number | null, sentThreadId: boolean): string[] {
  307. if (status === 401) {
  308. return [
  309. 'the access token was rejected at send time',
  310. 'the refresh token has been revoked (Google Account → Security → Third-party access)',
  311. 'GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET no longer match the client the token was minted against',
  312. ]
  313. }
  314. if (status === 403) {
  315. return [
  316. 'this token was minted before the consent screen carried gmail.send; re-mint it with `pnpm inbox:auth`',
  317. 'the OAuth consent screen does not list gmail.send',
  318. 'the OAuth consent screen is still in Testing',
  319. 'the mailbox is suspended',
  320. 'the Gmail API is not enabled on the Cloud project',
  321. ]
  322. }
  323. if (status === 400) {
  324. return [
  325. ...(sentThreadId
  326. ? ['the thread id belongs to a different mailbox than the one sending (that should be impossible here; if you see it, it is a bug in decideReply)']
  327. : []),
  328. 'the message Gmail was handed is malformed',
  329. 'the recipient address was not accepted',
  330. ]
  331. }
  332. if (status === 429) {
  333. return [
  334. "Gmail's per-user send quota for the day (2,000 on Workspace)",
  335. 'a short-term rate limit; the same send may succeed in a minute',
  336. ]
  337. }
  338. return [
  339. 'Gmail returned an error status',
  340. 'the request did not reach Google (network, DNS, or the Worker being torn down mid-flight)',
  341. ]
  342. }
  343. /**
  344. * Send it.
  345. *
  346. * Returns a result for every outcome, including the ones that are normally
  347. * exceptions, because the caller writes an audit row either way and a row that
  348. * says nothing is worse than a row that says "failed, and here is what Gmail
  349. * said".
  350. */
  351. export async function sendMessage(
  352. mailbox: string,
  353. cred: GmailCredential,
  354. msg: OutgoingMessage,
  355. fetchImpl: typeof fetch = fetch,
  356. ): Promise<SendResult> {
  357. let accessToken: string
  358. try {
  359. ;({ accessToken } = await accessTokenForCredential(mailbox, cred, fetchImpl))
  360. } catch (err) {
  361. // A token error before the send means nothing left the building. Say that,
  362. // with the message GmailAuthError already writes; it names its own causes.
  363. return {
  364. ok: false,
  365. status: null,
  366. error: errorMessage(err),
  367. candidates: [
  368. 'the credential for this mailbox could not be exchanged for an access token',
  369. 'the mailbox has no refresh token set, or the client pair does not match it',
  370. 'Google refused the grant; see the message above, which names its own candidates',
  371. ],
  372. }
  373. }
  374. const body: { raw: string; threadId?: string } = { raw: rawToB64Url(buildRawMessage(msg)) }
  375. if (msg.threadId) body.threadId = msg.threadId
  376. try {
  377. const res = await fetchImpl(SEND_URL, {
  378. method: 'POST',
  379. headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' },
  380. body: JSON.stringify(body),
  381. })
  382. const parsed = (await res.json().catch(() => ({}))) as {
  383. id?: string
  384. threadId?: string
  385. error?: { message?: string }
  386. }
  387. if (!res.ok) {
  388. return {
  389. ok: false,
  390. status: res.status,
  391. error: parsed.error?.message
  392. ? `Gmail refused the send (HTTP ${res.status}): ${parsed.error.message}`
  393. : `Gmail refused the send (HTTP ${res.status}).`,
  394. candidates: candidatesFor(res.status, Boolean(msg.threadId)),
  395. }
  396. }
  397. if (!parsed.id) {
  398. // A 200 with no id is not a success we can record. Treating it as one
  399. // would put "sent" beside a message nobody can find in Gmail.
  400. return {
  401. ok: false,
  402. status: res.status,
  403. error: 'Gmail returned 200 with no message id, so the send cannot be confirmed.',
  404. candidates: [
  405. 'the message may have been sent and only the response was lost, so check the Sent folder before resending',
  406. 'an unexpected response shape from the Gmail API',
  407. ],
  408. }
  409. }
  410. return { ok: true, id: parsed.id, threadId: parsed.threadId ?? '' }
  411. } catch (err) {
  412. return {
  413. ok: false,
  414. status: null,
  415. error: errorMessage(err),
  416. candidates: candidatesFor(null, Boolean(msg.threadId)),
  417. }
  418. }
  419. }