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

inbox-send.test.ts

Language TypeScript37.5 KB926 lines

apps/worker/test/inbox-send.test.ts

  1. /**
  2. * The send route, exercised end to end without a network and without Gmail.
  3. *
  4. * The handlers take their collaborators as arguments because this is the one
  5. * action on the board that cannot be undone, and it is behind a Cloudflare
  6. * Access JWT these tests cannot mint. Injected deps are what let the
  7. * nonce claim, the write-before-send ordering and the per-mailbox thread rule
  8. * be tested at all, rather than being "covered by the deploy".
  9. *
  10. * Nothing here sends anything: `sendMessage` is a fake in every test.
  11. */
  12. import { describe, expect, it } from 'vitest'
  13. import {
  14. REPLIED_MARK_MAX_AGE_MS,
  15. SEND_KEY,
  16. decideNew,
  17. decideReply,
  18. handleCompose,
  19. handleSend,
  20. handleSignaturesPost,
  21. listSent,
  22. parseRecipients,
  23. parseStaged,
  24. readUploads,
  25. replyAllCc,
  26. stagedToOutgoing,
  27. readReplied,
  28. readSignatures,
  29. type SendDeps,
  30. type SendEnv,
  31. } from '../src/inbox-send.js'
  32. import { viewerFor } from '../../dashboard/src/viewer.js'
  33. import { SIGNATURE_CAP, type SendRecord } from '../../dashboard/src/inbox-view.js'
  34. import type { MessageBody, OutgoingMessage, SendResult } from '@meridian/gmail-client'
  35. /**
  36. * `decideReply` can refuse now: a reply-all whose derived Cc is over the
  37. * recipient cap comes back as an error rather than a trimmed message. This
  38. * unwraps the success case so the assertions below stay about the message,
  39. * and throws loudly if a test ever accidentally asserts against a refusal.
  40. */
  41. const ok = (r: OutgoingMessage | { error: string }): OutgoingMessage => {
  42. if ('error' in r) throw new Error(`expected a message, got: ${r.error}`)
  43. return r
  44. }
  45. const ALL = [
  46. 'hello@meridiandisplay.example',
  47. 'orderdesk@meridiandisplay.example',
  48. 'studio@meridiandisplay.example',
  49. 'accounts@meridiandisplay.example',
  50. 'help@meridiandisplay.example',
  51. 'service@meridiandisplay.example',
  52. 'exec@meridiandisplay.example',
  53. ]
  54. /** A KV that behaves like the real one for get/put/delete/list-with-metadata. */
  55. function fakeKv(seed: Record<string, string> = {}) {
  56. const store = new Map<string, { value: string; metadata?: unknown }>()
  57. for (const [k, v] of Object.entries(seed)) store.set(k, { value: v })
  58. return {
  59. store,
  60. async get(k: string) {
  61. return store.get(k)?.value ?? null
  62. },
  63. async put(k: string, v: string, opts?: { metadata?: unknown }) {
  64. store.set(k, { value: v, metadata: opts?.metadata })
  65. },
  66. async delete(k: string) {
  67. store.delete(k)
  68. },
  69. async list({ prefix, limit = 1000 }: { prefix: string; limit?: number }) {
  70. const keys = [...store.entries()]
  71. .filter(([k]) => k.startsWith(prefix))
  72. .map(([name, v]) => ({ name, metadata: v.metadata }))
  73. return { keys: keys.slice(0, limit), list_complete: keys.length <= limit }
  74. },
  75. }
  76. }
  77. const envOf = (seed: Record<string, string> = {}) => {
  78. const kv = fakeKv(seed)
  79. return { env: { DASHBOARD: kv } as unknown as SendEnv, kv }
  80. }
  81. const ORIG = (over: Partial<MessageBody> = {}): MessageBody => ({
  82. id: 'm1',
  83. mailbox: 'hello@meridiandisplay.example',
  84. sender: 'client@example.com',
  85. senderName: 'A Client',
  86. subject: 'Banner proof',
  87. date: '2026-09-04T10:00:00.000Z',
  88. to: 'hello@meridiandisplay.example',
  89. text: 'Where is my proof?',
  90. kind: 'plain',
  91. truncated: false,
  92. html: null,
  93. htmlTruncated: false,
  94. unread: true,
  95. attachments: [],
  96. threadId: 'thread-1',
  97. messageId: '<abc@example.com>',
  98. references: null,
  99. replyTo: '',
  100. cc: '',
  101. ...over,
  102. })
  103. function deps(
  104. over: Partial<SendDeps> = {},
  105. sendResult: SendResult = { ok: true, id: 'gm1', threadId: 'gt1' },
  106. ): SendDeps & { sent: OutgoingMessage[] } {
  107. const sent: OutgoingMessage[] = []
  108. return {
  109. sent,
  110. fetchBody: async () => ORIG(),
  111. sendMessage: async (_m, _c, msg) => {
  112. sent.push(msg)
  113. return sendResult
  114. },
  115. credentialFor: () => ({ clientId: 'i', clientSecret: 's', refreshToken: 'r' }),
  116. now: () => Date.parse('2026-09-04T12:00:00.000Z'),
  117. ...over,
  118. }
  119. }
  120. const form = (o: Record<string, string>) => {
  121. const f = new FormData()
  122. for (const [k, v] of Object.entries(o)) f.set(k, v)
  123. return f
  124. }
  125. const GOOD = { mailbox: 'hello@meridiandisplay.example', id: 'm1', from: 'hello@meridiandisplay.example', body: 'Here it is.', nonce: 'n-11111111' }
  126. describe('decideReply', () => {
  127. it('replies to Reply-To when there is one, and to From when there is not', () => {
  128. expect(ok(decideReply(ORIG(), 'hello@meridiandisplay.example', 'x', null)).to).toBe('client@example.com')
  129. expect(
  130. ok(decideReply(ORIG({ replyTo: 'Billing <billing@example.com>' }), 'hello@meridiandisplay.example', 'x', null)).to,
  131. ).toBe('billing@example.com')
  132. })
  133. it('falls back to From when Reply-To is unusable rather than sending to nothing', () => {
  134. expect(ok(decideReply(ORIG({ replyTo: 'not an address' }), 'hello@meridiandisplay.example', 'x', null)).to).toBe(
  135. 'client@example.com',
  136. )
  137. })
  138. it('sends the thread id only when replying from the mailbox it arrived in', () => {
  139. // Gmail thread ids are per-mailbox. Passing hello@'s id through accounts@'s
  140. // token is rejected outright, and the recipient's client threads on
  141. // In-Reply-To either way, so nothing is lost by omitting it.
  142. expect(ok(decideReply(ORIG(), 'hello@meridiandisplay.example', 'x', null)).threadId).toBe('thread-1')
  143. expect(ok(decideReply(ORIG(), 'accounts@meridiandisplay.example', 'x', null)).threadId).toBeNull()
  144. })
  145. it('builds References from the original chain plus its Message-ID', () => {
  146. expect(ok(decideReply(ORIG({ references: '<r1@x>' }), 'hello@meridiandisplay.example', 'x', null)).references).toBe(
  147. '<r1@x> <abc@example.com>',
  148. )
  149. expect(ok(decideReply(ORIG({ references: null, messageId: null }), 'hello@meridiandisplay.example', 'x', null)).references).toBeNull()
  150. })
  151. it('puts NOTHING identifying the sender in the message', () => {
  152. // Attribution only if truly invisible to the client, and an X- header is
  153. // not. So there is no header at all, and this
  154. // pins that nobody adds one back without changing this test.
  155. const out = ok(decideReply(ORIG(), 'accounts@meridiandisplay.example', 'x', null))
  156. expect(out.extraHeaders).toBeUndefined()
  157. expect(JSON.stringify(out)).not.toContain('meridiandisplay.example>')
  158. })
  159. })
  160. describe('parseRecipients', () => {
  161. it('keeps the address and drops the display name', () => {
  162. // The name is cosmetic and it is the half that can contain anything. What
  163. // goes into the header is built out of the parse, never copied from input.
  164. const r = parseRecipients('Lena Ortiz <lena@example.org>')
  165. expect(r.ok && r.header).toBe('lena@example.org')
  166. })
  167. it('splits on a comma OUTSIDE quotes, which is the multi-party client case', () => {
  168. // A client thread Cc'd to `"Ortiz, Lena" <Lena.Ortiz@lakeside-expo.example>`
  169. // is the shape. A naive split turns that one recipient into `"Ortiz`, which
  170. // is not an address, and `Lena" <...>`, which is one that would be sent to.
  171. const r = parseRecipients('"Ortiz, Lena" <Lena.Ortiz@lakeside-expo.example>, sasha@lakeside-expo.example')
  172. expect(r.ok && r.addresses).toEqual(['Lena.Ortiz@lakeside-expo.example', 'sasha@lakeside-expo.example'])
  173. })
  174. it('cannot be made to inject a header', () => {
  175. // CR/LF is how one header becomes two, and a forged Bcc: is the payload
  176. // that matters. This is refused at the parse, so nothing downstream has to
  177. // be careful.
  178. expect(parseRecipients('a@b.com\r\nBcc: victim@elsewhere.com').ok).toBe(false)
  179. expect(parseRecipients('a@b.com\nBcc: victim@elsewhere.com').ok).toBe(false)
  180. })
  181. it('refuses anything that is not plainly an address, and says which', () => {
  182. const r = parseRecipients('not-an-address')
  183. expect(r.ok).toBe(false)
  184. expect(!r.ok && r.error).toContain('not-an-address')
  185. })
  186. it('collapses duplicates by normalized address but sends what was typed', () => {
  187. const r = parseRecipients('Lena@Example.org, lena@example.org')
  188. expect(r.ok && r.addresses).toEqual(['Lena@Example.org'])
  189. })
  190. it('refuses over the cap with the count, instead of trimming to fit', () => {
  191. const many = Array.from({ length: 20 }, (_, i) => `p${i}@example.com`).join(', ')
  192. const r = parseRecipients(many)
  193. expect(r.ok).toBe(false)
  194. expect(!r.ok && r.error).toContain('20')
  195. })
  196. it('is not satisfied by an empty list', () => {
  197. expect(parseRecipients(' ').ok).toBe(false)
  198. })
  199. })
  200. describe('reply-all', () => {
  201. const THREAD = ORIG({
  202. sender: 'sasha@lakeside-expo.example',
  203. to: 'hello@meridiandisplay.example',
  204. cc: '"Ortiz, Lena" <Lena.Ortiz@lakeside-expo.example>, help@meridiandisplay.example',
  205. })
  206. it('is off unless asked for', () => {
  207. expect(ok(decideReply(THREAD, 'hello@meridiandisplay.example', 'x', null)).cc).toBeNull()
  208. })
  209. it('adds everyone on the original except us and the person being answered', () => {
  210. const out = ok(decideReply(THREAD, 'hello@meridiandisplay.example', 'x', null, { replyAll: true }))
  211. expect(out.to).toBe('sasha@lakeside-expo.example')
  212. // hello@ was in To and is the sender here, so it goes; Lena and support stay.
  213. expect(out.cc).toBe('Lena.Ortiz@lakeside-expo.example, help@meridiandisplay.example')
  214. })
  215. it('keeps other Meridian mailboxes on the thread', () => {
  216. // A colleague following a client thread is not noise to be tidied away;
  217. // dropping them is the silent omission reply-all exists to avoid.
  218. const out = ok(decideReply(THREAD, 'hello@meridiandisplay.example', 'x', null, { replyAll: true }))
  219. expect(out.cc).toContain('help@meridiandisplay.example')
  220. })
  221. it('drops the mailbox it arrived in even when replying as another', () => {
  222. const out = ok(decideReply(THREAD, 'accounts@meridiandisplay.example', 'x', null, { replyAll: true }))
  223. expect(out.cc).not.toContain('hello@meridiandisplay.example')
  224. })
  225. it('is null, not empty, when there is nobody left to add', () => {
  226. const solo = ORIG({ to: 'hello@meridiandisplay.example', cc: '' })
  227. expect(ok(decideReply(solo, 'hello@meridiandisplay.example', 'x', null, { replyAll: true })).cc).toBeNull()
  228. })
  229. it('REFUSES rather than trimming when the derived list is over the cap', () => {
  230. const crowd = ORIG({
  231. cc: Array.from({ length: 30 }, (_, i) => `p${i}@example.com`).join(', '),
  232. })
  233. const out = decideReply(crowd, 'hello@meridiandisplay.example', 'x', null, { replyAll: true })
  234. expect('error' in out).toBe(true)
  235. if ('error' in out) expect(out.error).toContain('ceiling')
  236. })
  237. it('ignores a malformed entry on somebody else\'s header rather than failing the send', () => {
  238. // A broken address in an incoming Cc is not ours to fix and not a reason
  239. // to refuse a reply; it is dropped from the Cc and the rest goes.
  240. const messy = ORIG({ cc: 'undisclosed-recipients:;, real@example.com' })
  241. const out = ok(decideReply(messy, 'hello@meridiandisplay.example', 'x', null, { replyAll: true }))
  242. expect(out.cc).toBe('real@example.com')
  243. })
  244. it('replyAllCc is null when the original names nobody', () => {
  245. expect(replyAllCc(ORIG({ to: '', cc: '' }), 'hello@meridiandisplay.example', 'client@example.com')).toBeNull()
  246. })
  247. })
  248. describe('decideNew', () => {
  249. const NEW = {
  250. from: 'hello@meridiandisplay.example',
  251. to: 'client@example.com',
  252. cc: null,
  253. subject: 'Your banner',
  254. text: 'Hello',
  255. signatureHtml: null,
  256. attachments: [],
  257. }
  258. it('carries NO threading headers: a new message must not join a conversation', () => {
  259. const out = decideNew(NEW)
  260. expect(out.inReplyTo).toBeNull()
  261. expect(out.references).toBeNull()
  262. expect(out.threadId).toBeNull()
  263. })
  264. it('labels an empty subject rather than sending a blank one', () => {
  265. expect(decideNew({ ...NEW, subject: ' ' }).subject).toBe('(no subject)')
  266. })
  267. it('does not prefix Re:, since it is answering nothing', () => {
  268. expect(decideNew(NEW).subject).toBe('Your banner')
  269. })
  270. })
  271. describe('attachments on the way out', () => {
  272. /**
  273. * REAL `File` OBJECTS, not duck-typed stand-ins.
  274. *
  275. * FormData.append stringifies anything that is not a Blob: an object
  276. * literal arrives as "[object Object]" and is silently dropped by
  277. * readUploads' filter, so a hand-rolled fake would make every cap test pass
  278. * by attaching nothing at all. Node has had File since 20.
  279. */
  280. const file = (name: string, size: number, type = 'application/pdf') =>
  281. new File([new Uint8Array(size)], name, { type })
  282. const withFiles = (...files: File[]) => {
  283. const f = new FormData()
  284. for (const [k, v] of Object.entries(GOOD)) f.set(k, v)
  285. for (const x of files) f.append('files', x)
  286. return f
  287. }
  288. it('drops the empty entry a browser posts for an untouched file input', async () => {
  289. // WITHOUT THIS every reply would carry one nameless zero-byte attachment.
  290. const r = await readUploads(withFiles(file('', 0)))
  291. expect(r.ok && r.files).toEqual([])
  292. })
  293. it('refuses a file over the per-file cap, naming it', async () => {
  294. const r = await readUploads(withFiles(file('huge.pdf', 5 * 1024 * 1024)))
  295. expect(r.ok).toBe(false)
  296. expect(!r.ok && r.error).toContain('huge.pdf')
  297. })
  298. it('refuses a set over the total cap', async () => {
  299. const r = await readUploads(withFiles(file('a.pdf', 2_500_000), file('b.pdf', 2_500_000)))
  300. expect(r.ok).toBe(false)
  301. expect(!r.ok && r.error).toContain('total')
  302. })
  303. it('refuses more files than the count cap', async () => {
  304. const r = await readUploads(withFiles(...Array.from({ length: 9 }, (_, i) => file(`f${i}.pdf`, 10))))
  305. expect(r.ok).toBe(false)
  306. expect(!r.ok && r.error).toContain('at most')
  307. })
  308. it('round-trips a small file to base64 and back to the same bytes', async () => {
  309. const bytes = new Uint8Array([0, 1, 2, 250, 255])
  310. const up = await readUploads(
  311. withFiles(new File([bytes], 'x.bin', { type: 'application/octet-stream' })),
  312. )
  313. expect(up.ok).toBe(true)
  314. if (!up.ok) return
  315. const out = stagedToOutgoing(up.files)
  316. expect(out.ok).toBe(true)
  317. if (!out.ok) return
  318. expect([...out.files[0]!.bytes]).toEqual([...bytes])
  319. })
  320. it('re-applies the caps to what the confirmation page hands back', () => {
  321. // The compose step checked them, and then the payload sat in a form field
  322. // on somebody's browser. What comes back is input.
  323. const big = { name: 'x.bin', mimeType: 'application/octet-stream', b64: btoa('x'.repeat(4 * 1024 * 1024)) }
  324. const out = stagedToOutgoing([big])
  325. expect(out.ok).toBe(false)
  326. })
  327. it('refuses a malformed staged field rather than sending without the files', () => {
  328. expect(parseStaged('not json').ok).toBe(false)
  329. expect(parseStaged('{"n":1}').ok).toBe(false)
  330. expect(parseStaged('[{"name":"a"}]').ok).toBe(false)
  331. // Absent is legitimate: a message with no attachments.
  332. expect(parseStaged('').ok).toBe(true)
  333. })
  334. })
  335. describe('handleSend refuses what it should', () => {
  336. it('refuses a viewer who may read everything but holds no send grant', async () => {
  337. // The mistake this guards: reusing the read grant as the send check. The
  338. // whole roster reads all seven mailboxes; only some may send.
  339. const { env } = envOf()
  340. const out = await handleSend(viewerFor('projects@meridiandisplay.example'), env, form(GOOD), ALL, deps())
  341. expect(out.kind).toBe('denied')
  342. })
  343. it('refuses sending AS a mailbox outside this viewer’s grant, even one they read', async () => {
  344. const { env, kv } = envOf()
  345. const d = deps()
  346. const out = await handleSend(
  347. viewerFor('studio@meridiandisplay.example'),
  348. env,
  349. form({ ...GOOD, from: 'accounts@meridiandisplay.example' }),
  350. ALL,
  351. d,
  352. )
  353. expect(out.kind).toBe('denied')
  354. expect(d.sent).toHaveLength(0)
  355. expect(kv.store.size).toBe(0)
  356. })
  357. it('refuses a mailbox this viewer cannot READ, even if they could send as it', async () => {
  358. const { env } = envOf()
  359. const out = await handleSend(viewerFor('hello@meridiandisplay.example'), env, form(GOOD), ['orderdesk@meridiandisplay.example'], deps())
  360. expect(out.kind).toBe('denied')
  361. })
  362. it('sends nothing for an empty body', async () => {
  363. const { env } = envOf()
  364. const d = deps()
  365. const out = await handleSend(viewerFor('hello@meridiandisplay.example'), env, form({ ...GOOD, body: ' ' }), ALL, d)
  366. expect(out.kind).toBe('error')
  367. expect(d.sent).toHaveLength(0)
  368. })
  369. it('refuses a missing or malformed nonce rather than sending un-deduplicated', async () => {
  370. const { env } = envOf()
  371. const d = deps()
  372. for (const nonce of ['', 'short', 'has spaces in it']) {
  373. const out = await handleSend(viewerFor('hello@meridiandisplay.example'), env, form({ ...GOOD, nonce }), ALL, d)
  374. expect(out.kind).toBe('error')
  375. }
  376. expect(d.sent).toHaveLength(0)
  377. })
  378. })
  379. describe('a new message, which is the one send that chooses its own recipients', () => {
  380. const NEWFORM = {
  381. mode: 'new',
  382. from: 'hello@meridiandisplay.example',
  383. to: 'client@example.com',
  384. subject: 'Your proof',
  385. body: 'Attached.',
  386. nonce: 'n-22222222',
  387. }
  388. it('still needs the SEND grant; reading seven mailboxes is not enough', async () => {
  389. // The same guard as a reply, and worth its own test because the new-message
  390. // route touches no mailbox at all: there is no readable() check to hide
  391. // behind here, so canSendFrom is the only thing standing in the way.
  392. const { env } = envOf()
  393. const out = await handleSend(viewerFor('projects@meridiandisplay.example'), env, form(NEWFORM), ALL, deps())
  394. expect(out.kind).toBe('denied')
  395. })
  396. it('refuses sending AS a mailbox outside the viewer’s grant', async () => {
  397. const { env } = envOf()
  398. const d = deps()
  399. const out = await handleSend(
  400. viewerFor('studio@meridiandisplay.example'),
  401. env,
  402. form({ ...NEWFORM, from: 'accounts@meridiandisplay.example' }),
  403. ALL,
  404. d,
  405. )
  406. expect(out.kind).toBe('denied')
  407. expect(d.sent).toHaveLength(0)
  408. })
  409. it('sends to the typed address and reads no original at all', async () => {
  410. const { env } = envOf()
  411. const d = deps({
  412. fetchBody: async () => {
  413. throw new Error('a new message must not fetch a message')
  414. },
  415. })
  416. await handleSend(viewerFor('hello@meridiandisplay.example'), env, form(NEWFORM), ALL, d)
  417. expect(d.sent).toHaveLength(1)
  418. expect(d.sent[0]!.to).toBe('client@example.com')
  419. expect(d.sent[0]!.threadId).toBeNull()
  420. })
  421. it('refuses a bad recipient before the nonce is claimed, so it can be fixed and resent', async () => {
  422. // A submission refused for its contents must not burn the one-time token,
  423. // otherwise correcting a typo means the resend is treated as a duplicate
  424. // and silently redirected to a record of something that never went.
  425. const { env, kv } = envOf()
  426. const d = deps()
  427. const out = await handleSend(
  428. viewerFor('hello@meridiandisplay.example'),
  429. env,
  430. form({ ...NEWFORM, to: 'nonsense' }),
  431. ALL,
  432. d,
  433. )
  434. expect(out.kind).toBe('error')
  435. expect(d.sent).toHaveLength(0)
  436. expect(kv.store.get(`${SEND_KEY.sentPrefix}n-22222222`)).toBeUndefined()
  437. })
  438. it('records mode, cc and attachments, and NULL for the original it does not have', async () => {
  439. const { env, kv } = envOf()
  440. await handleSend(
  441. viewerFor('hello@meridiandisplay.example'),
  442. env,
  443. form({ ...NEWFORM, cc: 'boss@example.com' }),
  444. ALL,
  445. deps(),
  446. )
  447. const rec = JSON.parse(kv.store.get(`${SEND_KEY.sentPrefix}n-22222222`)!.value) as SendRecord
  448. expect(rec.mode).toBe('new')
  449. expect(rec.cc).toBe('boss@example.com')
  450. // null, not '': there is no original, and an empty string in a field that
  451. // holds a mailbox reads as one that could not be read.
  452. expect(rec.originalMailbox).toBeNull()
  453. expect(rec.originalId).toBeNull()
  454. expect(rec.attachments).toEqual([])
  455. })
  456. it('leaves no replied mark; it answered nothing', async () => {
  457. const { env } = envOf()
  458. await handleSend(viewerFor('hello@meridiandisplay.example'), env, form(NEWFORM), ALL, deps())
  459. expect(await readReplied(env)).toEqual({})
  460. })
  461. it('caps To and Cc together, not one each', async () => {
  462. const { env } = envOf()
  463. const eight = (p: string) => Array.from({ length: 8 }, (_, i) => `${p}${i}@example.com`).join(', ')
  464. const out = await handleSend(
  465. viewerFor('hello@meridiandisplay.example'),
  466. env,
  467. form({ ...NEWFORM, to: eight('a'), cc: eight('b') }),
  468. ALL,
  469. deps(),
  470. )
  471. expect(out.kind).toBe('error')
  472. if (out.kind === 'error') expect(out.message).toContain('16 recipients')
  473. })
  474. it('an unknown mode is a reply, never a new message', async () => {
  475. // The reply path is the one that cannot address a stranger, so it is the
  476. // one an unrecognised value falls back to. Here the reply fields are
  477. // missing, so falling back means denied, not "send to whatever `to` says".
  478. const { env } = envOf()
  479. const d = deps()
  480. const out = await handleSend(
  481. viewerFor('hello@meridiandisplay.example'),
  482. env,
  483. form({ ...NEWFORM, mode: 'NEW ' }),
  484. ALL,
  485. d,
  486. )
  487. expect(out.kind).toBe('denied')
  488. expect(d.sent).toHaveLength(0)
  489. })
  490. })
  491. describe('a reply that carries files, and one that carries everyone', () => {
  492. const staged = (files: { name: string; mimeType: string; b64: string }[]) => {
  493. const f = new FormData()
  494. for (const [k, v] of Object.entries(GOOD)) f.set(k, v)
  495. f.set('staged', JSON.stringify(files))
  496. return f
  497. }
  498. it('hands the decoded bytes to the sender and records name and size only', async () => {
  499. const { env, kv } = envOf()
  500. const d = deps()
  501. await handleSend(
  502. viewerFor('hello@meridiandisplay.example'),
  503. env,
  504. staged([{ name: 'proof.pdf', mimeType: 'application/pdf', b64: btoa('hello') }]),
  505. ALL,
  506. d,
  507. )
  508. expect(d.sent[0]!.attachments).toHaveLength(1)
  509. expect(d.sent[0]!.attachments[0]!.bytes.byteLength).toBe(5)
  510. const rec = JSON.parse(kv.store.get(`${SEND_KEY.sentPrefix}n-11111111`)!.value) as SendRecord
  511. expect(rec.attachments).toEqual([{ name: 'proof.pdf', size: 5 }])
  512. // The bytes are not in the audit row. It records that a file went, not the
  513. // file: the same line the row already draws around the message text.
  514. expect(kv.store.get(`${SEND_KEY.sentPrefix}n-11111111`)!.value).not.toContain(btoa('hello'))
  515. })
  516. it('refuses an over-cap staged payload WITHOUT claiming the nonce', async () => {
  517. const { env, kv } = envOf()
  518. const d = deps()
  519. const out = await handleSend(
  520. viewerFor('hello@meridiandisplay.example'),
  521. env,
  522. staged([{ name: 'big.bin', mimeType: 'application/octet-stream', b64: btoa('x'.repeat(4 * 1024 * 1024)) }]),
  523. ALL,
  524. d,
  525. )
  526. expect(out.kind).toBe('error')
  527. expect(d.sent).toHaveLength(0)
  528. expect(kv.store.get(`${SEND_KEY.sentPrefix}n-11111111`)).toBeUndefined()
  529. })
  530. it('sends the reply-all Cc that the original justifies', async () => {
  531. const { env, kv } = envOf()
  532. const thread = ORIG({ sender: 'sasha@lakeside-expo.example', to: 'hello@meridiandisplay.example', cc: 'Lena.Ortiz@lakeside-expo.example' })
  533. const d = deps({ fetchBody: async () => thread })
  534. await handleSend(viewerFor('hello@meridiandisplay.example'), env, form({ ...GOOD, all: '1' }), ALL, d)
  535. expect(d.sent[0]!.cc).toBe('Lena.Ortiz@lakeside-expo.example')
  536. const rec = JSON.parse(kv.store.get(`${SEND_KEY.sentPrefix}n-11111111`)!.value) as SendRecord
  537. expect(rec.cc).toBe('Lena.Ortiz@lakeside-expo.example')
  538. })
  539. it('records cc as null (not absent) when reply-all was not asked for', async () => {
  540. // Absent means "this row predates the feature" and the page says so. A
  541. // send made today must record which of the two it was.
  542. const { env, kv } = envOf()
  543. await handleSend(viewerFor('hello@meridiandisplay.example'), env, form(GOOD), ALL, deps())
  544. const rec = JSON.parse(kv.store.get(`${SEND_KEY.sentPrefix}n-11111111`)!.value) as SendRecord
  545. expect(rec.cc).toBeNull()
  546. expect('cc' in rec).toBe(true)
  547. })
  548. it('keeps the KV metadata inside its 1,024-byte ceiling with five long filenames', async () => {
  549. // The listing renders from metadata alone. Over the cap the metadata write
  550. // fails and the row vanishes from the list while the record still exists:
  551. // a send that happened and cannot be seen.
  552. const { env, kv } = envOf()
  553. const long = (i: number) => ({
  554. name: `${'a-very-long-client-filename'.repeat(4)}-${i}.pdf`,
  555. mimeType: 'application/pdf',
  556. b64: btoa('x'),
  557. })
  558. await handleSend(
  559. viewerFor('hello@meridiandisplay.example'),
  560. env,
  561. staged([1, 2, 3, 4, 5].map(long)),
  562. ALL,
  563. deps(),
  564. )
  565. const meta = kv.store.get(`${SEND_KEY.sentPrefix}n-11111111`)!.metadata
  566. expect(new TextEncoder().encode(JSON.stringify(meta)).byteLength).toBeLessThan(1024)
  567. // And the count survives, because that is what the listing prints.
  568. expect((meta as { attachmentCount: number }).attachmentCount).toBe(5)
  569. })
  570. })
  571. describe('handleCompose, the confirmation step', () => {
  572. it('previews a reply-all with the list the SEND will use, not the header it came from', async () => {
  573. const { env } = envOf()
  574. const thread = ORIG({
  575. sender: 'sasha@lakeside-expo.example',
  576. to: 'hello@meridiandisplay.example',
  577. cc: 'Lena.Ortiz@lakeside-expo.example',
  578. })
  579. const out = await handleCompose(
  580. viewerFor('hello@meridiandisplay.example'),
  581. env,
  582. form({ ...GOOD, all: '1' }),
  583. ALL,
  584. deps({ fetchBody: async () => thread }),
  585. )
  586. expect(out.kind).toBe('confirm')
  587. if (out.kind !== 'confirm') return
  588. expect(out.outgoing.to).toBe('sasha@lakeside-expo.example')
  589. expect(out.outgoing.cc).toBe('Lena.Ortiz@lakeside-expo.example')
  590. expect(out.mode).toBe('reply')
  591. })
  592. it('sends nothing and writes nothing', async () => {
  593. const { env, kv } = envOf()
  594. const d = deps()
  595. await handleCompose(viewerFor('hello@meridiandisplay.example'), env, form({ ...GOOD, all: '1' }), ALL, d)
  596. expect(d.sent).toHaveLength(0)
  597. expect([...kv.store.keys()]).toEqual([])
  598. })
  599. it('carries the staged files forward for the confirmation page to show', async () => {
  600. const { env } = envOf()
  601. const f = new FormData()
  602. for (const [k, v] of Object.entries(GOOD)) f.set(k, v)
  603. f.append('files', new File([new Uint8Array([1, 2, 3])], 'proof.pdf', { type: 'application/pdf' }))
  604. const out = await handleCompose(viewerFor('hello@meridiandisplay.example'), env, f, ALL, deps())
  605. expect(out.kind).toBe('confirm')
  606. if (out.kind !== 'confirm') return
  607. expect(out.staged).toHaveLength(1)
  608. expect(out.staged[0]!.name).toBe('proof.pdf')
  609. expect(out.outgoing.attachments[0]!.bytes.byteLength).toBe(3)
  610. })
  611. it('refuses an over-cap upload at the compose step, before any confirmation', async () => {
  612. const { env } = envOf()
  613. const f = new FormData()
  614. for (const [k, v] of Object.entries(GOOD)) f.set(k, v)
  615. f.append('files', new File([new Uint8Array(5 * 1024 * 1024)], 'big.pdf', { type: 'application/pdf' }))
  616. const out = await handleCompose(viewerFor('hello@meridiandisplay.example'), env, f, ALL, deps())
  617. expect(out.kind).toBe('error')
  618. if (out.kind === 'error') expect(out.message).toContain('big.pdf')
  619. })
  620. })
  621. describe('handleSend, the happy path and the record', () => {
  622. it('writes "sending" BEFORE Gmail is called and overwrites it after', async () => {
  623. // A Worker torn down mid-flight must leave evidence rather than silence:
  624. // the outcome page then says "submitted, outcome not recorded; check the
  625. // Sent folder", which is the honest answer and not "failed".
  626. const { env, kv } = envOf()
  627. const seen: string[] = []
  628. const d = deps({
  629. sendMessage: async () => {
  630. seen.push(JSON.parse(kv.store.get(`${SEND_KEY.sentPrefix}n-11111111`)!.value).outcome)
  631. return { ok: true, id: 'gm1', threadId: 'gt1' }
  632. },
  633. })
  634. const out = await handleSend(viewerFor('hello@meridiandisplay.example'), env, form(GOOD), ALL, d)
  635. expect(seen).toEqual(['sending'])
  636. const rec = JSON.parse(kv.store.get(`${SEND_KEY.sentPrefix}n-11111111`)!.value) as SendRecord
  637. expect(rec.outcome).toBe('sent')
  638. expect(rec.gmailId).toBe('gm1')
  639. expect(out).toEqual({ kind: 'redirect', location: '/inbox/sent?ref=n-11111111' })
  640. })
  641. it('records a failed send too, with its candidates and no cause picked', async () => {
  642. const { env, kv } = envOf()
  643. const d = deps({}, { ok: false, status: 403, error: 'insufficient scopes', candidates: ['a', 'b', 'c'] })
  644. await handleSend(viewerFor('hello@meridiandisplay.example'), env, form(GOOD), ALL, d)
  645. const rec = JSON.parse(kv.store.get(`${SEND_KEY.sentPrefix}n-11111111`)!.value) as SendRecord
  646. expect(rec.outcome).toBe('failed')
  647. expect(rec.error).toBe('insufficient scopes')
  648. expect(rec.candidates).toHaveLength(3)
  649. // An attempt that left no trace would be the one worth hiding.
  650. expect(rec.at).toBeTruthy()
  651. })
  652. it('holds no message text: subject and addresses only', async () => {
  653. const { env, kv } = envOf()
  654. await handleSend(
  655. viewerFor('hello@meridiandisplay.example'),
  656. env,
  657. form({ ...GOOD, body: 'SECRET-REPLY-TEXT' }),
  658. ALL,
  659. deps(),
  660. )
  661. const raw = kv.store.get(`${SEND_KEY.sentPrefix}n-11111111`)!.value
  662. expect(raw).not.toContain('SECRET-REPLY-TEXT')
  663. expect(JSON.stringify(kv.store.get(`${SEND_KEY.sentPrefix}n-11111111`)!.metadata)).not.toContain('SECRET-REPLY-TEXT')
  664. })
  665. it('a second post of the same nonce does not send again', async () => {
  666. const { env } = envOf()
  667. const d = deps()
  668. await handleSend(viewerFor('hello@meridiandisplay.example'), env, form(GOOD), ALL, d)
  669. const again = await handleSend(viewerFor('hello@meridiandisplay.example'), env, form(GOOD), ALL, d)
  670. expect(d.sent).toHaveLength(1)
  671. expect(again).toEqual({ kind: 'redirect', location: '/inbox/sent?ref=n-11111111&dup=1' })
  672. })
  673. it('applies the mailbox’s signature, and records that it did', async () => {
  674. const { env, kv } = envOf({
  675. [SEND_KEY.signatures]: JSON.stringify({
  676. 'hello@meridiandisplay.example': { html: '<p>Sam</p>', by: 'Sam', at: '2026-09-04T00:00:00.000Z' },
  677. }),
  678. })
  679. const d = deps()
  680. await handleSend(viewerFor('hello@meridiandisplay.example'), env, form(GOOD), ALL, d)
  681. expect(d.sent[0]!.signatureHtml).toBe('<p>Sam</p>')
  682. expect((JSON.parse(kv.store.get(`${SEND_KEY.sentPrefix}n-11111111`)!.value) as SendRecord).signatureApplied).toBe(true)
  683. })
  684. it('sends unsigned rather than failing when the signature store is unreadable', async () => {
  685. const { env, kv } = envOf({ [SEND_KEY.signatures]: 'not json at all' })
  686. const d = deps()
  687. await handleSend(viewerFor('hello@meridiandisplay.example'), env, form(GOOD), ALL, d)
  688. expect(d.sent).toHaveLength(1)
  689. expect(d.sent[0]!.signatureHtml).toBeNull()
  690. expect((JSON.parse(kv.store.get(`${SEND_KEY.sentPrefix}n-11111111`)!.value) as SendRecord).signatureApplied).toBe(false)
  691. })
  692. })
  693. describe('the replied mark is persistent board state', () => {
  694. it('is written on a successful send, keyed like a read mark', async () => {
  695. const { env } = envOf()
  696. await handleSend(viewerFor('hello@meridiandisplay.example'), env, form(GOOD), ALL, deps())
  697. const marks = await readReplied(env)
  698. expect(marks['hello@meridiandisplay.example/m1']).toMatchObject({ from: 'hello@meridiandisplay.example', by: 'Sam Whitaker' })
  699. })
  700. it('is NOT written when the send failed', async () => {
  701. // A "replied" chip on a message nobody answered is worse than no chip: in a
  702. // shared mailbox it is what makes the next person not answer it either.
  703. const { env } = envOf()
  704. const d = deps({}, { ok: false, status: 500, error: 'boom', candidates: ['x'] })
  705. await handleSend(viewerFor('hello@meridiandisplay.example'), env, form(GOOD), ALL, d)
  706. expect(await readReplied(env)).toEqual({})
  707. })
  708. it('survives a rewrite of the message list, because it is not derived from it', async () => {
  709. // The 15-minute sync replaces inbox:messages wholesale; the mark lives in
  710. // its own key and is untouched.
  711. const { env, kv } = envOf()
  712. await handleSend(viewerFor('hello@meridiandisplay.example'), env, form(GOOD), ALL, deps())
  713. await kv.put('inbox:messages', JSON.stringify({ syncedAt: 'later', mailboxes: [] }))
  714. expect(Object.keys(await readReplied(env))).toEqual(['hello@meridiandisplay.example/m1'])
  715. })
  716. it('prunes past the list window, so it cannot grow without bound', async () => {
  717. const stale = Date.parse('2026-09-04T12:00:00.000Z') - REPLIED_MARK_MAX_AGE_MS - 1
  718. const { env } = envOf({
  719. [SEND_KEY.replied]: JSON.stringify({ 'x@y/old': { at: stale, by: 'Someone', from: 'x@y' } }),
  720. })
  721. await handleSend(viewerFor('hello@meridiandisplay.example'), env, form(GOOD), ALL, deps())
  722. const marks = await readReplied(env)
  723. expect(marks['x@y/old']).toBeUndefined()
  724. expect(marks['hello@meridiandisplay.example/m1']).toBeTruthy()
  725. // 35 days, past the 30-day list window: a mark can never vanish from under
  726. // a message that is still on the page.
  727. expect(REPLIED_MARK_MAX_AGE_MS).toBeGreaterThan(30 * 24 * 60 * 60 * 1000)
  728. })
  729. it('degrades to no chips, not to broken, when the key is malformed', async () => {
  730. const { env } = envOf({ [SEND_KEY.replied]: '{{{' })
  731. expect(await readReplied(env)).toEqual({})
  732. })
  733. })
  734. describe('handleCompose', () => {
  735. it('confirms without sending or writing anything', async () => {
  736. const { env, kv } = envOf()
  737. const d = deps()
  738. const out = await handleCompose(viewerFor('hello@meridiandisplay.example'), env, form(GOOD), ALL, d)
  739. expect(out.kind).toBe('confirm')
  740. expect(d.sent).toHaveLength(0)
  741. expect(kv.store.size).toBe(0)
  742. })
  743. it('refuses the same viewers handleSend refuses', async () => {
  744. const { env } = envOf()
  745. expect((await handleCompose(viewerFor('nora.l@meridiandisplay.example'), env, form(GOOD), ALL, deps())).kind).toBe('denied')
  746. })
  747. it('reports a failed read of the original rather than composing a blank reply', async () => {
  748. const { env } = envOf()
  749. const out = await handleCompose(
  750. viewerFor('hello@meridiandisplay.example'),
  751. env,
  752. form(GOOD),
  753. ALL,
  754. deps({ fetchBody: async () => ({ error: 'Gmail returned HTTP 404 for that message.' }) }),
  755. )
  756. expect(out).toMatchObject({ kind: 'error' })
  757. })
  758. })
  759. describe('signatures', () => {
  760. const sam = viewerFor('hello@meridiandisplay.example')
  761. it('round-trips set, then clear', async () => {
  762. const { env } = envOf()
  763. await handleSignaturesPost(sam, env, form({ mailbox: 'hello@meridiandisplay.example', op: 'set', html: '<p>Hi</p>' }), Date.now)
  764. let store = await readSignatures(env)
  765. expect(store.ok && store.map['hello@meridiandisplay.example']?.html).toBe('<p>Hi</p>')
  766. await handleSignaturesPost(sam, env, form({ mailbox: 'hello@meridiandisplay.example', op: 'clear' }), Date.now)
  767. store = await readSignatures(env)
  768. expect(store.ok && store.map['hello@meridiandisplay.example']).toBeUndefined()
  769. })
  770. it('refuses a viewer who may read the mailbox but not send as it', async () => {
  771. const { env, kv } = envOf()
  772. const out = await handleSignaturesPost(
  773. viewerFor('studio@meridiandisplay.example'),
  774. env,
  775. form({ mailbox: 'accounts@meridiandisplay.example', op: 'set', html: '<p>x</p>' }),
  776. Date.now,
  777. )
  778. expect(out.kind).toBe('denied')
  779. expect(kv.store.size).toBe(0)
  780. })
  781. it('writes NOTHING when over the cap; a truncated signature would go out broken', async () => {
  782. const { env, kv } = envOf()
  783. const out = await handleSignaturesPost(
  784. sam,
  785. env,
  786. form({ mailbox: 'hello@meridiandisplay.example', op: 'set', html: 'x'.repeat(SIGNATURE_CAP + 1) }),
  787. Date.now,
  788. )
  789. expect(out).toMatchObject({ kind: 'redirect' })
  790. expect(out.kind === 'redirect' && out.location).toContain('rejected=')
  791. expect(kv.store.has(SEND_KEY.signatures)).toBe(false)
  792. })
  793. it('treats empty-after-trim as a clear, like the board’s notes', async () => {
  794. const { env } = envOf()
  795. await handleSignaturesPost(sam, env, form({ mailbox: 'hello@meridiandisplay.example', op: 'set', html: '<p>Hi</p>' }), Date.now)
  796. await handleSignaturesPost(sam, env, form({ mailbox: 'hello@meridiandisplay.example', op: 'set', html: ' ' }), Date.now)
  797. const store = await readSignatures(env)
  798. expect(store.ok && Object.keys(store.map)).toEqual([])
  799. })
  800. it('will not overwrite a store it could not read', async () => {
  801. // Otherwise one transient bad read permanently loses everyone's signature.
  802. const { env, kv } = envOf({ [SEND_KEY.signatures]: 'not json' })
  803. await handleSignaturesPost(sam, env, form({ mailbox: 'hello@meridiandisplay.example', op: 'set', html: '<p>x</p>' }), Date.now)
  804. expect(kv.store.get(SEND_KEY.signatures)!.value).toBe('not json')
  805. })
  806. it('surfaces a malformed store as failed rather than as an empty one', async () => {
  807. const { env } = envOf({ [SEND_KEY.signatures]: '["not","an","object"]' })
  808. const store = await readSignatures(env)
  809. expect(store.ok).toBe(false)
  810. })
  811. it('a missing key is ok and empty: nobody has set one yet', async () => {
  812. const { env } = envOf()
  813. expect(await readSignatures(env)).toEqual({ ok: true, map: {} })
  814. })
  815. })
  816. describe('listSent', () => {
  817. it('reads the listing from metadata, newest first, and labels truncation', async () => {
  818. // Rule 5: a capped list returns what it has and SAYS it is capped.
  819. const { env } = envOf()
  820. const d = deps()
  821. for (const n of ['n-aaaaaaaa', 'n-bbbbbbbb', 'n-cccccccc']) {
  822. await handleSend(
  823. viewerFor('hello@meridiandisplay.example'),
  824. env,
  825. form({ ...GOOD, nonce: n }),
  826. ALL,
  827. { ...d, now: () => Date.parse(`2026-09-0${n === 'n-aaaaaaaa' ? 1 : n === 'n-bbbbbbbb' ? 2 : 3}T12:00:00.000Z`) },
  828. )
  829. }
  830. const all = await listSent(env)
  831. expect(all.rows.map((r) => r.nonce)).toEqual(['n-cccccccc', 'n-bbbbbbbb', 'n-aaaaaaaa'])
  832. expect(all.truncated).toBe(false)
  833. const capped = await listSent(env, 2)
  834. expect(capped.rows).toHaveLength(2)
  835. expect(capped.truncated).toBe(true)
  836. })
  837. })