inbox-send.test.ts
Language TypeScript23.1 KB514 lines
/*** The reply surfaces: who may send, what each state renders as, and what the* pages must never contain.** The bug this file guards against is the one that would be easiest to write:* reusing INBOX_READERS as the send check. The whole roster may read every* mailbox, and none of that is permission to write to a client as somebody* else. If `sendableMailboxes` ever starts returning what* `readableMailboxes` returns, the first test below fails.*/import { describe, expect, it } from 'vitest'import {INBOX_CSS,composeForm,confirmPage,fileSize,newMessageForm,newMessagePage,offerable,sendStates,sentPage,signaturesPage,type InboxData,type MailboxSendState,type SendRecord,type SignatureStore,} from '../src/inbox-view.js'import {ATTACHMENT_LIMITS,INBOX_MAILBOXES,INBOX_SENDERS,RECIPIENT_CAP,canSendFrom,readableMailboxes,sendableMailboxes,} from '../../../config/inbox.js'import { viewerFor } from '../src/viewer.js'import { THEME_CSS } from '../src/theme.js'import type { MessageBody } from '@meridian/gmail-client'const viewer = (email: string) => viewerFor(email)const SIGS_OK: SignatureStore = {ok: true,map: { 'hello@meridiandisplay.example': { html: '<p>Sam</p>', by: 'Sam Whitaker', at: '2026-09-04T00:00:00.000Z' } },}const SIGS_DEAD: SignatureStore = { ok: false, error: 'inbox:signatures holds something that is not an object' }/** One mailbox read per state we want to exercise. */const DATA: InboxData = {syncedAt: '2026-09-04T12:00:00.000Z',mailboxes: [{ mailbox: 'hello@meridiandisplay.example', ok: true, error: null, messages: [], canSend: true },{ mailbox: 'orderdesk@meridiandisplay.example', ok: true, error: null, messages: [], canSend: false },{ mailbox: 'studio@meridiandisplay.example', ok: true, error: null, messages: [], canSend: null },{ mailbox: 'accounts@meridiandisplay.example', ok: false, error: 'invalid_grant', messages: [], canSend: null },{ mailbox: 'help@meridiandisplay.example', ok: true, error: null, messages: [], canSend: true },{ mailbox: 'service@meridiandisplay.example', ok: true, error: null, messages: [], canSend: true },{ mailbox: 'exec@meridiandisplay.example', ok: true, error: null, messages: [], canSend: true },],}const ORIG: MessageBody = {id: 'm1',mailbox: 'hello@meridiandisplay.example',sender: 'client@example.com',senderName: 'A Client',subject: 'Banner proof',date: '2026-09-04T10:00:00.000Z',to: 'hello@meridiandisplay.example',text: 'Where is my proof?',kind: 'plain',truncated: false,html: null,htmlTruncated: false,unread: true,attachments: [],threadId: 't1',messageId: '<abc@example.com>',references: null,replyTo: '',cc: 'accounts@example.com',}describe('sending is a separate grant from reading', () => {it('a viewer who may read everything and holds no send line may send nothing', () => {// Ines reads all seven (INBOX_READERS) and has no INBOX_SENDERS line: she// does not work client mail.const ines = viewer('projects@meridiandisplay.example')expect(readableMailboxes(ines)).toHaveLength(INBOX_MAILBOXES.length)expect(sendableMailboxes(ines)).toEqual([])for (const m of INBOX_MAILBOXES) expect(canSendFrom(ines, m.email)).toBe(false)})it('honours Sam’s seven lines exactly', () => {// Four of the five full grants are role mailboxes, so these assertions// outlive whoever holds those desks today: anyone who can sign in as one of// them can write to a client as any of the seven. Narrowing that is a// decision, not a cleanup.for (const full of ['hello@meridiandisplay.example','frontdesk@meridiandisplay.example','accounts@meridiandisplay.example','help@meridiandisplay.example','service@meridiandisplay.example',]) {expect(sendableMailboxes(viewer(full)), full).toHaveLength(INBOX_MAILBOXES.length)}expect(sendableMailboxes(viewer('studio@meridiandisplay.example'))).toEqual(['studio@meridiandisplay.example'])expect(sendableMailboxes(viewer('orderdesk@meridiandisplay.example'))).toEqual(['orderdesk@meridiandisplay.example'])expect(sendableMailboxes(viewer('nora.l@meridiandisplay.example'))).toEqual([])})it('a send grant is useless without a read grant, so every sender has both', () => {// The inert-grant trap: a send grant without a read grant does nothing,// because /inbox refuses the whole block before a Reply button is ever// rendered, while looking present in config. Both lists get the line, or// neither should. This fails if a future send grant is added to only one of them.for (const sender of Object.keys(INBOX_SENDERS)) {expect(readableMailboxes(viewer(sender)).length, sender).toBeGreaterThan(0)}})it('cannot be widened by a typo: a grant is intersected with the real list', () => {// Same guard readableMailboxes has: a misspelt mailbox in a grant is// dropped rather than inventing one.const marco = sendableMailboxes(viewer('studio@meridiandisplay.example'))for (const m of marco) expect(INBOX_MAILBOXES.map((x) => x.email)).toContain(m)})it('an unidentified viewer sends nothing', () => {expect(sendableMailboxes({ ...viewer('hello@meridiandisplay.example'), email: null })).toEqual([])})})describe('sendStates', () => {const all = INBOX_MAILBOXES.map((m) => m.email)it('produces five distinct states from one fixture, and no two are the same', () => {const st = sendStates(DATA, INBOX_MAILBOXES, all.filter((e) => e !== 'exec@meridiandisplay.example'), SIGS_OK)const by = (e: string) => st.find((x) => x.email === e)!expect(by('hello@meridiandisplay.example').state).toBe('sendable')expect(by('orderdesk@meridiandisplay.example').state).toBe('readonly-token')expect(by('studio@meridiandisplay.example').state).toBe('scope-unknown')expect(by('accounts@meridiandisplay.example').state).toBe('failed')expect(by('exec@meridiandisplay.example').state).toBe('not-granted')expect(new Set(st.map((x) => x.state)).size).toBe(5)})it('keeps read-only, unknown and failed apart; they need opposite fixes', () => {// read-only -> re-mint the token// unknown -> nothing to do, try it// failed -> the credential is broken// Collapsing any pair sends someone to the wrong page of the runbook.const st = sendStates(DATA, INBOX_MAILBOXES, all, SIGS_OK)const ro = st.find((x) => x.state === 'readonly-token')!const unk = st.find((x) => x.state === 'scope-unknown')!const failed = st.find((x) => x.state === 'failed')!expect(ro.error).toBeNull()expect(unk.error).toBeNull()expect(failed.error).toBe('invalid_grant')expect(offerable(st).map((x) => x.state).sort()).toEqual(['scope-unknown', 'sendable', 'sendable', 'sendable', 'sendable'])})it('not-granted wins over everything, even a perfect token', () => {const st = sendStates(DATA, INBOX_MAILBOXES, [], SIGS_OK)expect(st.every((x) => x.state === 'not-granted')).toBe(true)})it('distinguishes "no signature set" from "the store could not be read"', () => {// Both mean the reply goes out unsigned; only one of them means something// is broken, and a reader must be able to tell which.const set = sendStates(DATA, INBOX_MAILBOXES, all, SIGS_OK)expect(set.find((x) => x.email === 'hello@meridiandisplay.example')!.signature).toBe('set')expect(set.find((x) => x.email === 'exec@meridiandisplay.example')!.signature).toBe('none')const dead = sendStates(DATA, INBOX_MAILBOXES, all, SIGS_DEAD)expect(dead.every((x) => x.signature === 'store-failed')).toBe(true)})})/*** Per-mailbox send states for a viewer granted exactly `sendable`.** Module-scoped because the new-message form takes the same states as the* reply form, and a second copy of this helper is a second definition* of what "may send" means in a test file about who may send.*/const states = (sendable: string[], sigs = SIGS_OK): MailboxSendState[] =>sendStates(DATA, INBOX_MAILBOXES, sendable, sigs)describe('composeForm', () => {it('disables exactly the mailboxes that cannot send, and no others', () => {const html = composeForm(ORIG, states(INBOX_MAILBOXES.map((m) => m.email)), 'n1', SIGS_OK)const radios = [...html.matchAll(/<input type="radio" name="from" value="([^"]+)"([\s\S]*?)>/g)]const disabled = radios.filter((r) => r[2]!.includes('disabled')).map((r) => r[1])expect(disabled.sort()).toEqual(['accounts@meridiandisplay.example', 'orderdesk@meridiandisplay.example'])})it('says how many mailboxes are readable but not sendable, and where the grants live', () => {// Rule 9: where a surface withholds a row, it says how many and why. The// earlier version of this mistake counted thirteen and then showed nothing.const html = composeForm(ORIG, states(['hello@meridiandisplay.example']), 'n1', SIGS_OK)expect(html).toContain('6 more mailboxes')expect(html).toContain('INBOX_SENDERS')})it('has no To, Subject or Cc input; a REPLY\'s recipient is still decided server-side', () => {// A file input is allowed; To, Subject and Cc are not. An attachment cannot// change who gets the mail; a To field can, and the reply path's guarantee// is that nothing in the form steers that. Reply-all widens the recipients// through a boolean: the addresses still come off the original.const html = composeForm(ORIG, states(['hello@meridiandisplay.example']), 'n1', SIGS_OK)expect(html).not.toMatch(/name="to"/i)expect(html).not.toMatch(/name="subject"/i)expect(html).not.toMatch(/name="cc"/i)expect(html).toMatch(/type="file"[^>]*name="files"|name="files"[^>]*type="file"/i)expect(html).toContain('enctype="multipart/form-data"')// The reply-all control is a checkbox called `all`, and nothing else.expect(html).toMatch(/name="all" value="1"/)// The original's Cc is SHOWN, as text, with what including it would take.expect(html).toContain('accounts@example.com')expect(html).toContain('Reply to everyone')})it('offers no reply-all control when the original names nobody else', () => {// Rule 9 from the other side: a control that cannot do anything is not// shown. With no To and no Cc on the original there is nobody to add, and// a ticked box would then send exactly the same message as an unticked one.const alone = composeForm({ ...ORIG, to: '', cc: '' }, states(['hello@meridiandisplay.example']), 'n1', SIGS_OK)expect(alone).not.toMatch(/name="all"/)})it('gives the caveats their own emphasis class, not the muted metadata style', () => {// The caveats a sender must know about a shared mailbox must not look like// the muted To/Subject metadata they already know. Asserts the class is// applied and defined, not its wording. A caveat that has gone stale is// worse in red than in grey, so the assertion is that the box carries the// limits, not that it carries a sentence.const html = composeForm(ORIG, states(['hello@meridiandisplay.example']), 'n1', SIGS_OK)const para = html.match(/<p class="[^"]*compose-caveats[^"]*">([\s\S]*?)<\/p>/)expect(para).not.toBeNull()expect(para?.[1]).toContain('Reply to everyone')expect(para?.[1]).toMatch(/\d+ files at most/)expect(para?.[1]).not.toContain('cannot be added')// .compose-caveats overrides .compose-meta by ORDER, so a stylesheet that// defines it first silently restores the muted 12px it exists to replace.expect(INBOX_CSS.indexOf('.compose-caveats{')).toBeGreaterThan(INBOX_CSS.indexOf('.compose-meta{'))expect(INBOX_CSS).toContain('var(--caveat)')})it('defines --caveat in BOTH palettes', () => {// A token defined only on light renders as inherited text on dark, and the// caveats would silently go back to looking like everything else there,// which is the exact failure this change fixes.const dark = THEME_CSS.slice(THEME_CSS.indexOf(':root[data-theme="dark"]'))expect(THEME_CSS.slice(0, THEME_CSS.indexOf(':root[data-theme="dark"]'))).toContain('--caveat:')expect(dark).toContain('--caveat:')})it('carries the nonce, the message and the mailbox, and nothing that steers mail', () => {const html = composeForm(ORIG, states(['hello@meridiandisplay.example']), 'nonce-123', SIGS_OK)expect(html).toContain('name="nonce" value="nonce-123"')expect(html).toContain('name="id" value="m1"')expect(html).toContain('name="mailbox" value="hello@meridiandisplay.example"')})it('renders the two signature absences differently', () => {const none = composeForm(ORIG, states(['exec@meridiandisplay.example']), 'n', SIGS_OK)const dead = composeForm(ORIG, states(['exec@meridiandisplay.example'], SIGS_DEAD), 'n', SIGS_DEAD)expect(none).toContain('No signature is set')expect(dead).toContain('could not be read')expect(dead).toContain('failed read, not an empty signature')expect(none).not.toContain('could not be read')})it('names the read-only fix rather than saying only that it is unavailable', () => {const html = composeForm(ORIG, states(INBOX_MAILBOXES.map((m) => m.email)), 'n', SIGS_OK)expect(html).toContain('pnpm inbox:auth')expect(html).toContain('rechecks every 15 minutes')})})describe('the signatures page never renders a signature', () => {it('keeps user-supplied markup out of the board document entirely', () => {// Same rule, and the same reason, as a sender's message HTML: it reaches// only the sandboxed frame route and the outgoing MIME. Anything that puts// it in this page reintroduces the bug the frame exists to prevent.const nasty = '<img src=x onerror=alert(1)><script>steal()</script>'const page = signaturesPage(INBOX_MAILBOXES,{ ok: true, map: { 'hello@meridiandisplay.example': { html: nasty, by: 'Sam', at: '2026-09-04T00:00:00.000Z' } } },['hello@meridiandisplay.example'],null,)expect(page).not.toContain('onerror')expect(page).not.toContain('steal()')// It reports the facts about it instead, and frames it.expect(page).toContain('/inbox/signatures/frame?mailbox=hello%40meridiandisplay.example')expect(page).toContain(String(nasty.length))})it('offers an edit form only for mailboxes this viewer may send as', () => {const page = signaturesPage(INBOX_MAILBOXES, { ok: true, map: {} }, ['studio@meridiandisplay.example'], null)const forms = [...page.matchAll(/name="mailbox" value="([^"]+)"/g)].map((m) => m[1])expect(new Set(forms)).toEqual(new Set(['studio@meridiandisplay.example']))// Exactly one editable signature box on a page listing seven mailboxes.expect(page.match(/<textarea/g)).toHaveLength(1)})it('says a rejected signature was not written, rather than that it was truncated', () => {const page = signaturesPage(INBOX_MAILBOXES, { ok: true, map: {} }, ['hello@meridiandisplay.example'], {mailbox: 'hello@meridiandisplay.example',over: 41_000,})expect(page).toContain('Not saved')expect(page).toContain('Nothing was written')})it('reports a failed store as failed, not as "none set" for everyone', () => {// The distinction the prime invariant is about: a store nobody could read// must not render as seven mailboxes that happen to have no signature.const page = signaturesPage(INBOX_MAILBOXES, SIGS_DEAD, ['hello@meridiandisplay.example'], null)expect(page).toContain(SIGS_DEAD.ok ? '' : SIGS_DEAD.error)expect(page).not.toContain('Not set.')})})describe('the new-message form', () => {const st = statesit('HAS the To, Cc and Subject fields the reply form must not have', () => {// The reply form's guarantee is that no form field steers where mail goes.// A compose box cannot keep it (there is no original to read a recipient// off), so the fields exist here and the guarantee is replaced by// parseRecipients, the cap and the confirmation step.const html = newMessageForm(st(['hello@meridiandisplay.example']), 'n1', SIGS_OK)expect(html).toMatch(/name="to"/)expect(html).toMatch(/name="cc"/)expect(html).toMatch(/name="subject"/)expect(html).toContain('name="mode" value="new"')expect(html).toContain('enctype="multipart/form-data"')})it('says in the red box that the sender is choosing the recipients', () => {const para = newMessageForm(st(['hello@meridiandisplay.example']), 'n1', SIGS_OK).match(/<p class="[^"]*compose-caveats[^"]*">([\s\S]*?)<\/p>/,)expect(para).not.toBeNull()expect(para?.[1]).toContain('choosing the recipients')expect(para?.[1]).toContain(String(RECIPIENT_CAP))})it('carries no message id, mailbox or threading field at all', () => {// A new message that carried an id would be a reply wearing a compose// box's clothes, and would thread into somebody's unrelated conversation.const html = newMessageForm(st(['hello@meridiandisplay.example']), 'n1', SIGS_OK)expect(html).not.toMatch(/name="mailbox"/)expect(html).not.toMatch(/name="id"/)expect(html).not.toMatch(/name="all"/)})it('offers only mailboxes the viewer may send from, with the rest counted', () => {const html = newMessageForm(st(['studio@meridiandisplay.example']), 'n1', SIGS_OK)expect(html).toContain('value="studio@meridiandisplay.example"')expect(html).toContain('6 more mailboxes')expect(html).toContain('INBOX_SENDERS')})it('states the attachment limits where the files are chosen', () => {const html = newMessageForm(st(['hello@meridiandisplay.example']), 'n1', SIGS_OK)expect(html).toContain(fileSize(ATTACHMENT_LIMITS.perFile))expect(html).toContain(fileSize(ATTACHMENT_LIMITS.total))expect(html).toContain(String(ATTACHMENT_LIMITS.count))})it('shows no form and says why when the viewer may send from nothing', () => {// Rule 9 again: not a dead button, and not silence either.const html = newMessagePage(st([]), 'n1', SIGS_OK)expect(html).not.toContain('<form')expect(html).toContain('not send from any mailbox')expect(html).toContain('INBOX_SENDERS')})it('distinguishes "no grant" from "the grant cannot send right now"', () => {// Same three credential states the reply control separates, because they// need three different fixes.// orderdesk@ is the fixture mailbox whose token reports no gmail.send.const readonly = newMessagePage(states(['orderdesk@meridiandisplay.example']), 'n', SIGS_OK)expect(readonly).toContain('read-only token')expect(readonly).not.toContain('not send from any mailbox')})})describe('the confirmation page', () => {const OUT = {from: 'hello@meridiandisplay.example',to: 'client@example.com',cc: null as string | null,subject: 'Re: Banner proof',text: 'Here it is.',inReplyTo: '<abc@example.com>',threadId: 't1',}const ST: MailboxSendState = {email: 'hello@meridiandisplay.example',label: 'Sam Whitaker',state: 'sendable',error: null,signature: 'none',}it('shows the Cc the SERVER settled on, and says it is after the exclusions', () => {const html = confirmPage({ ...OUT, cc: 'a@x.com, b@y.com' }, ORIG, ST, 'n1', SIGS_OK)expect(html).toContain('a@x.com, b@y.com')expect(html).toContain('2 addresses')expect(html).toContain('after taking out you and the sender')})it('carries the reply-all choice forward so the send re-derives the same list', () => {// `all=1` rather than the addresses: the send recomputes the Cc from the// original, so the confirmation cannot be edited into a different one.const html = confirmPage({ ...OUT, cc: 'a@x.com' }, ORIG, ST, 'n1', SIGS_OK)expect(html).toContain('name="all" value="1"')expect(html).not.toMatch(/name="cc" value/)})it('reads as a new message, not a reply, when there is no original', () => {const html = confirmPage({ ...OUT, inReplyTo: null, threadId: null }, null, ST, 'n1', SIGS_OK)expect(html).toContain('Send this message?')expect(html).toContain('name="mode" value="new"')expect(html).toContain('start its own conversation')// The typed recipients ride in the form, because there is no original to// re-read them from, and they are re-parsed server-side on the send.expect(html).toContain('name="to" value="client@example.com"')})it('lists every staged file with its size, and carries the bytes in the form', () => {const staged = [{ name: 'proof.pdf', mimeType: 'application/pdf', b64: btoa('x'.repeat(3000)) }]const html = confirmPage(OUT, ORIG, ST, 'n1', SIGS_OK, staged)expect(html).toContain('proof.pdf')expect(html).toContain('1 attachment')expect(html).toContain('3 KB')expect(html).toContain('name="staged"')})it('says "No attachments" rather than leaving the question open', () => {const html = confirmPage(OUT, ORIG, ST, 'n1', SIGS_OK)expect(html).toContain('No attachments')expect(html).not.toContain('name="staged"')})})describe('the sent record renders three states of Cc, not two', () => {const ROW: SendRecord = {nonce: 'n1',at: '2026-09-09T15:00:00.000Z',by: 'Sam Whitaker',byEmail: 'hello@meridiandisplay.example',from: 'hello@meridiandisplay.example',to: 'client@example.com',subject: 'Re: Banner proof',originalMailbox: 'hello@meridiandisplay.example',originalId: 'm1',originalMessageId: '<abc@example.com>',threadIdSent: 't1',signatureApplied: true,outcome: 'sent',gmailId: 'gm1',gmailThreadId: 'gt1',error: null,candidates: [],}it('says "not recorded" for a row written before reply-all existed', () => {// An old row does not record "no Cc"; it records nothing. Reading absent// as none would make this audit log claim something it never observed.const html = sentPage([], false, ROW, false)expect(html).toContain('not recorded')})it('says "none" when the send recorded that there was no Cc', () => {const html = sentPage([], false, { ...ROW, cc: null, attachments: [] }, false)expect(html).toContain('Cc none')expect(html).not.toContain('not recorded')})it('names the files and their sizes when there were some', () => {const html = sentPage([],false,{ ...ROW, cc: null, attachments: [{ name: 'proof.pdf', size: 240_000 }] },false,)expect(html).toContain('proof.pdf')expect(html).toContain('234 KB')})it('marks a new message as one, so the record shows who chose the recipient', () => {const html = sentPage([], false, { ...ROW, mode: 'new', originalMailbox: null, originalId: null }, false)expect(html).toContain('new message')})it('shows the listing count from metadata, and nothing when it is absent', () => {// The listing reads KV metadata, which carries a count rather than names// (1,024-byte cap). Null is "not recorded" and must not print as zero.const withCount = sentPage([{ ...ROW, attachmentCount: 2 }], false, null, false)expect(withCount).toContain('2 files')const without = sentPage([{ ...ROW, attachmentCount: null }], false, null, false)expect(without).not.toContain('files')})})