send.ts
Language TypeScript16.7 KB447 lines
/*** Sending mail: the only outbound write this board makes (rule 12).** Everything else here reads: five sources, seven mailboxes, no write-back.* Sending exists because most addresses are role mailboxes that change hands,* and the board is where the team already sees them together.** A send cannot be taken back, so the shape of this module is built around* that one fact:** Nothing here decides who to write to. `decideReply` and `decideNew` in the* Worker settle every recipient; this module is handed a finished* `OutgoingMessage` and encodes it. For a reply that recipient still comes* off the original message on the server. For a new message it necessarily* comes from the form, because there is no* original to read it from; what guards that is validation, the count cap* and the confirmation step in the Worker, not this file.** `sendMessage` never throws. Same contract as `readMailbox`, for the same* reason: the caller has an audit record to write either way, and a thrown* error halfway through that is a send whose outcome nobody recorded.** A failure names candidates and picks none (rule 1). `SendResult.candidates`* is a list, in a structured field, so the page can print all of them.** Nothing that identifies the person behind a shared mailbox goes into the* outgoing mail, not even an `X-` header: every mail client shows raw* headers under "Show original". Who sent what is recorded where a recipient* cannot reach it: `inbox:sent:*` and the mailbox's own Sent folder.** Attachments: the caps live in config/inbox.ts (they are policy, and the arithmetic behind* them is written out there); the encoder takes bytes it is given and does two* things carefully:** A filename is not a header value. It arrives from whoever picked the file,* it goes into `Content-Disposition`, and CR, LF and `"` in it would end the* parameter and start something else. `mimeParam` strips those and RFC* 2047-encodes anything non-ASCII.** A declared MIME type is not trusted either. It is a header too, so it goes* through the same sanitiser, and anything that does not look like* `type/subtype` becomes application/octet-stream rather than being passed* through.** What Gmail rejects is Gmail's to reject. There is no extension blocklist* here: Gmail refuses .exe and friends itself, and its refusal is reported with* candidates (rule 3: the source's own semantics beat a hand-maintained list* that lags it).** Runtime-neutral: no `node:` imports, because the Worker bundles this (rule 7).*/import {accessTokenForCredential,b64urlText,GmailAuthError,type GmailCredential,} from './oauth.js'import { htmlToText } from './body.js'import { errorMessage } from '@meridian/shared'const SEND_URL = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send'/*** One file going out, already read into memory by the Worker.** `bytes` and not a base64 string: the encoder needs to know the real length* to check nothing, but the caller does, and a string would make the size* checks in the Worker apply to the encoded form rather than the file, which* is the kind of off-by-a-third that makes a cap mean something other than* what it says.*/export interface OutgoingAttachment {name: stringmimeType: stringbytes: Uint8Array}export interface OutgoingMessage {/** The sending mailbox's own address. Each token sends only as itself. */from: string/*** The recipients, one header's worth, already resolved by the caller:* from the original for a reply, from the validated form for a new message.*/to: string/*** Cc, or null for none. Null and '' must not both be passed for "none":* the encoder omits the header when this is null, and an empty Cc header is* legal but reads, in a Sent folder and in an audit row, as a Cc that was* sent to nobody rather than as no Cc at all.*/cc: string | null/** Already through `replySubject` for a reply; verbatim for a new message. */subject: string/** What the person typed. Plain text; the compose box is a textarea. */text: string/** That mailbox's signature, or null when none is set or the store failed. */signatureHtml: string | null/** The original's Message-ID, or null when it had none. */inReplyTo: string | null/** Original References plus the original's Message-ID, or null. */references: string | null/*** Gmail's thread id, only when replying from the mailbox the message* arrived in. Thread ids are per-mailbox; sending one mailbox's id through* another's token is rejected. The caller enforces this, and a test pins it.*/threadId: string | null/*** Extra headers, if a caller ever needs one. Empty in production: see the* file header on why no attribution header is sent. The parameter stays* because the encoder should not be the thing that has an opinion; nothing* in this repo passes it.*/extraHeaders?: Record<string, string>/*** Files. Empty array means none; the encoder then produces a plain* multipart/alternative message with no mixed wrapper.*/attachments: OutgoingAttachment[]}/** `Re: ` once, never twice. `RE:`, `re:` and leading space all count as one. */export function replySubject(subject: string): string {const s = subject.trim()if (!s) return 'Re: (no subject)'return /^re\s*:/i.test(s) ? s : `Re: ${s}`}/** Printable ASCII only, per RFC 2822: space through tilde. */const isAsciiPrintable = (s: string): boolean => /^[\x20-\x7e]*$/.test(s)/*** A header value that cannot inject a header.** CR and LF are what turn one header into two, and this is the one place in* the repo where a stray newline in a string would let a value forge a `Bcc:`.* The subject comes from a message someone else sent us, so it is not ours to* trust however unlikely a folded value looks.*/const oneLine = (s: string): string => s.replace(/[\r\n]+/g, ' ').trim()/*** RFC 2047 for a header that is not pure ASCII: an accented client name, an* emoji in a subject line. ASCII passes through untouched, because encoding it* anyway makes every subject unreadable in the raw source for no gain.*/export function encodeHeaderWord(s: string): string {const v = oneLine(s)return isAsciiPrintable(v) ? v : `=?UTF-8?B?${b64Std(v)}?=`}/** UTF-8 -> standard base64 (padded). btoa alone throws above U+00FF. */const b64Std = (s: string): string => {const bytes = new TextEncoder().encode(s)let bin = ''for (const b of bytes) bin += String.fromCharCode(b)return btoa(bin)}/** Base64 wrapped at 76 columns, which is what a MIME body part wants. */const b64Body = (s: string): string => (b64Std(s).match(/.{1,76}/g) ?? ['']).join('\r\n')/*** The same, for bytes that are not text.** Chunked through `String.fromCharCode`: the spread form blows the call stack* somewhere past a hundred thousand arguments, which for a 3 MB attachment is* not an edge case but every time. (The HTML-cap test in body.test.ts hit the* same wall from the other direction and chunks for the same reason.)*/const b64Bytes = (bytes: Uint8Array): string => {let bin = ''for (let i = 0; i < bytes.length; i += 8192) {bin += String.fromCharCode(...bytes.subarray(i, i + 8192))}return (btoa(bin).match(/.{1,76}/g) ?? ['']).join('\r\n')}/*** A quoted MIME parameter value (a filename, mostly).** CR, LF and `"` all end the parameter early, and a filename is chosen by* whoever picked the file: on a reply-with-attachment that is a colleague, but* on any path where a name could come from a message we received it is a* stranger. Backslash goes too, because a trailing one escapes the closing* quote. Non-ASCII is RFC 2047-encoded rather than dropped, so an accented* filename survives instead of arriving as `_____.pdf`.*/export function mimeParam(value: string): string {const clean = value.replace(/[\r\n"\\]/g, '').trim()return encodeHeaderWord(clean || 'attachment')}/*** A mime type we are willing to write into a header.** Anything that is not plainly `type/subtype` becomes octet-stream. The type* is a hint to the recipient's client about a file it already has; a* pass-through of whatever a browser or a sender put in the field is a header* value nobody checked.*/export function safeMimeType(t: string): string {const v = oneLine(t).toLowerCase().split(';')[0]!.trim()return /^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/.test(v) ? v : 'application/octet-stream'}/*** Typed text -> the HTML part.** `<br>` rather than `white-space:pre-wrap`. Both were specified; using both* double-spaces every line, and of the two `<br>` is the one Outlook renders.*/export function textToHtml(text: string): string {const escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')return `<div>${escaped.replace(/\r\n|\r|\n/g, '<br>')}</div>`}/** A MIME boundary that cannot collide with the body. */const boundary = (): string => {const b = new Uint8Array(16)crypto.getRandomValues(b)return `----meridian-${[...b].map((x) => x.toString(16).padStart(2, '0')).join('')}`}/*** The whole message, RFC 2822, CRLF throughout.** multipart/alternative with both parts base64-encoded. Base64 rather than* quoted-printable is the reason this file needs no QP encoder: it makes 8-bit* UTF-8 and long lines legal in one step, and every mail client has read it* since 1996.*/export function buildRawMessage(msg: OutgoingMessage, date: Date = new Date()): string {const alt = boundary()const files = msg.attachments ?? []// With files the message grows a layer, and only then: multipart/mixed// wrapping the multipart/alternative part. A mixed wrapper around a single// alternative part with no files is legal and renders identically, but it// would add bytes to every reply for no reason, and this is the one output in// the repo nobody can inspect after the fact.const mixed = files.length ? boundary() : nullconst headers: string[] = [`From: ${oneLine(msg.from)}`,`To: ${oneLine(msg.to)}`,...(msg.cc ? [`Cc: ${oneLine(msg.cc)}`] : []),`Subject: ${encodeHeaderWord(msg.subject)}`,// toUTCString ends in "GMT"; RFC 2822 wants a numeric offset.`Date: ${date.toUTCString().replace(/GMT$/, '+0000')}`,'MIME-Version: 1.0',]if (msg.inReplyTo) headers.push(`In-Reply-To: ${oneLine(msg.inReplyTo)}`)if (msg.references) headers.push(`References: ${oneLine(msg.references)}`)for (const [k, v] of Object.entries(msg.extraHeaders ?? {})) {headers.push(`${oneLine(k)}: ${oneLine(v)}`)}headers.push(mixed? `Content-Type: multipart/mixed; boundary="${mixed}"`: `Content-Type: multipart/alternative; boundary="${alt}"`,)// The signature's text form comes from the same htmlToText the reader sees,// so the plain-text part is not a second rendering to keep in step. "-- " is// the RFC 3676 signature separator; the trailing space is part of it.const sigText = msg.signatureHtml ? `\r\n\r\n-- \r\n${htmlToText(msg.signatureHtml)}` : ''const plain = `${msg.text}${sigText}`const html = `${textToHtml(msg.text)}${msg.signatureHtml ? `<br><br>${msg.signatureHtml}` : ''}`const bodyParts = [`--${alt}`,'Content-Type: text/plain; charset=UTF-8','Content-Transfer-Encoding: base64','',b64Body(plain),`--${alt}`,'Content-Type: text/html; charset=UTF-8','Content-Transfer-Encoding: base64','',b64Body(html),`--${alt}--`,]if (!mixed) return [headers.join('\r\n'), '', ...bodyParts, ''].join('\r\n')const fileParts = files.flatMap((f) => {const name = mimeParam(f.name)return [`--${mixed}`,`Content-Type: ${safeMimeType(f.mimeType)}; name="${name}"`,`Content-Disposition: attachment; filename="${name}"`,'Content-Transfer-Encoding: base64','',b64Bytes(f.bytes),]})return [headers.join('\r\n'),'',`--${mixed}`,`Content-Type: multipart/alternative; boundary="${alt}"`,'',...bodyParts,...fileParts,`--${mixed}--`,'',].join('\r\n')}/** Gmail's `raw` field is base64url of the whole RFC 2822 message. */export const rawToB64Url = (raw: string): string => b64urlText(raw)export type SendResult =| { ok: true; id: string; threadId: string }| { ok: false; status: number | null; error: string; candidates: string[] }/*** What could produce this status, all of it, none of it chosen.** Every entry is something a person can go and check. The list is the answer;* a ranked guess would be the thing rule 1 exists to prevent.*/function candidatesFor(status: number | null, sentThreadId: boolean): string[] {if (status === 401) {return ['the access token was rejected at send time','the refresh token has been revoked (Google Account → Security → Third-party access)','GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET no longer match the client the token was minted against',]}if (status === 403) {return ['this token was minted before the consent screen carried gmail.send; re-mint it with `pnpm inbox:auth`','the OAuth consent screen does not list gmail.send','the OAuth consent screen is still in Testing','the mailbox is suspended','the Gmail API is not enabled on the Cloud project',]}if (status === 400) {return [...(sentThreadId? ['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)']: []),'the message Gmail was handed is malformed','the recipient address was not accepted',]}if (status === 429) {return ["Gmail's per-user send quota for the day (2,000 on Workspace)",'a short-term rate limit; the same send may succeed in a minute',]}return ['Gmail returned an error status','the request did not reach Google (network, DNS, or the Worker being torn down mid-flight)',]}/*** Send it.** Returns a result for every outcome, including the ones that are normally* exceptions, because the caller writes an audit row either way and a row that* says nothing is worse than a row that says "failed, and here is what Gmail* said".*/export async function sendMessage(mailbox: string,cred: GmailCredential,msg: OutgoingMessage,fetchImpl: typeof fetch = fetch,): Promise<SendResult> {let accessToken: stringtry {;({ accessToken } = await accessTokenForCredential(mailbox, cred, fetchImpl))} catch (err) {// A token error before the send means nothing left the building. Say that,// with the message GmailAuthError already writes; it names its own causes.return {ok: false,status: null,error: errorMessage(err),candidates: ['the credential for this mailbox could not be exchanged for an access token','the mailbox has no refresh token set, or the client pair does not match it','Google refused the grant; see the message above, which names its own candidates',],}}const body: { raw: string; threadId?: string } = { raw: rawToB64Url(buildRawMessage(msg)) }if (msg.threadId) body.threadId = msg.threadIdtry {const res = await fetchImpl(SEND_URL, {method: 'POST',headers: { authorization: `Bearer ${accessToken}`, 'content-type': 'application/json' },body: JSON.stringify(body),})const parsed = (await res.json().catch(() => ({}))) as {id?: stringthreadId?: stringerror?: { message?: string }}if (!res.ok) {return {ok: false,status: res.status,error: parsed.error?.message? `Gmail refused the send (HTTP ${res.status}): ${parsed.error.message}`: `Gmail refused the send (HTTP ${res.status}).`,candidates: candidatesFor(res.status, Boolean(msg.threadId)),}}if (!parsed.id) {// A 200 with no id is not a success we can record. Treating it as one// would put "sent" beside a message nobody can find in Gmail.return {ok: false,status: res.status,error: 'Gmail returned 200 with no message id, so the send cannot be confirmed.',candidates: ['the message may have been sent and only the response was lost, so check the Sent folder before resending','an unexpected response shape from the Gmail API',],}}return { ok: true, id: parsed.id, threadId: parsed.threadId ?? '' }} catch (err) {return {ok: false,status: null,error: errorMessage(err),candidates: candidatesFor(null, Boolean(msg.threadId)),}}}