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

inbox.test.ts

Language TypeScript31.2 KB727 lines

apps/dashboard/test/inbox.test.ts

  1. /**
  2. * The consolidated inbox tab.
  3. *
  4. * The two things that would make this feature actively harmful, both tested
  5. * here: hiding mail that matters, and showing mail to someone who should not
  6. * see it.
  7. */
  8. import { describe, expect, it } from 'vitest'
  9. import {
  10. INBOX_MAILBOXES,
  11. INBOX_READERS,
  12. KNOWN_NOT_MARKETING,
  13. MARKETING_SENDERS,
  14. canReadInbox,
  15. isOperational,
  16. matchesFilter,
  17. readableMailboxes,
  18. } from '../../../config/inbox.js'
  19. import {
  20. ALL_INBOXES_CAP,
  21. INBOX_CSS,
  22. INBOX_MAX_PER_MAILBOX,
  23. inboxPage,
  24. messagePage,
  25. prioritised,
  26. rowTime,
  27. type InboxData,
  28. } from '../src/inbox-view.js'
  29. import { COMPANY_VIEWER, viewerFor } from '../src/viewer.js'
  30. import type { Attachment, InboxMessage } from '@meridian/gmail-client'
  31. /**
  32. * The count a named section header is claiming.
  33. *
  34. * It sits in its own <span> so the search script can rewrite it: a count
  35. * rendered once and never updated stops describing the list under it the
  36. * moment anything is filtered. These tests care about the number, not
  37. * where it sits, so they ask for it rather than matching a flat string.
  38. */
  39. const sectionCount = (html: string, title: string): number | null => {
  40. const m = new RegExp(`${title} · <span class="msg-count">(\\d+)</span>`).exec(html)
  41. return m ? Number(m[1]) : null
  42. }
  43. const msg = (over: Partial<InboxMessage>): InboxMessage => ({
  44. id: 'm1',
  45. threadId: 't1',
  46. mailbox: 'hello@meridiandisplay.example',
  47. sender: 'news@upscaler.vendor.example',
  48. senderName: 'Upscaler Labs',
  49. subject: 'What is new this month',
  50. snippet: 'Release notes and a few tips for the latest version',
  51. date: '2026-08-31T18:00:00.000Z',
  52. labels: ['INBOX', 'CATEGORY_PROMOTIONS'],
  53. unread: false,
  54. ...over,
  55. })
  56. /** Two healthy mailboxes and one dead one, so "2 of 3" is exercised. */
  57. const DATA: InboxData = {
  58. syncedAt: '2026-09-01T06:00:00.000Z',
  59. mailboxes: [
  60. {
  61. mailbox: 'hello@meridiandisplay.example',
  62. ok: true,
  63. error: null,
  64. messages: [
  65. msg({ id: 'a' }),
  66. msg({ id: 'b', subject: 'Last chance: annual plan discount' }),
  67. msg({ id: 'c', sender: 'trackingupdates@courier.vendor.example', senderName: 'Courier', subject: 'Shipment delivered' }),
  68. msg({ id: 'd', sender: 'failed-payments@stripe.com', senderName: '', subject: 'A payment failed' }),
  69. // NEW MARKETING, not in the seed and not operational. This is the row
  70. // the button exists for, and without it the fixture had no "filter this"
  71. // control at all, so the add path was untested while the tests passed.
  72. msg({ id: 'f', sender: 'hello@newvendor.test', senderName: 'New Vendor', subject: 'Introducing our thing' }),
  73. ],
  74. },
  75. {
  76. mailbox: 'orderdesk@meridiandisplay.example',
  77. ok: true,
  78. error: null,
  79. messages: [msg({ id: 'e', mailbox: 'orderdesk@meridiandisplay.example' })],
  80. },
  81. {
  82. mailbox: 'accounts@meridiandisplay.example',
  83. ok: false,
  84. error: 'invalid_grant: the mailbox is suspended',
  85. messages: [],
  86. },
  87. ],
  88. }
  89. const ALL = INBOX_MAILBOXES.map((m) => m.email)
  90. const page = (readable = ALL, filters = MARKETING_SENDERS) =>
  91. inboxPage(DATA, filters, INBOX_MAILBOXES, readable, 'Sam Whitaker')
  92. describe('the filter never hides mail that matters', () => {
  93. it('leaves every known-operational sender unfiltered', () => {
  94. // Failed payments, shipment tracking, meeting recordings, and two of this
  95. // board's own sources all land in a Gmail promotional category. Seeding from
  96. // the category alone would have hidden them on day one.
  97. for (const s of KNOWN_NOT_MARKETING) {
  98. expect(matchesFilter(s, MARKETING_SENDERS), s).toBe(false)
  99. }
  100. })
  101. it('does not let a domain rule catch a vendor that sends both kinds', () => {
  102. // Three pairs. A same-domain rule hides the half that matters in the
  103. // first and last; the middle pair shows cold outreach and a failed payment
  104. // must still land on opposite sides of the filter:
  105. // sweeps@message.courier.vendor.example vs TrackingUpdates@courier.vendor.example
  106. // rep@growth.vendor.example vs failed-payments@stripe.com
  107. // teamscheduler@send.scheduler.vendor.example vs notifications@scheduler.vendor.example
  108. expect(matchesFilter('sweeps@message.courier.vendor.example', MARKETING_SENDERS)).toBe(true)
  109. expect(matchesFilter('trackingupdates@courier.vendor.example', MARKETING_SENDERS)).toBe(false)
  110. expect(matchesFilter('rep@growth.vendor.example', MARKETING_SENDERS)).toBe(true)
  111. expect(matchesFilter('failed-payments@stripe.com', MARKETING_SENDERS)).toBe(false)
  112. expect(matchesFilter('teamscheduler@send.scheduler.vendor.example', MARKETING_SENDERS)).toBe(true)
  113. expect(matchesFilter('notifications@scheduler.vendor.example', MARKETING_SENDERS)).toBe(false)
  114. })
  115. it('matches nothing by substring', () => {
  116. // A customer at a domain containing a vendor's name must not be swept up.
  117. expect(matchesFilter('someone@notupscaler.vendor.example', ['news@upscaler.vendor.example'])).toBe(false)
  118. expect(matchesFilter('news@upscaler.vendor.example.evil.test', ['news@upscaler.vendor.example'])).toBe(false)
  119. })
  120. it('honours an explicit @domain entry, which is the only wildcard there is', () => {
  121. expect(matchesFilter('anyone@spam.test', ['@spam.test'])).toBe(true)
  122. expect(matchesFilter('anyone@notspam.test', ['@spam.test'])).toBe(false)
  123. })
  124. })
  125. describe('an operational sender cannot be filtered, by anyone', () => {
  126. it('refuses to hide one even when it is on the filter list', () => {
  127. // Courier tracking is operational and must stay visible, so this is
  128. // enforced rather than documented: a stray entry in KV or in config is
  129. // inert, not obeyed.
  130. expect(matchesFilter('trackingupdates@courier.vendor.example', ['trackingupdates@courier.vendor.example'])).toBe(false)
  131. expect(matchesFilter('failed-payments@stripe.com', ['@stripe.com'])).toBe(false)
  132. })
  133. it('protects every sender on the operational list', () => {
  134. for (const s of KNOWN_NOT_MARKETING) expect(isOperational(s), s).toBe(true)
  135. })
  136. it('shows a label instead of a button, rather than a button that refuses', () => {
  137. const html = page()
  138. expect(html).toContain('operational')
  139. // The courier row must not carry a filter form.
  140. const row = html.slice(html.indexOf('trackingupdates@courier.vendor.example'))
  141. expect(row.slice(0, row.indexOf('</div>') + 6)).not.toContain('<form')
  142. })
  143. })
  144. describe('reading a message', () => {
  145. const body = {
  146. id: 'm1',
  147. mailbox: 'hello@meridiandisplay.example',
  148. sender: 'news@upscaler.vendor.example',
  149. senderName: 'Upscaler Labs',
  150. subject: 'What is new this month',
  151. date: '2026-08-31T18:00:00.000Z',
  152. to: 'hello@meridiandisplay.example',
  153. text: 'Read this.',
  154. kind: 'plain' as const,
  155. truncated: false,
  156. html: null as string | null,
  157. htmlTruncated: false,
  158. unread: false,
  159. attachments: [] as Attachment[],
  160. }
  161. /** One attachment part, overridable. Defaults to a downloadable PDF. */
  162. const att = (over: Partial<Attachment> = {}): Attachment => ({
  163. index: 0,
  164. name: 'flyer.pdf',
  165. mimeType: 'application/pdf',
  166. size: 240_000,
  167. attachmentId: 'a1',
  168. inline: false,
  169. ...over,
  170. })
  171. it('renders the body as text and escapes it', () => {
  172. // A marketing email is written by someone outside the company. Rendering its
  173. // markup on a page behind Access would hand an injection surface to anyone
  174. // who can email us.
  175. const html = messagePage({ ...body, text: '<script>alert(1)</script>' }, 'Sam Whitaker')
  176. expect(html).not.toContain('<script>alert(1)</script>')
  177. expect(html).toContain('&lt;script&gt;')
  178. })
  179. it('says a failed read failed, rather than showing an empty message', () => {
  180. const html = messagePage({ error: 'invalid_grant' }, 'Client Success')
  181. expect(html).toContain('invalid_grant')
  182. expect(html).toContain('not an empty message')
  183. })
  184. it('says when a body was cut', () => {
  185. expect(messagePage({ ...body, truncated: true }, 'Sam')).toContain('Cut at')
  186. })
  187. it('distinguishes an image-only message from a blank one', () => {
  188. expect(messagePage({ ...body, kind: 'none', text: '' }, 'Sam')).toContain('images only')
  189. })
  190. it('offers each attachment as a download, with its size', () => {
  191. // The property: a name is never on the page without either a way to get
  192. // the file or the reason there is none.
  193. const html = messagePage({ ...body, attachments: [att()] }, 'Sam')
  194. expect(html).toContain('flyer.pdf')
  195. expect(html).toContain('234 KB')
  196. expect(html).toMatch(/href="\/inbox\/attachment\?mailbox=[^"]*&amp;id=[^"]*&amp;n=0"/)
  197. })
  198. it('says why an attachment has no download rather than offering a dead link', () => {
  199. // The two absences are different and neither may render as a link.
  200. const noHandle = messagePage({ ...body, attachments: [att({ attachmentId: null })] }, 'Sam')
  201. expect(noHandle).toContain('no handle')
  202. expect(noHandle).not.toContain('/inbox/attachment?')
  203. const tooBig = messagePage({ ...body, attachments: [att({ size: 40 * 1024 * 1024 })] }, 'Sam')
  204. expect(tooBig).toContain('download ceiling')
  205. expect(tooBig).not.toContain('/inbox/attachment?')
  206. })
  207. it('does not report an unmeasured size as zero', () => {
  208. // Gmail omits body.size on some parts. "0 KB" beside a file that plainly
  209. // has contents is the prime invariant broken in the smallest possible way.
  210. const html = messagePage({ ...body, attachments: [att({ size: null })] }, 'Sam')
  211. expect(html).toContain('size not reported')
  212. expect(html).not.toContain('0 B')
  213. })
  214. it('separates inline images from files somebody attached', () => {
  215. // A multi-party client thread can carry four inline signature logos.
  216. // Counted as attachments they read as "4 attachments" on a message with
  217. // none, which is true and useless.
  218. const html = messagePage(
  219. {
  220. ...body,
  221. attachments: [att({ name: 'image001.jpg', mimeType: 'image/jpeg', inline: true })],
  222. },
  223. 'Sam',
  224. )
  225. expect(html).toContain('No attachments')
  226. expect(html).toContain('1 inline image')
  227. })
  228. it('renders an HTML message through the frame, never inline', () => {
  229. // Messages render as full HTML, but the sender's markup must never touch
  230. // the board's own document: it rides in a separate response framed by a
  231. // sandbox without allow-scripts. If either assertion
  232. // below fails, the XSS this design prevents is back.
  233. const evil = '<img src=x onerror=alert(1)>'
  234. const html = messagePage({ ...body, kind: 'html' as const, html: evil }, 'Sam')
  235. expect(html).not.toContain(evil)
  236. expect(html).toContain('/inbox/message/frame?mailbox=hello%40meridiandisplay.example&amp;id=m1')
  237. expect(html).toMatch(/sandbox="[^"]*"/)
  238. expect(html).not.toMatch(/sandbox="[^"]*allow-scripts/)
  239. })
  240. it('says when the HTML was cut, and falls back to text when there is none', () => {
  241. expect(messagePage({ ...body, html: '<p>x</p>', htmlTruncated: true }, 'M')).toContain('HTML was cut')
  242. // No HTML part → the plain text renders, no frame.
  243. const plain = messagePage(body, 'M')
  244. expect(plain).toContain('Read this.')
  245. expect(plain).not.toContain('mail-frame')
  246. })
  247. it('offers Mark read for a Gmail-unread message and Mark unread for a board-read one', () => {
  248. const unreadPage = messagePage({ ...body, unread: true }, 'M', false)
  249. expect(unreadPage).toContain('action="/inbox/read"')
  250. expect(unreadPage).toContain('Mark read')
  251. const boardReadPage = messagePage({ ...body, unread: true }, 'M', true)
  252. expect(boardReadPage).toContain('Mark unread')
  253. // Read in Gmail itself: no board state exists to flip, so no button.
  254. const gmailRead = messagePage({ ...body, unread: false }, 'M', false)
  255. expect(gmailRead).not.toContain('action="/inbox/read"')
  256. })
  257. })
  258. describe('access fails closed', () => {
  259. it('grants exactly the addresses Sam named, and nobody else', () => {
  260. // The whole roster plus the support and service desks (whose send grants
  261. // would otherwise be inert). The property that must survive any future
  262. // edit: a grant exists only
  263. // where a human wrote one (explicit lines, not a loop over the roster or
  264. // over INBOX_MAILBOXES), and anyone not written there is still closed, the
  265. // company viewer included.
  266. expect(Object.keys(INBOX_READERS).sort()).toEqual([
  267. 'accounts@meridiandisplay.example',
  268. 'frontdesk@meridiandisplay.example',
  269. 'hello@meridiandisplay.example',
  270. 'help@meridiandisplay.example',
  271. 'nora.l@meridiandisplay.example',
  272. 'orderdesk@meridiandisplay.example',
  273. 'projects@meridiandisplay.example',
  274. 'service@meridiandisplay.example',
  275. 'studio@meridiandisplay.example',
  276. ])
  277. for (const email of Object.keys(INBOX_READERS)) {
  278. expect(readableMailboxes(viewerFor(email)), email).toHaveLength(INBOX_MAILBOXES.length)
  279. }
  280. expect(canReadInbox(viewerFor('someone-else@meridiandisplay.example'))).toBe(false)
  281. expect(canReadInbox(COMPANY_VIEWER)).toBe(false)
  282. })
  283. it('reads inbox mailboxes that are not roster people without inventing viewers', () => {
  284. // exec@ is a mailbox the tab reads, not a person who can read the tab. An
  285. // address that is only in INBOX_MAILBOXES must resolve to no grant: access
  286. // arrives only when a human writes the line.
  287. expect(canReadInbox(viewerFor('exec@meridiandisplay.example'))).toBe(false)
  288. expect(INBOX_MAILBOXES.some((m) => m.email === 'exec@meridiandisplay.example')).toBe(true)
  289. })
  290. it('gives the two granted desks the inbox but not a roster identity', () => {
  291. // They are shared desks, not people, so they read every mailbox and resolve
  292. // to the COMPANY viewer everywhere else: no personal briefing, no
  293. // Leadership. Being in INBOX_READERS is not being on the roster.
  294. for (const email of ['help@meridiandisplay.example', 'service@meridiandisplay.example']) {
  295. const v = viewerFor(email)
  296. expect(readableMailboxes(v), email).toHaveLength(INBOX_MAILBOXES.length)
  297. expect(v.person, email).toBeNull()
  298. expect(v.seesLeadership, email).toBe(false)
  299. expect(v.ownBriefing, email).toBeNull()
  300. }
  301. })
  302. it('gives an unrecognised address nothing', () => {
  303. expect(readableMailboxes(viewerFor('stranger@example.com'))).toEqual([])
  304. expect(readableMailboxes(COMPANY_VIEWER)).toEqual([])
  305. })
  306. it('cannot invent a mailbox from a typo in a grant', () => {
  307. const grant = { 'x@meridiandisplay.example': { mailboxes: ['typo@meridiandisplay.example'] } }
  308. const saved = { ...INBOX_READERS }
  309. Object.assign(INBOX_READERS, grant)
  310. try {
  311. expect(readableMailboxes({ ...COMPANY_VIEWER, email: 'x@meridiandisplay.example' })).toEqual([])
  312. } finally {
  313. for (const k of Object.keys(INBOX_READERS)) delete INBOX_READERS[k]
  314. Object.assign(INBOX_READERS, saved)
  315. }
  316. })
  317. })
  318. describe('rowTime: the Gmail time column, in Meridian time', () => {
  319. it('shows clock time for today and month-day for older, in America/Chicago', () => {
  320. const now = new Date('2026-09-01T20:00:00Z') // 3:00 PM in Chicago
  321. expect(rowTime('2026-09-01T18:42:00Z', now)).toBe('1:42 PM')
  322. // 03:00Z on Sep 1 is still Aug 31 EVENING in Chicago; the day boundary is
  323. // the reader's, not UTC's. A UTC boundary would stamp late-evening mail
  324. // "today" a day early.
  325. expect(rowTime('2026-09-01T03:00:00Z', now)).toBe('Aug 31')
  326. expect(rowTime('2026-08-20T12:00:00Z', now)).toBe('Aug 20')
  327. })
  328. it('renders a dash for garbage, never Jan 1 1970', () => {
  329. expect(rowTime('not a date')).toBe('–')
  330. })
  331. })
  332. describe('the page: Gmail anatomy, board identity', () => {
  333. it('renders one row per message, newest first', () => {
  334. const html = page()
  335. // Every message row carries data-search; sender-grouping now lives only in
  336. // the marketing bucket. Two Upscaler messages = two rows, not one group.
  337. const rows = html.match(/class="msg-row/g) ?? []
  338. const unfilteredCount = DATA.mailboxes
  339. .flatMap((m) => m.messages)
  340. .filter((m) => !matchesFilter(m.sender, MARKETING_SENDERS)).length
  341. expect(rows.length).toBe(unfilteredCount)
  342. })
  343. it('splits Unread from Everything else, and bolds unread via the row class', () => {
  344. const withUnread: InboxData = {
  345. ...DATA,
  346. mailboxes: [
  347. {
  348. mailbox: 'hello@meridiandisplay.example',
  349. ok: true,
  350. error: null,
  351. messages: [
  352. msg({ id: 'u1', sender: 'a@x.test', subject: 'New thing', unread: true, labels: ['UNREAD', 'INBOX'] }),
  353. msg({ id: 'r1', sender: 'b@x.test', subject: 'Old thing' }),
  354. ],
  355. },
  356. ],
  357. }
  358. const html = inboxPage(withUnread, [], INBOX_MAILBOXES, ALL, 'Sam Whitaker')
  359. expect(sectionCount(html, 'Unread')).toBe(1)
  360. expect(sectionCount(html, 'Everything else')).toBe(1)
  361. expect(html).toContain('msg-row unread')
  362. })
  363. it('a board read-mark moves a Gmail-unread message out of Unread, and offers the way back', () => {
  364. const withUnread: InboxData = {
  365. ...DATA,
  366. mailboxes: [
  367. {
  368. mailbox: 'hello@meridiandisplay.example',
  369. ok: true,
  370. error: null,
  371. messages: [
  372. msg({ id: 'u1', sender: 'a@x.test', subject: 'New thing', unread: true, labels: ['UNREAD'] }),
  373. msg({ id: 'u2', sender: 'b@x.test', subject: 'Handled thing', unread: true, labels: ['UNREAD'] }),
  374. ],
  375. },
  376. ],
  377. }
  378. // Nobody marked anything: both unread, both rows offer Mark read.
  379. const before = inboxPage(withUnread, [], INBOX_MAILBOXES, ALL, 'Sam Whitaker')
  380. expect(sectionCount(before, 'Unread')).toBe(2)
  381. expect((before.match(/>Mark read</g) ?? []).length).toBe(2)
  382. // u2 marked read on the board: it leaves Unread, loses the bold class, and
  383. // its row offers Mark unread, so the mark is reversible from the page
  384. // (rule 12). The rail count follows the same arithmetic.
  385. const after = inboxPage(withUnread, [], INBOX_MAILBOXES, ALL, 'Sam Whitaker', null,
  386. new Set(['hello@meridiandisplay.example/u2']))
  387. expect(sectionCount(after, 'Unread')).toBe(1)
  388. expect(sectionCount(after, 'Everything else')).toBe(1)
  389. expect(after).toContain('>Mark unread<')
  390. expect(after).toContain('name="op" value="unread"')
  391. })
  392. it('a message read in Gmail itself gets no mark button; there is no board state to flip', () => {
  393. const html = page()
  394. // Fixture messages are all unread:false with no board marks.
  395. expect(html).not.toContain('action="/inbox/read"')
  396. })
  397. it('the mark-read form posts the message its OWN mailbox and echoes the view for the 303', () => {
  398. const withUnread: InboxData = {
  399. ...DATA,
  400. mailboxes: [
  401. {
  402. mailbox: 'orderdesk@meridiandisplay.example',
  403. ok: true,
  404. error: null,
  405. messages: [msg({ id: 'u9', mailbox: 'orderdesk@meridiandisplay.example', sender: 'a@x.test', unread: true })],
  406. },
  407. ],
  408. }
  409. const html = inboxPage(withUnread, [], INBOX_MAILBOXES, ALL, 'Sam Whitaker', 'orderdesk@meridiandisplay.example')
  410. expect(html).toContain('action="/inbox/read"')
  411. expect(html).toContain('name="mailbox" value="orderdesk@meridiandisplay.example"')
  412. expect(html).toContain('name="id" value="u9"')
  413. expect(html).toContain('name="back" value="orderdesk@meridiandisplay.example"')
  414. })
  415. it('shows the snippet on the subject line, escaped', () => {
  416. const nasty: InboxData = {
  417. syncedAt: null,
  418. mailboxes: [
  419. {
  420. mailbox: 'hello@meridiandisplay.example',
  421. ok: true,
  422. error: null,
  423. messages: [msg({ snippet: '<img onerror=x> preview', senderName: '<script>alert(1)</script>' })],
  424. },
  425. ],
  426. }
  427. const html = inboxPage(nasty, [], INBOX_MAILBOXES, ALL, 'Sam')
  428. expect(html).toContain('· &lt;img onerror=x&gt; preview')
  429. expect(html).not.toContain('<script>alert(1)</script>')
  430. })
  431. it('filters to one mailbox when selected, and marks it current in the rail', () => {
  432. const html = inboxPage(DATA, MARKETING_SENDERS, INBOX_MAILBOXES, ALL, 'Sam', 'orderdesk@meridiandisplay.example')
  433. // hello@ holds the only Stripe mail in the fixture; a filtered view must not show it.
  434. expect(html).not.toContain('A payment failed')
  435. expect(html).toMatch(/aria-current="true"[^>]*>\s*<span>Rafael Montero<\/span>/)
  436. })
  437. it('carries the selected mailbox through the filter form, so the 303 returns here', () => {
  438. const html = inboxPage(DATA, [], INBOX_MAILBOXES, ALL, 'Sam', 'hello@meridiandisplay.example')
  439. expect(html).toContain('<input type="hidden" name="mailbox" value="hello@meridiandisplay.example">')
  440. })
  441. it('names a failed mailbox in the rail AND the banner, never as an empty inbox', () => {
  442. const html = page()
  443. expect(html).toContain('invalid_grant')
  444. expect(html).toContain('missing from this list, not absent')
  445. // The rail shows "failed" where its unread count would be; a count it
  446. // cannot have would be a lie.
  447. expect(html).toMatch(/class="dead"[^>]*>failed</)
  448. })
  449. it('keeps filtered senders in the collapsed bucket with the way back', () => {
  450. const html = page()
  451. expect(html).toContain('Filtered marketing:')
  452. expect(html).toContain('Not marketing')
  453. expect(html).toContain('news@upscaler.vendor.example')
  454. })
  455. it('keeps operational senders in the list with a tag instead of a button', () => {
  456. const html = page()
  457. expect(html).toContain('A payment failed')
  458. expect(html).toContain('Shipment delivered')
  459. const stripe = html.slice(html.indexOf('failed-payments@stripe.com'))
  460. expect(stripe.slice(0, stripe.indexOf('</div>'))).not.toContain('<form')
  461. })
  462. it('shows only the mailboxes this viewer may read, in list and rail alike', () => {
  463. const html = page(['orderdesk@meridiandisplay.example'])
  464. expect(html).not.toContain('A payment failed')
  465. expect(html).not.toContain('>Sam Whitaker</span>')
  466. })
  467. it('says a dead read is a dead read rather than a quiet inbox', () => {
  468. const allDown: InboxData = {
  469. syncedAt: null,
  470. mailboxes: [{ mailbox: 'hello@meridiandisplay.example', ok: false, error: 'HTTP 403', messages: [] }],
  471. }
  472. const html = inboxPage(allDown, MARKETING_SENDERS, INBOX_MAILBOXES, ['hello@meridiandisplay.example'], 'Sam')
  473. expect(html).toContain('not because there is no mail')
  474. })
  475. it('works with JavaScript off: rail links, form POSTs, search hidden by default', () => {
  476. const html = page()
  477. expect(html).toContain('href="/inbox?mailbox=')
  478. expect(html).toContain('method="post"')
  479. expect(html).toContain('action="/inbox/filter"')
  480. expect(html).toContain('action="/inbox/unfilter"')
  481. expect(html).toMatch(/<div hidden[^>]*>\s*<input id="inbox-search"/)
  482. })
  483. })
  484. /**
  485. * Search must actually hide rows, not merely render its markup.
  486. *
  487. * The `hidden` property depends on the user-agent stylesheet's
  488. * `[hidden]{display:none}`, and a UA rule loses to any author rule regardless
  489. * of specificity. `.msg-row` is `display:grid`, so a row hidden that way stays
  490. * on screen. The presence of a control is not evidence that the control works.
  491. */
  492. describe('search actually hides what it filters', () => {
  493. it('re-asserts display:none for every element the script toggles', () => {
  494. // The script sets `hidden` on .msg-row, .msg-sect and .msg-none. Each needs
  495. // an author-level rule or the UA default is overridden by whatever display
  496. // that class already carries.
  497. for (const sel of ['.msg-row[hidden]', '.msg-sect[hidden]', '.msg-none[hidden]']) {
  498. expect(INBOX_CSS, `${sel} has no display:none rule`).toContain(sel)
  499. }
  500. expect(INBOX_CSS).toMatch(/\.msg-row\[hidden\][^{]*\{display:none\}/)
  501. })
  502. it('gives every class that the script hides an explicit display rule', () => {
  503. // Generalised so the next person who styles a filtered element gets caught:
  504. // any selector carrying `display:` whose class the script toggles must also
  505. // appear in the [hidden] override.
  506. const hiddenRule = INBOX_CSS.match(/^[^\n]*\[hidden\][^{]*\{display:none\}/m)?.[0] ?? ''
  507. for (const cls of ['msg-row', 'msg-sect', 'msg-none']) {
  508. expect(hiddenRule, `${cls} missing from the [hidden] override`).toContain(cls)
  509. }
  510. })
  511. it('wraps each section so its header hides with its rows', () => {
  512. // Left as siblings, filtering away every row in a section stranded its
  513. // header above nothing, still claiming a count.
  514. const html = inboxPage(DATA, [], INBOX_MAILBOXES, ALL, 'Sam Whitaker')
  515. expect(html).toContain('class="msg-sect"')
  516. const sects = html.match(/class="msg-sect"/g) ?? []
  517. const heads = html.match(/class="msg-section"/g) ?? []
  518. expect(sects.length).toBe(heads.length)
  519. })
  520. it('puts the header count in its own element so the script can rewrite it', () => {
  521. // A count rendered once on the server and never updated is a number that
  522. // stops describing the list under it the moment anything is filtered.
  523. const html = inboxPage(DATA, [], INBOX_MAILBOXES, ALL, 'Sam Whitaker')
  524. expect(html).toMatch(/<span class="msg-count">\d+<\/span>/)
  525. })
  526. it('carries a no-match line that is distinct from an empty inbox', () => {
  527. const html = inboxPage(DATA, [], INBOX_MAILBOXES, ALL, 'Sam Whitaker')
  528. expect(html).toContain('id="msg-none"')
  529. expect(html).toMatch(/id="msg-none"[^>]*hidden/)
  530. // And it must not claim to have searched Gmail.
  531. expect(html).toContain('not a search of Gmail')
  532. })
  533. })
  534. /**
  535. * How much of each mailbox the page shows is a decision, not a fetch limit: the
  536. * combined view summarises, one mailbox shows everything held, and both say
  537. * which they are doing.
  538. */
  539. /** Collapse markup whitespace, so a re-wrapped sentence is not a test failure. */
  540. const flat = (html: string): string => html.replace(/\s+/g, ' ')
  541. const bulk = (n: number, over: Partial<InboxMessage> = {}): InboxMessage[] =>
  542. Array.from({ length: n }, (_, i) =>
  543. msg({
  544. id: `b${i}`,
  545. subject: `Message ${i}`,
  546. // Descending dates, so index order is newest-first like the real list.
  547. date: new Date(Date.parse('2026-09-04T18:00:00.000Z') - i * 60_000).toISOString(),
  548. ...over,
  549. }),
  550. )
  551. describe('prioritised: the All-inboxes rule', () => {
  552. const unreadOf = (m: InboxMessage) => m.unread
  553. it('takes unread first, newest first, and caps at 25', () => {
  554. const r = prioritised(bulk(40, { unread: true }), unreadOf)
  555. expect(r.unread).toHaveLength(ALL_INBOXES_CAP)
  556. expect(r.read).toHaveLength(0)
  557. expect(r.unread[0]!.id).toBe('b0')
  558. expect(r.heldBack).toBe(15)
  559. })
  560. it('fills the remainder with read messages only when unread does not fill it', () => {
  561. // Unread outranks read: with 25 unread waiting, nothing already-read earns
  562. // a place in a summary.
  563. const kept = [...bulk(3, { unread: true }), ...bulk(30, { unread: false })]
  564. const r = prioritised(kept, unreadOf)
  565. expect(r.unread).toHaveLength(3)
  566. expect(r.read).toHaveLength(ALL_INBOXES_CAP - 3)
  567. // 3 unread + 22 read = the cap; the other 8 read are held back.
  568. expect(r.heldBack).toBe(kept.length - ALL_INBOXES_CAP)
  569. })
  570. it('accounts for every message it does not show', () => {
  571. // Rule 9: shown plus held back equals what was loaded, exactly.
  572. const kept = [...bulk(30, { unread: true }), ...bulk(30, { unread: false })]
  573. const r = prioritised(kept, unreadOf)
  574. expect(r.unread.length + r.read.length + r.heldBack).toBe(kept.length)
  575. })
  576. it('holds nothing back when there is little mail', () => {
  577. const r = prioritised(bulk(4, { unread: true }), unreadOf)
  578. expect(r.heldBack).toBe(0)
  579. })
  580. })
  581. describe('the combined view summarises; one mailbox shows everything', () => {
  582. const many = (n: number): InboxData => ({
  583. syncedAt: '2026-09-04T18:00:00.000Z',
  584. mailboxes: [{
  585. mailbox: 'hello@meridiandisplay.example', ok: true, error: null, canSend: true,
  586. windowTotal: n, truncated: false, failedFetches: 0,
  587. messages: bulk(n, { unread: true }),
  588. }],
  589. })
  590. it('caps All inboxes and says how many are waiting behind it', () => {
  591. const html = inboxPage(many(60), [], INBOX_MAILBOXES, ALL, 'Sam Whitaker', null)
  592. expect((html.match(/class="msg-row/g) ?? [])).toHaveLength(ALL_INBOXES_CAP)
  593. expect(flat(html)).toContain('35 more')
  594. // And it must not read as a fetch limit.
  595. expect(flat(html)).toContain('nothing here is a limit on what was fetched')
  596. })
  597. it('shows everything held when one mailbox is selected', () => {
  598. const html = inboxPage(many(60), [], INBOX_MAILBOXES, ALL, 'Sam Whitaker', 'hello@meridiandisplay.example')
  599. expect((html.match(/class="msg-row/g) ?? [])).toHaveLength(60)
  600. expect(flat(html)).not.toContain('more messages</b> are loaded')
  601. })
  602. it('names a mailbox whose window held more than was fetched', () => {
  603. // Truncated is not the same as held back: one means open the mailbox, the
  604. // other means raise the cap. They must not share a sentence.
  605. const d = many(10)
  606. d.mailboxes[0]!.truncated = true
  607. d.mailboxes[0]!.windowTotal = 900
  608. const html = inboxPage(d, [], INBOX_MAILBOXES, ALL, 'Sam Whitaker', 'hello@meridiandisplay.example')
  609. expect(flat(html)).toContain('900 in the window')
  610. expect(flat(html)).toContain('not absent from Gmail')
  611. })
  612. it('says the count is unknown rather than inventing one', () => {
  613. // windowTotal is null when the id list could not be walked to the end. The
  614. // field it replaced reported Gmail's resultSizeEstimate, which came back
  615. // 501 for windows of larger, unknown size, a number that read as a fact.
  616. const d = many(10)
  617. d.mailboxes[0]!.truncated = true
  618. d.mailboxes[0]!.windowTotal = null
  619. const html = inboxPage(d, [], INBOX_MAILBOXES, ALL, 'Sam Whitaker', 'hello@meridiandisplay.example')
  620. expect(flat(html)).toContain('more than the board counted')
  621. })
  622. it('reports messages Gmail failed to return, distinctly from truncation', () => {
  623. const d = many(5)
  624. d.mailboxes[0]!.failedFetches = 3
  625. const html = inboxPage(d, [], INBOX_MAILBOXES, ALL, 'Sam Whitaker', 'hello@meridiandisplay.example')
  626. expect(flat(html)).toContain('failed fetch, not an empty inbox')
  627. })
  628. it('says nothing about shortfalls when there are none', () => {
  629. const html = inboxPage(many(5), [], INBOX_MAILBOXES, ALL, 'Sam Whitaker', 'hello@meridiandisplay.example')
  630. expect(flat(html)).not.toContain('not absent from Gmail')
  631. expect(flat(html)).not.toContain('more messages</b> are loaded')
  632. })
  633. })
  634. describe('the footer does not lie about coverage', () => {
  635. it('states the per-mailbox depth from the constant, not a typed literal', () => {
  636. // The footer is built from the constant, not typed beside it: a hand-typed
  637. // number would go on claiming the old depth when the constant moved, a page
  638. // lying about its own coverage.
  639. const html = inboxPage(DATA, [], INBOX_MAILBOXES, ALL, 'Sam Whitaker')
  640. expect(html).toContain(INBOX_MAX_PER_MAILBOX.toLocaleString())
  641. expect(html).not.toContain('newest 25 per mailbox')
  642. })
  643. })
  644. describe('the New message button', () => {
  645. it('is absent unless the viewer may send from something', () => {
  646. // Defaults to absent, like every permission on this board (rule 13). A
  647. // button that 403s when pressed is worse than one that was never there,
  648. // and the reason is on the message page, where the Reply control explains
  649. // it in full rather than in a tooltip here.
  650. expect(inboxPage(DATA, [], INBOX_MAILBOXES, ALL, 'Sam Whitaker')).not.toContain('/inbox/new')
  651. })
  652. it('appears for a viewer who holds a send grant', () => {
  653. const html = inboxPage(DATA, [], INBOX_MAILBOXES, ALL, 'Sam Whitaker', null, new Set(), new Set(), true)
  654. expect(html).toContain('href="/inbox/new"')
  655. expect(html).toContain('New message')
  656. })
  657. })