send.test.ts
Language TypeScript15.9 KB363 lines
/*** Sending a reply: the only outbound write this board makes.** Nothing here touches the network: every test injects a fake fetch. The first* real send is made by hand; a build that could send a real email as a side* effect of a test run is not a build anyone should trust with a client's inbox.** What is worth testing is not "does it POST"; it is the handful of things* that would produce a technically-successful send that is wrong: a subject* that stacks "Re: Re: Re:", a thread id sent to the wrong mailbox, an accented* body that arrives as mojibake, and a failure that names one cause instead of* listing the candidates.*/import { describe, expect, it } from 'vitest'import {buildRawMessage,encodeHeaderWord,mimeParam,rawToB64Url,replySubject,safeMimeType,sendMessage,textToHtml,type OutgoingMessage,} from '../src/index.js'const CRED = { clientId: 'id', clientSecret: 'sec', refreshToken: 'rt' }const json = (body: unknown, status = 200) =>new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })/** Token call first, then the send call. Both faked. */const fakeFetch = (sendResponse: Response, calls: { url: string; body: string }[] = []) =>(async (url: string | URL | Request, init?: RequestInit) => {const u = String(url)calls.push({ url: u, body: String(init?.body ?? '') })if (u.includes('oauth2.googleapis.com/token')) {return json({ access_token: 'tok', expires_in: 3599, scope: 'https://www.googleapis.com/auth/gmail.send' })}return sendResponse}) as unknown as typeof fetchconst reply = (over: Partial<OutgoingMessage> = {}): OutgoingMessage => ({from: 'hello@meridiandisplay.example',to: 'client@example.com',cc: null,subject: 'Re: Banner proof',text: 'Proof attached in Gmail.',signatureHtml: null,inReplyTo: '<abc@mail.example.com>',references: '<abc@mail.example.com>',threadId: null,attachments: [],...over,})/** A file part, with bytes that are not text so a text path would corrupt it. */const bin = (name = 'proof.pdf', bytes = new Uint8Array([0, 1, 2, 253, 254, 255])) => ({name,mimeType: 'application/pdf',bytes,})/*** Is there a header line of this name anywhere in the message?** A line test, not a substring test. `Cc: a@b.com Bcc: v@x.com` contains the* text "Bcc:" and is harmless; the injection is what makes it a header, which* means starting a line. An `expect(raw).not.toContain('Bcc:')` passes only* until someone folds a value, and then it fails on a message that is fine.*/const hasHeader = (raw: string, name: string): boolean =>raw.split('\r\n').some((l) => new RegExp(`^${name}:`, 'i').test(l))/** The named boundary's parts, headers and raw body kept separate. */const sections = (raw: string, boundary: string): { head: string; body: string }[] =>raw.split(`--${boundary}`).slice(1, -1).map((p) => {const i = p.indexOf('\r\n\r\n')return { head: p.slice(0, i), body: p.slice(i + 4) }})const boundaryOf = (raw: string, type: string): string =>new RegExp(`Content-Type: ${type}; boundary="([^"]+)"`).exec(raw)![1]!/** Pull one base64 body part out of the raw message and decode it. */const parts = (raw: string): string[] => {const m = /boundary="([^"]+)"/.exec(raw)if (!m) return []return raw.split(`--${m[1]!}`).slice(1, -1).map((p) => {const b64 = p.split('\r\n\r\n').slice(1).join('\r\n\r\n').trim().replace(/\r\n/g, '')return new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)))})}describe('replySubject', () => {it('adds Re: once', () => {expect(replySubject('Banner proof')).toBe('Re: Banner proof')})it('is idempotent, whatever the case or spacing', () => {// Three replies deep, a naive prefix gives "Re: Re: Re: Banner proof" and// the client sees the board talking to itself.for (const s of ['Re: Banner proof', 'RE: Banner proof', 're:Banner proof', ' Re : x']) {expect(replySubject(s)).toBe(replySubject(replySubject(s)))}expect(replySubject('Re: Banner proof')).toBe('Re: Banner proof')})it('names an empty subject rather than sending a bare "Re:"', () => {expect(replySubject(' ')).toBe('Re: (no subject)')})})describe('encodeHeaderWord', () => {it('leaves printable ASCII alone', () => {// Encoding everything would make every subject unreadable in the raw source// for no gain.expect(encodeHeaderWord('Banner proof #4')).toBe('Banner proof #4')})it('RFC 2047-encodes anything that is not', () => {for (const s of ['Café résumé', 'Año nuevo 🎉', '设计稿']) {const out = encodeHeaderWord(s)expect(out).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/)const b64 = out.slice('=?UTF-8?B?'.length, -2)expect(new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)))).toBe(s)}})it('cannot carry a newline into the header block', () => {// Header injection: a CR or LF in a value turns one header into two, and// the value that reaches here came off a message a stranger sent us.const out = encodeHeaderWord('Subject\r\nBcc: attacker@example.com')expect(out).not.toContain('\r')expect(out).not.toContain('\n')})})describe('buildRawMessage', () => {it('carries In-Reply-To and References when given, and omits them when null', () => {expect(buildRawMessage(reply())).toContain('In-Reply-To: <abc@mail.example.com>')const bare = buildRawMessage(reply({ inReplyTo: null, references: null }))expect(bare).not.toContain('In-Reply-To:')expect(bare).not.toContain('References:')})it('is multipart/alternative with both a text and an HTML part', () => {const raw = buildRawMessage(reply())expect(raw).toContain('Content-Type: multipart/alternative; boundary="')expect(raw).toContain('Content-Type: text/plain; charset=UTF-8')expect(raw).toContain('Content-Type: text/html; charset=UTF-8')expect(parts(raw)).toHaveLength(2)})it('round-trips a non-ASCII body byte for byte', () => {// The whole reason both encoders go through TextEncoder: btoa on a string// containing 'é' throws, and a "fix" that drops to latin-1 silently sends// mojibake to the client instead.const text = 'Hola Begoña, el diseño está listo 🎉 设计稿'const raw = buildRawMessage(reply({ text }))expect(parts(raw)[0]).toBe(text)const back = new TextDecoder().decode(Uint8Array.from(atob(rawToB64Url(raw).replace(/-/g, '+').replace(/_/g, '/') + '=='.slice(0, (4 - (rawToB64Url(raw).length % 4)) % 4)), (c) => c.charCodeAt(0)),)expect(back).toBe(raw)})it('puts the signature in BOTH parts when given, and neither when not', () => {const sig = '<p><b>Meridian Display</b> · Northside office</p>'const withSig = buildRawMessage(reply({ signatureHtml: sig }))const [plain, html] = parts(withSig)expect(html).toContain(sig)// The plain part gets the same signature as text, from the same converter// the reader sees, not a second rendering to keep in step by hand.expect(plain).toContain('Meridian Display')expect(plain).toContain('\r\n-- \r\n')const [p2, h2] = parts(buildRawMessage(reply()))expect(p2).not.toContain('--')expect(h2).not.toContain('Meridian Display')})it('uses CRLF throughout, which is what RFC 2822 requires', () => {const raw = buildRawMessage(reply())expect(raw).toContain('\r\n')expect(raw.replace(/\r\n/g, '')).not.toContain('\n')})it('writes a Cc header only when there is one', () => {expect(buildRawMessage(reply({ cc: 'boss@example.com' }))).toContain('Cc: boss@example.com')// null and '' must both mean "no Cc": an empty Cc header is legal and// reads, in a Sent folder, as a Cc sent to nobody.expect(buildRawMessage(reply({ cc: null }))).not.toContain('Cc:')expect(buildRawMessage(reply({ cc: '' }))).not.toContain('Cc:')})it('cannot be made to inject a header through the Cc', () => {const raw = buildRawMessage(reply({ cc: 'a@b.com\r\nBcc: victim@elsewhere.com' }))// The CRLF is flattened to a space, so the whole thing stays one Cc value// and no Bcc header exists. (The Worker's parseRecipients refuses this// input outright; the encoder is the second line of defence, not the only.)expect(hasHeader(raw, 'Bcc')).toBe(false)expect(raw).toContain('Cc: a@b.com Bcc: victim@elsewhere.com')})})describe('buildRawMessage with attachments', () => {it('is byte-identical to the old shape when there are none', () => {// The reason `mixed` is conditional. A mixed wrapper around a single// alternative part renders the same, but this is the one output nobody can// inspect after the fact, so nothing changes for a message that asked for// no change.const raw = buildRawMessage(reply())expect(raw).toContain('Content-Type: multipart/alternative; boundary="')expect(raw).not.toContain('multipart/mixed')})it('wraps the alternative in a mixed part and adds one part per file', () => {const raw = buildRawMessage(reply({ attachments: [bin(), bin('quote.pdf')] }))const mixed = boundaryOf(raw, 'multipart/mixed')const secs = sections(raw, mixed)expect(secs).toHaveLength(3)expect(secs[0]!.head).toContain('multipart/alternative')expect(secs[1]!.head).toContain('Content-Disposition: attachment; filename="proof.pdf"')expect(secs[2]!.head).toContain('filename="quote.pdf"')// The text and HTML parts are still both there, nested inside the first.const alt = boundaryOf(raw, 'multipart/alternative')expect(sections(raw, alt)).toHaveLength(2)})it('round-trips the file bytes exactly, including ones no text codec survives', () => {// 0xFD 0xFE 0xFF is not valid UTF-8. Through a text path each becomes// U+FFFD and the recipient gets a file that opens as garbage, which is// why there is a separate bytes encoder at all.const bytes = new Uint8Array([0, 1, 2, 253, 254, 255, 10, 13])const raw = buildRawMessage(reply({ attachments: [bin('x.bin', bytes)] }))const mixed = boundaryOf(raw, 'multipart/mixed')const body = sections(raw, mixed)[1]!.body.trim().replace(/\r\n/g, '')expect([...Uint8Array.from(atob(body), (c) => c.charCodeAt(0))]).toEqual([...bytes])})it('wraps the base64 at 76 columns like every other part', () => {const raw = buildRawMessage(reply({ attachments: [bin('big.bin', new Uint8Array(1000))] }))const mixed = boundaryOf(raw, 'multipart/mixed')const lines = sections(raw, mixed)[1]!.body.trim().split('\r\n')expect(lines.length).toBeGreaterThan(1)expect(Math.max(...lines.map((l) => l.length))).toBeLessThanOrEqual(76)})it('a filename cannot end the parameter or forge a header', () => {// The filename is chosen by whoever picked the file and goes straight into// Content-Disposition. A quote closes the parameter; a CRLF starts a header.const raw = buildRawMessage(reply({ attachments: [bin('a".pdf\r\nBcc: v@x.com')] }))expect(hasHeader(raw, 'Bcc')).toBe(false)expect(raw).toContain('filename="a.pdfBcc: v@x.com"')})it('RFC 2047-encodes a non-ASCII filename rather than mangling it', () => {const raw = buildRawMessage(reply({ attachments: [bin('diseño.pdf')] }))expect(raw).toContain('filename="=?UTF-8?B?')expect(raw).not.toContain('diseño.pdf')})it('refuses to pass through a mime type that is not one', () => {expect(safeMimeType('application/pdf')).toBe('application/pdf')expect(safeMimeType('text/html; charset=utf-8')).toBe('text/html')expect(safeMimeType('nonsense')).toBe('application/octet-stream')expect(safeMimeType('a/b\r\nX: y')).toBe('application/octet-stream')expect(safeMimeType('')).toBe('application/octet-stream')})it('never lets a blank filename produce a nameless part', () => {expect(mimeParam(' ')).toBe('attachment')})})describe('textToHtml', () => {it('escapes markup rather than letting typed text become tags', () => {expect(textToHtml('<b>hi</b> & "x"')).toContain('<b>hi</b> & "x"')})it('turns newlines into breaks', () => {expect(textToHtml('a\nb')).toContain('a<br>b')})})describe('sendMessage', () => {it('POSTs raw to the send endpoint and reports the ids back', async () => {const calls: { url: string; body: string }[] = []const res = await sendMessage('hello@meridiandisplay.example', CRED, reply(), fakeFetch(json({ id: 'm1', threadId: 't1' }), calls))expect(res).toEqual({ ok: true, id: 'm1', threadId: 't1' })const send = calls.find((c) => c.url.includes('/messages/send'))!expect(send.url).toBe('https://gmail.googleapis.com/gmail/v1/users/me/messages/send')expect(JSON.parse(send.body).raw).toBeTruthy()})it('includes threadId only when one was given', async () => {// Gmail thread ids are PER-MAILBOX. Sending hello@'s id through accounts@'s// token is rejected outright, so decideReply omits it when the sending// mailbox differs, and this pins that the encoder honours the omission.const a: { url: string; body: string }[] = []await sendMessage('hello@meridiandisplay.example', CRED, reply(), fakeFetch(json({ id: 'm', threadId: 't' }), a))expect(JSON.parse(a.find((c) => c.url.includes('send'))!.body).threadId).toBeUndefined()const b: { url: string; body: string }[] = []await sendMessage('hello@meridiandisplay.example', CRED, reply({ threadId: 'thr1' }), fakeFetch(json({ id: 'm', threadId: 'thr1' }), b))expect(JSON.parse(b.find((c) => c.url.includes('send'))!.body).threadId).toBe('thr1')})it('names several candidates for a 403 and picks none of them', async () => {// Rule 1. A 403 here has at least five plausible causes and the// expensive one ("the token is fine, re-mint it" vs "consent screen is in// Testing") is not distinguishable from the response.const res = await sendMessage('hello@meridiandisplay.example', CRED, reply(), fakeFetch(json({ error: { message: 'Request had insufficient authentication scopes.' } }, 403)))expect(res.ok).toBe(false)if (res.ok) throw new Error('unreachable')expect(res.status).toBe(403)expect(res.candidates.length).toBeGreaterThanOrEqual(3)expect(res.error).toContain('403')// No candidate is marked as the likely one.expect(res.candidates.join(' ')).not.toMatch(/most likely|probably/i)})it('never throws on a network failure; it returns the failure', async () => {// Same contract as readMailbox, for a sharper reason: the caller has an// audit row to finish writing, and a thrown error is a send whose outcome// nobody recorded.const f = (async () => {throw new Error('connection reset')}) as unknown as typeof fetchconst res = await sendMessage('hello@meridiandisplay.example', CRED, reply(), f)expect(res.ok).toBe(false)if (res.ok) throw new Error('unreachable')expect(res.error).toContain('connection reset')expect(res.status).toBeNull()})it('treats a 200 with no message id as a failure, not a success', async () => {// "sent" beside a message nobody can find in Gmail is worse than an error.const res = await sendMessage('hello@meridiandisplay.example', CRED, reply(), fakeFetch(json({})))expect(res.ok).toBe(false)if (res.ok) throw new Error('unreachable')expect(res.candidates.join(' ')).toMatch(/Sent folder/i)})it('reports a token failure without ever reaching the send endpoint', async () => {const calls: { url: string; body: string }[] = []const f = (async (url: string | URL | Request) => {calls.push({ url: String(url), body: '' })return json({ error: 'invalid_grant' }, 400)}) as unknown as typeof fetchconst res = await sendMessage('hello@meridiandisplay.example', CRED, reply(), f)expect(res.ok).toBe(false)expect(calls.some((c) => c.url.includes('/messages/send'))).toBe(false)})})