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

index.ts

Language TypeScript83.9 KB1892 lines

apps/worker/src/index.ts

  1. /**
  2. * The deployed dashboard: a Cloudflare Worker that syncs on a schedule and
  3. * serves the page.
  4. *
  5. * A `fetch` handler that pulled live data on every request would hit four APIs
  6. * per page load, take ten seconds, and burn the Plinth rate limit the moment two
  7. * people opened it at once. So the cron does the work and writes the result to
  8. * KV; requests are a KV read and nothing else.
  9. *
  10. * That also means a request can never fail because a source is down. The page
  11. * shows the last good sync with its `syncedAt` timestamp, and the source pills
  12. * report which sources were failing when it ran, which is the honest answer,
  13. * and the one this whole board is built around.
  14. *
  15. * Authentication is Cloudflare Access in front of the Worker, authenticating
  16. * against Google Workspace. The page carries client company names and order
  17. * numbers, so it must not be public, and Access means no user table, no login
  18. * form, no session handling and no password resets. access.ts verifies the
  19. * assertion Access attaches. A page served without signing in means Access is
  20. * misconfigured.
  21. */
  22. import { collect, headline, type Snapshot } from '../../dashboard/src/collect.js'
  23. import { render, routeTabBar } from '../../dashboard/src/render.js'
  24. import { buildBriefingPayload } from '../../dashboard/src/briefing-payload.js'
  25. import { rowFrom, type HistoryRow } from '../../dashboard/src/history.js'
  26. import { renderVariants, variantKey } from '../../dashboard/src/variants.js'
  27. import { EXPORT_CSS, THEME_CSS } from '../../dashboard/src/theme.js'
  28. import { esc } from '../../dashboard/src/html.js'
  29. import { WORK_KEY_RE } from '../../dashboard/src/work-key.js'
  30. import { ownListBlock, parseOwnOutcome, stampOwnList } from '../../dashboard/src/own-list-view.js'
  31. import { hasOwnList, ownListPersonFor } from '../../../config/own-list.js'
  32. import { readOwnList, saveOwn } from './own-list.js'
  33. import {
  34. ATTACHMENT_DOWNLOAD_CAP,
  35. fetchAttachment,
  36. fetchBody,
  37. mimeParam,
  38. parseServiceAccountSecret,
  39. readMailbox,
  40. sendMessage,
  41. type GmailCredential,
  42. type MailboxRead,
  43. type ServiceAccount,
  44. } from '@meridian/gmail-client'
  45. import {
  46. INBOX_CSS,
  47. INBOX_MAX_PER_MAILBOX,
  48. composePage,
  49. confirmPage,
  50. inboxPage,
  51. messagePage,
  52. newMessagePage,
  53. SEARCH_RESULT_CAP,
  54. searchResultsPage,
  55. sendStates,
  56. sentPage,
  57. signaturesPage,
  58. type InboxData,
  59. type SearchHit,
  60. } from '../../dashboard/src/inbox-view.js'
  61. import { hasOnboarding, onboardingFor } from '../../../config/onboarding.js'
  62. import {
  63. exportToday,
  64. parseWindow,
  65. recapPage,
  66. recapTextAll,
  67. } from '../../dashboard/src/onboarding-export.js'
  68. import { checkinAuthorFor, readableCheckinAuthors } from '../../../config/checkin.js'
  69. import {
  70. checkinBlock,
  71. parseOutcome,
  72. stampCheckin,
  73. type CheckinStore,
  74. } from '../../dashboard/src/checkin-view.js'
  75. import { readCheckinsFor, saveCheckin } from './checkin.js'
  76. import {
  77. INBOX_MAILBOXES,
  78. MARKETING_SENDERS,
  79. canReadInbox,
  80. buildSearchQuery,
  81. isOperational,
  82. readableMailboxes,
  83. sendableMailboxes,
  84. } from '../../../config/inbox.js'
  85. import {
  86. SEND_KEY,
  87. handleCompose,
  88. handleSend,
  89. handleSignaturesPost,
  90. listSent,
  91. readRecord,
  92. readReplied,
  93. readSignatures,
  94. type SendDeps,
  95. type SendEnv,
  96. } from './inbox-send.js'
  97. import { viewerFor } from '../../dashboard/src/viewer.js'
  98. import { identify } from './access.js'
  99. import type { Briefings } from '../../dashboard/src/briefings.js'
  100. import { localDay, localStamp } from '@meridian/ninety-client'
  101. import { errorMessage } from '@meridian/shared'
  102. export interface Env {
  103. DASHBOARD: KVNamespace
  104. NINETY_TOKEN: string
  105. BITRIX_WEBHOOK_URL: string
  106. PLINTH_API_KEY: string
  107. /**
  108. * Gmail, for the consolidated inbox. One service account for all seven
  109. * mailboxes, authorized once in Google Admin for domain-wide delegation.
  110. * `GMAIL_SA_KEY` is the `private_key` from the key JSON; `GMAIL_SA_EMAIL` is
  111. * its `client_email`.
  112. *
  113. * Both optional: with neither set, every mailbox reports as failed with a
  114. * reason and the rest of the board is untouched. A half-configured inbox must
  115. * not take the sync down with it.
  116. */
  117. GMAIL_SA_EMAIL?: string
  118. GMAIL_SA_KEY?: string
  119. /**
  120. * Per-mailbox OAuth refresh tokens, for when domain-wide delegation is not
  121. * available (it needs a Workspace super-admin). One shared client, one token
  122. * per mailbox, minted by `pnpm inbox:auth`.
  123. *
  124. * Precedence: a mailbox with its token set uses it; a mailbox without one
  125. * falls back to the service account, so tokens can be retired one at a time
  126. * with no code change once delegation is in place.
  127. */
  128. GMAIL_CLIENT_ID?: string
  129. GMAIL_CLIENT_SECRET?: string
  130. GMAIL_REFRESH_HELLO?: string
  131. GMAIL_REFRESH_ORDERDESK?: string
  132. GMAIL_REFRESH_STUDIO?: string
  133. GMAIL_REFRESH_ACCOUNTS?: string
  134. GMAIL_REFRESH_HELP?: string
  135. GMAIL_REFRESH_SERVICE?: string
  136. GMAIL_REFRESH_EXEC?: string
  137. /**
  138. * Fathom, one key per person. Every one is optional: with none set, meeting
  139. * commitments read "not tracked" rather than zero, and with one revoked the
  140. * others still sync while the board names the dark key.
  141. *
  142. * Declared here only to be findable. collect() reads them by name out of
  143. * config/fathom-keys.ts, through the cast at the collect() call below, so
  144. * adding a person means editing that file and running `wrangler secret put`;
  145. * a line added here alone does nothing. The names must match
  146. * config/fathom-keys.ts exactly.
  147. */
  148. FATHOM_KEY_SAM?: string
  149. FATHOM_KEY_INES?: string
  150. FATHOM_KEY_THEO?: string
  151. /**
  152. * D1, for the end-of-day note and the private list: the only SQL on this
  153. * board and the only binding here that is not a credential.
  154. *
  155. * Optional by design (rule 14). A Worker deployed before `wrangler d1 create`
  156. * has been run must still serve the board, with the note card saying, in those words,
  157. * that the store is not set up. The alternative is an exception on the read
  158. * path or, far worse, an empty log, which reads as "they have never written
  159. * one" and is the prime invariant broken in the place it costs most.
  160. * See apps/worker/migrations/.
  161. */
  162. CHECKINS?: D1Database
  163. /** Optional. Widens the Plinth order window; defaults to 60 days. */
  164. PLINTH_WINDOW_DAYS?: string
  165. /**
  166. * Cloudflare Access team domain, e.g. `meridiandisplay.cloudflareaccess.com`.
  167. * Not a secret (it is in every login URL), but the Worker refuses every
  168. * request without it, because it cannot verify an assertion it cannot check.
  169. */
  170. ACCESS_TEAM_DOMAIN?: string
  171. /**
  172. * The Access application's Audience (AUD) tag. Pins assertions to THIS
  173. * application; without it a token minted for another app on the same Access
  174. * team would be accepted here.
  175. */
  176. ACCESS_AUD?: string
  177. DEBUG?: string
  178. }
  179. // Every KV key the board writes. There is deliberately no whole-board page key:
  180. // every page lives under `dashboard:html:<viewer>` and is chosen by verified
  181. // identity. A copy of client data that no code path serves is not a fallback,
  182. // it is an extra place to leak from.
  183. const KEY = {
  184. payload: 'dashboard:payload',
  185. history: 'dashboard:history',
  186. /**
  187. * The model-written briefings, pushed from a laptop by `pnpm push`.
  188. *
  189. * They arrive from outside because the only place a model is called is
  190. * `analysis/brief.py`, which runs locally. This Worker does not call one, so it
  191. * has no way to author a briefing; it can only render one somebody else wrote.
  192. * Without this key the deployed board renders the deterministic briefing and
  193. * says the written one was not generated, which is honest and also permanent.
  194. */
  195. briefings: 'dashboard:briefings',
  196. lastError: 'sync:lastError',
  197. /** Metadata-only message list, rewritten every sync. No bodies, ever. */
  198. inboxMessages: 'inbox:messages',
  199. /**
  200. * The marketing sender list, board-owned state (rule 12). The write goes to
  201. * the board's own KV and never to a source system, and losing this key
  202. * degrades to MARKETING_SENDERS in config rather than to nothing.
  203. */
  204. inboxFilters: 'inbox:filters',
  205. /**
  206. * The board's own read-marks, kept board-side so that no mailbox has to be
  207. * re-consented for gmail.modify. Same bar as inbox:filters: board KV, never
  208. * Gmail; losing the key degrades to Gmail's real unread state; the button
  209. * that set a mark can unset it. A map of "mailbox/id" → epoch ms, pruned past
  210. * the list window so it cannot grow without bound.
  211. */
  212. inboxRead: 'inbox:read',
  213. /**
  214. * User notes on orders and tasks: "why this is where it is", typed by a
  215. * person, shared with everyone, keyed `order:MD26…` / `task:<id>`. Board KV
  216. * only; losing the key degrades to a board with no annotations, never to
  217. * broken; a note is edited or removed from the same button that wrote it.
  218. */
  219. notes: 'board:notes',
  220. /**
  221. * Who asked for a sync and when, for the Sync now button: the last manual
  222. * run's start time and who pressed it.
  223. *
  224. * It is a cooldown, not a lock. Two people clicking ten seconds apart would
  225. * each run a full sync and each burn the Plinth and Bitrix rate limits; Bitrix
  226. * answers that with QUERY_LIMIT_EXCEEDED, which would land in sync:lastError
  227. * and read as a broken source rather than as two eager clicks. Every write in
  228. * sync() is idempotent, so the risk is the rate limit, not corruption.
  229. *
  230. * Losing the key degrades to "the next click is allowed". Nothing depends on
  231. * it being present.
  232. */
  233. syncRequest: 'sync:request',
  234. /**
  235. * The send keys live in inbox-send.ts as SEND_KEY and are named here so this
  236. * map stays the one place every key the board owns is listed.
  237. *
  238. * inbox:signatures per-mailbox HTML signatures. Losing it means replies go
  239. * out unsigned and the page says "not set"; reversible
  240. * from the page that wrote it.
  241. * inbox:replied the persistent replied mark, board-owned so the
  242. * 15-minute rewrite of inbox:messages cannot erase it.
  243. * Losing it costs the chips, not the replies: those are
  244. * in Gmail's Sent folder.
  245. * inbox:sent:<n> one key per send attempt, append-only. The only state
  246. * here the page cannot undo, because it records
  247. * something that cannot be undone.
  248. */
  249. signatures: SEND_KEY.signatures,
  250. replied: SEND_KEY.replied,
  251. sentPrefix: SEND_KEY.sentPrefix,
  252. } as const
  253. /** History lives in KV rather than on disk. Same rows, same day-over-day deltas. */
  254. const MAX_HISTORY = 120
  255. /**
  256. * The pushed briefings, or null.
  257. *
  258. * Null is a normal answer, not an error: before the first push, and on any day
  259. * nobody ran `pnpm morning`, there is no written briefing and the page says so.
  260. * A corrupt value is treated the same way: losing the prose is survivable,
  261. * failing the whole sync over it is not.
  262. */
  263. async function readBriefings(env: Env): Promise<Briefings | null> {
  264. const raw = await env.DASHBOARD.get(KEY.briefings)
  265. if (!raw) return null
  266. try {
  267. const parsed: unknown = JSON.parse(raw)
  268. if (!parsed || typeof parsed !== 'object') return null
  269. const b = parsed as Briefings
  270. return b.briefings && typeof b.briefings === 'object' ? b : null
  271. } catch {
  272. return null
  273. }
  274. }
  275. async function readHistory(env: Env): Promise<HistoryRow[]> {
  276. const raw = await env.DASHBOARD.get(KEY.history)
  277. if (!raw) return []
  278. try {
  279. const parsed: unknown = JSON.parse(raw)
  280. return Array.isArray(parsed) ? (parsed as HistoryRow[]) : []
  281. } catch {
  282. // A corrupt history must not take the sync down. Losing yesterday's delta
  283. // is survivable; losing today's board is not.
  284. return []
  285. }
  286. }
  287. /**
  288. * One sync. Returns the rendered page so the caller can decide what to do with
  289. * a failure rather than having it swallowed here.
  290. *
  291. * `writeHistory` is the one thing that differs between the two crons, and it
  292. * is false for the 15-minute one on purpose. `history.ts` keeps exactly one row per
  293. * America/Chicago day and its header says why: "re-running the build at 9:00
  294. * does not make 'yesterday' mean 'an hour ago'". Ninety-six rows a day would
  295. * turn the day-over-day arrows (the most useful thing on the page) from
  296. * "since yesterday morning" into "since the last quarter hour", without any
  297. * number on the page changing to show it (rule 6). So the morning cron writes
  298. * the day's baseline and every other sync leaves it alone.
  299. *
  300. * Reading history is unaffected: the 15-minute renders still draw their arrows
  301. * against the same morning row.
  302. */
  303. export async function sync(
  304. env: Env,
  305. { writeHistory = true }: { writeHistory?: boolean } = {},
  306. ): Promise<{ html: string; snap: Snapshot }> {
  307. const days = Number(env.PLINTH_WINDOW_DAYS ?? '60') || 60
  308. const snap = await collect(env as unknown as Record<string, string | undefined>, days)
  309. const h = headline(snap)
  310. const day = localDay(new Date(snap.syncedAt))
  311. const today = rowFrom(h, new Date(snap.syncedAt))
  312. // A run where a source died reports zeros, and a zero recorded today becomes
  313. // tomorrow's "yesterday", so the morning after an outage the briefing would
  314. // announce that the design queue grew by 53 overnight. A broken run neither
  315. // writes history nor reads it. Same rule as the local build; see main.ts.
  316. const sourcesOk = snap.leads.ok && snap.orderLines.ok && snap.tasks.ok
  317. const prior = sourcesOk ? await readHistory(env) : []
  318. // The briefings were written from a LAPTOP sync, not from this one, so the two
  319. // will normally carry different timestamps. That is not smoothed over:
  320. // `briefingAge()` labels the gap on the page, which is the honest reading:
  321. // the prose describes the numbers it was written from, and those are not the
  322. // numbers beside it after this cron runs.
  323. //
  324. // The alternative was for `pnpm push` to send only the briefings and let the
  325. // next cron pick them up, which would leave the board briefing-less for a day.
  326. // So the push sends the laptop's rendered page too, and this render is what
  327. // takes over once the numbers move.
  328. const briefings = await readBriefings(env)
  329. // One page per viewer. Each contains only what that person may read; nothing is
  330. // filtered in the browser, because anything sent to a browser has been
  331. // disclosed. See apps/dashboard/src/variants.ts.
  332. const variants = renderVariants(snap, h, prior, day, briefings)
  333. const html = variants.get('company') ?? render(snap, h, prior, day, briefings)
  334. const payload = buildBriefingPayload(snap)
  335. await Promise.all([
  336. ...[...variants].map(([key, page]) => env.DASHBOARD.put(variantKey(key), page)),
  337. env.DASHBOARD.put(KEY.payload, JSON.stringify(payload)),
  338. sourcesOk && writeHistory
  339. ? env.DASHBOARD.put(
  340. KEY.history,
  341. JSON.stringify([...prior.filter((r) => r.day !== today.day), today].slice(-MAX_HISTORY)),
  342. )
  343. : Promise.resolve(),
  344. ])
  345. // The inbox is written separately, not folded into the variants. It renders at
  346. // request time so the filter buttons take effect immediately rather than
  347. // tomorrow, so all the cron owes it is fresh message metadata.
  348. //
  349. // Its failure is contained: `readInboxes` never throws, and a Gmail outage or
  350. // a dead token must not stop the board's five real sources from publishing.
  351. await env.DASHBOARD.put(KEY.inboxMessages, JSON.stringify(await readInboxes(env)))
  352. const failures = [
  353. !snap.leads.ok && `leads: ${snap.leads.error}`,
  354. !snap.orderLines.ok && `orders: ${snap.orderLines.error}`,
  355. !snap.tasks.ok && `tasks: ${snap.tasks.error}`,
  356. !snap.ninety.ok && `ninety: ${snap.ninety.error}`,
  357. ].filter(Boolean)
  358. await env.DASHBOARD.put(
  359. KEY.lastError,
  360. failures.length ? JSON.stringify({ at: snap.syncedAt, failures }) : '',
  361. )
  362. return { html, snap }
  363. }
  364. /**
  365. * The shell for the inbox and the other request-time pages.
  366. *
  367. * A separate page, not a tab inside the pre-rendered board; see the header of
  368. * inbox-view.ts for why. It reuses the board's stylesheet so it does not look
  369. * like a different product, and links back so the tab bar's "Inbox" entry has a
  370. * way home.
  371. *
  372. * `showOnboarding` mirrors the board's own decision. The bar on this page is
  373. * the board's bar, so an entry the board omits for this viewer is omitted here
  374. * too; otherwise the Onboarding tab appears to vanish when you open the inbox.
  375. */
  376. const pageShell = (
  377. body: string,
  378. showOnboarding = false,
  379. title = 'Inbox',
  380. current: 'inbox' | 'none' = 'inbox',
  381. ): string => `<!doctype html>
  382. <html lang="en">
  383. <head>
  384. <script>(function(){try{var m=document.cookie.match(/(?:^|; )theme=([a-z]+)/);var v=m?m[1]:'system';
  385. if(v==='dark'||(v==='system'&&matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.setAttribute('data-theme','dark');}catch(e){}})();</script>
  386. <meta charset="utf-8">
  387. <meta name="viewport" content="width=device-width,initial-scale=1">
  388. <title>Meridian Display: ${esc(title)}</title>
  389. <style>${THEME_CSS}${INBOX_CSS}${EXPORT_CSS}</style>
  390. </head>
  391. <body>
  392. <div class="page-rule" aria-hidden="true"></div>
  393. <div class="page">
  394. <header class="masthead" style="padding:14px 0 0">
  395. <div class="eyebrow" style="margin:0">Meridian Display · Operations</div>
  396. </header>
  397. ${routeTabBar(current, showOnboarding)}
  398. <main id="main">
  399. ${body}
  400. </main>
  401. </div>
  402. </body>
  403. </html>`
  404. const NOT_SYNCED_YET = `<!doctype html><html lang="en"><head><meta charset="utf-8">
  405. <title>Meridian Display: not synced yet</title>
  406. <style>body{font:16px/1.6 system-ui,sans-serif;max-width:46em;margin:4em auto;padding:0 1.5em}</style>
  407. </head><body>
  408. <h1>Not synced yet</h1>
  409. <p>The scheduled sync has not run since this Worker was deployed, so there is no
  410. board to show. This is deliberately not a page of zeros: an empty board and
  411. a board that has never run must never look the same.</p>
  412. <p>Wait for the next scheduled run, or trigger one now:</p>
  413. <form method="post" action="/sync"><button type="submit">Sync now</button></form>
  414. <p style="font-size:14px;color:#555">A full run reads all five sources and the seven
  415. mailboxes and takes about half a minute. It does not write the written briefing;
  416. that is a weekday-morning job on a laptop.</p>
  417. </body></html>`
  418. /**
  419. * One answer for every failure to identify.
  420. *
  421. * Deliberately says nothing about why. A response that distinguished "no
  422. * assertion" from "wrong audience" from "unknown address" would let someone
  423. * outside map the configuration one request at a time. The reason is logged when
  424. * DEBUG is on; it is never returned.
  425. */
  426. const DENIED_HTML = `<!doctype html><html lang="en"><head><meta charset="utf-8">
  427. <title>Meridian Display: not authorised</title>
  428. <style>body{font:16px/1.6 system-ui,sans-serif;max-width:40em;margin:4em auto;padding:0 1.5em}</style>
  429. </head><body>
  430. <h1>Not authorised</h1>
  431. <p>This board could not confirm who you are. Sign in through the Meridian Display
  432. Google Workspace account you normally use.</p>
  433. <p>If you are signed in and still see this, Cloudflare Access is not configured in
  434. front of this Worker. Treat that as an incident rather than a
  435. convenience, because the board carries client names and order numbers.</p>
  436. </body></html>`
  437. /**
  438. * A function, not a shared constant. A Response body is a stream and is consumed
  439. * when it is returned, so handing the same object to a second request would send
  440. * an empty body: a 403 that looks like a broken page rather than a refusal.
  441. */
  442. const denied = (): Response =>
  443. new Response(DENIED_HTML, {
  444. status: 403,
  445. headers: PAGE_HEADERS,
  446. })
  447. async function health(env: Env): Promise<Response> {
  448. const [payload, lastError, inbox] = await Promise.all([
  449. env.DASHBOARD.get(KEY.payload),
  450. env.DASHBOARD.get(KEY.lastError),
  451. env.DASHBOARD.get(KEY.inboxMessages),
  452. ])
  453. const syncedAt = payload ? (JSON.parse(payload) as { generatedAt?: string }).generatedAt : null
  454. const ageMinutes = syncedAt ? Math.round((Date.now() - Date.parse(syncedAt)) / 60_000) : null
  455. /**
  456. * Per-mailbox read status: names and errors, never messages.
  457. *
  458. * This endpoint is identity-free by design, so what it may carry is tightly
  459. * bounded: our own mailbox addresses, whether each one read, and the reason
  460. * when it did not. No sender, no subject, no content of anyone's mail.
  461. *
  462. * It is the only way to check a Gmail setup without deploying: the board
  463. * itself cannot be opened at localhost:8787 (no Access sits in front of
  464. * `wrangler dev`, so the Worker correctly refuses to identify anyone). Setup
  465. * you cannot verify is setup people repeat blindly until it happens to work.
  466. */
  467. const inboxStatus = inbox
  468. ? (() => {
  469. const d = JSON.parse(inbox) as InboxData
  470. return {
  471. syncedAt: d.syncedAt,
  472. read: d.mailboxes.filter((m) => m.ok).map((m) => m.mailbox),
  473. failed: d.mailboxes.filter((m) => !m.ok).map((m) => ({ mailbox: m.mailbox, error: m.error })),
  474. // Can each mailbox send? Names and booleans, still no mail. It is the
  475. // only way to check freshly minted tokens before deploying, and null
  476. // (Google reported no scopes) must be visible as its own answer
  477. // rather than read as false.
  478. canSend: Object.fromEntries(d.mailboxes.map((m) => [m.mailbox, m.canSend ?? null])),
  479. // Counts only: how many messages are held and how many the window
  480. // holds. No sender, no subject. "Does the cap cover the window?" is
  481. // otherwise unanswerable without reading the whole blob.
  482. volume: Object.fromEntries(
  483. d.mailboxes.map((m) => [
  484. m.mailbox,
  485. {
  486. held: m.messages.length,
  487. // Counted by paging the id list, not Gmail's resultSizeEstimate,
  488. // which is capped near the page size and so reports ~501 for
  489. // any larger window.
  490. windowTotal: m.windowTotal ?? null,
  491. truncated: m.truncated ?? false,
  492. failedFetches: m.failedFetches ?? 0,
  493. // What Gmail actually said about the refusals: error text about
  494. // our own requests, not message content.
  495. failureSample: m.failureSample ?? [],
  496. },
  497. ]),
  498. ),
  499. }
  500. })()
  501. : null
  502. return Response.json(
  503. {
  504. ok: Boolean(syncedAt) && !lastError,
  505. // syncedAt stays a UTC ISO string: this is a machine endpoint and an
  506. // instant is the right thing to hand a machine. syncedAtLocal is beside
  507. // it because a person reads this page too, and a bare Z-suffixed
  508. // timestamp reads as the wrong hour to someone in Chicago.
  509. syncedAt,
  510. syncedAtLocal: syncedAt ? `${localStamp(syncedAt)} CT` : null,
  511. ageMinutes,
  512. accessConfigured: Boolean(env.ACCESS_TEAM_DOMAIN && env.ACCESS_AUD),
  513. failures: lastError ? (JSON.parse(lastError) as { failures: string[] }).failures : [],
  514. // null until the first sync writes it, which is different from "no
  515. // mailboxes configured" and reads differently here.
  516. inbox: inboxStatus,
  517. },
  518. { status: syncedAt ? 200 : 503 },
  519. )
  520. }
  521. /**
  522. * Response headers for the board.
  523. *
  524. * The CSP is the valuable one. The page is entirely self-contained (no external
  525. * stylesheet, script, image or font, verified by a test in render.test.ts), so
  526. * `default-src 'none'` costs nothing and removes the payoff from an injection
  527. * that got past escaping. With no `connect-src` and no `img-src` to a remote
  528. * host, a script that somehow ran could not send a briefing anywhere.
  529. *
  530. * `'unsafe-inline'` is unavoidable: the page inlines its own CSS and one small
  531. * script precisely so it works with no network. That is the trade: inline code
  532. * still runs, but it has nowhere to send anything.
  533. *
  534. * `frame-ancestors 'none'` blocks clickjacking, which matters more than usual for
  535. * a page whose whole job is displaying content one person may see and another
  536. * may not.
  537. */
  538. export const PAGE_HEADERS = {
  539. 'content-type': 'text/html; charset=utf-8',
  540. 'content-security-policy':
  541. "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; " +
  542. // form-action 'self': the page's buttons are plain form POSTs rather than
  543. // fetch(), so the board works with JavaScript off and needs no
  544. // `connect-src` at all. 'self' is the narrowest value that permits a
  545. // same-origin POST; the page still cannot submit anywhere else.
  546. // font-src data: the brand fonts are embedded in the page as data: URIs.
  547. // Without it, default-src 'none' blocks them, and the failure is invisible
  548. // on any machine with the fonts installed, because the browser resolves the
  549. // family name from the OS and renders the intended face anyway.
  550. "img-src data:; font-src data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
  551. 'x-content-type-options': 'nosniff',
  552. 'x-frame-options': 'DENY',
  553. 'referrer-policy': 'same-origin',
  554. 'x-robots-tag': 'noindex, nofollow',
  555. } as const
  556. /**
  557. * The open-message page only: identical to PAGE_HEADERS plus `frame-src 'self'`,
  558. * so the one page that embeds the message frame may do so and no other page can
  559. * grow an iframe by accident.
  560. */
  561. export const MESSAGE_PAGE_HEADERS = {
  562. ...PAGE_HEADERS,
  563. 'content-security-policy':
  564. PAGE_HEADERS['content-security-policy'].replace("img-src data:", "img-src data:; frame-src 'self'"),
  565. } as const
  566. /**
  567. * Headers for /inbox/message/frame, the only response that ever contains a
  568. * sender's HTML. Its CSP is the outer defence (the iframe's missing
  569. * `allow-scripts` is the inner one): no scripts of any kind, no form targets,
  570. * no frames-within-frames, no fetch. Images, styles and fonts are what an
  571. * email needs to look like an email, so exactly those load.
  572. * frame-ancestors 'self' (not 'none') because being framed by the board is
  573. * this response's job, and nobody else's.
  574. */
  575. const FRAME_HEADERS = {
  576. 'content-type': 'text/html; charset=utf-8',
  577. 'content-security-policy':
  578. "default-src 'none'; img-src https: http: data: cid:; style-src 'unsafe-inline' https:; " +
  579. "font-src https: data:; script-src 'none'; form-action 'none'; base-uri 'none'; " +
  580. "frame-ancestors 'self'",
  581. 'x-content-type-options': 'nosniff',
  582. 'x-frame-options': 'SAMEORIGIN',
  583. 'referrer-policy': 'no-referrer',
  584. 'x-robots-tag': 'noindex, nofollow',
  585. 'cache-control': 'private, no-store',
  586. } as const
  587. /**
  588. * GMAIL_SA_KEY may be the bare private key or the whole downloaded key JSON.
  589. * People paste the file, and the file also carries the account email, so a
  590. * full-JSON paste needs no GMAIL_SA_EMAIL at all.
  591. * A malformed value returns null here and each mailbox reports the parse error.
  592. */
  593. function serviceAccount(env: Env): ServiceAccount | { error: string } | null {
  594. if (!env.GMAIL_SA_KEY) return null
  595. try {
  596. const parsed = parseServiceAccountSecret(env.GMAIL_SA_KEY)
  597. const email = env.GMAIL_SA_EMAIL || parsed.email
  598. if (!email) {
  599. return { error: 'GMAIL_SA_KEY holds a bare key and GMAIL_SA_EMAIL is not set; one of the two must carry the account email' }
  600. }
  601. return { email, privateKey: parsed.privateKey }
  602. } catch (err) {
  603. return { error: errorMessage(err) }
  604. }
  605. }
  606. /**
  607. * The credential for one mailbox: its own refresh token when set, the service
  608. * account when not, an error naming what is missing when neither.
  609. */
  610. function credentialFor(env: Env, m: (typeof INBOX_MAILBOXES)[number]): GmailCredential | { error: string } {
  611. const secrets = env as unknown as Record<string, string | undefined>
  612. const refreshToken = secrets[m.secret]
  613. if (refreshToken) {
  614. if (!env.GMAIL_CLIENT_ID || !env.GMAIL_CLIENT_SECRET) {
  615. return { error: `${m.secret} is set but GMAIL_CLIENT_ID / GMAIL_CLIENT_SECRET are not; a token is only usable with its client` }
  616. }
  617. return { clientId: env.GMAIL_CLIENT_ID, clientSecret: env.GMAIL_CLIENT_SECRET, refreshToken }
  618. }
  619. const sa = serviceAccount(env)
  620. if (!sa) return { error: `no credential: set the ${m.secret} secret (pnpm inbox:auth) or configure the service account` }
  621. return sa
  622. }
  623. /**
  624. * Read every mailbox. Each one independently, each failure its own.
  625. *
  626. * A missing secret is reported the same way a rejected token is, because from
  627. * the reader's side they are the same fact: that inbox is not on the page and
  628. * here is why. Silently skipping an unconfigured mailbox would make a
  629. * half-finished setup look complete.
  630. */
  631. async function readInboxes(env: Env): Promise<InboxData> {
  632. const mailboxes = await Promise.all(
  633. INBOX_MAILBOXES.map(async (m): Promise<MailboxRead> => {
  634. const cred = credentialFor(env, m)
  635. // canSend null, not false: a mailbox with no credential never had its
  636. // scopes reported, and "we could not ask" is not "it cannot send".
  637. if ('error' in cred) {
  638. return {
  639. mailbox: m.email, ok: false, error: cred.error, messages: [], canSend: null,
  640. windowTotal: null, truncated: false, failedFetches: 0, failureSample: [],
  641. }
  642. }
  643. return readMailbox(m.email, cred, { maxMessages: INBOX_MAX_PER_MAILBOX })
  644. }),
  645. )
  646. return { syncedAt: new Date().toISOString(), mailboxes }
  647. }
  648. /** The filter list, falling back to the committed seed when KV holds nothing. */
  649. async function readFilters(env: Env): Promise<string[]> {
  650. const raw = await env.DASHBOARD.get(KEY.inboxFilters)
  651. if (!raw) return [...MARKETING_SENDERS]
  652. try {
  653. const parsed = JSON.parse(raw) as unknown
  654. // A malformed value falls back to the SEED, not to an empty list. Empty
  655. // would un-hide every newsletter at once, which reads as a broken filter
  656. // rather than as a bad read.
  657. return Array.isArray(parsed)
  658. ? parsed.filter((x): x is string => typeof x === 'string')
  659. : [...MARKETING_SENDERS]
  660. } catch {
  661. return [...MARKETING_SENDERS]
  662. }
  663. }
  664. /**
  665. * The viewer's theme, from the cookie the board's control sets.
  666. *
  667. * Server-stamped so a dark reader never gets a white flash and a no-JS reader
  668. * still gets their choice. 'system' stamps nothing; the page's pre-paint
  669. * script resolves it against prefers-color-scheme, and without JS system means
  670. * light, which is the safe default on a page whose brand ramp is light.
  671. */
  672. function themeFrom(req: Request): 'dark' | 'light' | 'system' {
  673. const m = (req.headers.get('cookie') ?? '').match(/(?:^|;\s*)theme=([a-z]+)/)
  674. return m?.[1] === 'dark' ? 'dark' : m?.[1] === 'light' ? 'light' : 'system'
  675. }
  676. /** Stamp data-theme into a stored page at serve time: one KV copy, any theme. */
  677. function stampTheme(html: string, theme: 'dark' | 'light' | 'system'): string {
  678. if (theme !== 'dark') return html
  679. return html.replace('<html lang="en">', '<html lang="en" data-theme="dark">')
  680. }
  681. /**
  682. * The board's read-marks. Lost or malformed falls back to empty, which here
  683. * means "trust Gmail's own unread state" (rule 12). Contrast readFilters, whose
  684. * safe fallback is the seed, not empty.
  685. */
  686. async function readReadMarks(env: Env): Promise<Record<string, number>> {
  687. const raw = await env.DASHBOARD.get(KEY.inboxRead)
  688. if (!raw) return {}
  689. try {
  690. const parsed = JSON.parse(raw) as unknown
  691. if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
  692. return Object.fromEntries(
  693. Object.entries(parsed as Record<string, unknown>).filter(
  694. (e): e is [string, number] => typeof e[1] === 'number',
  695. ),
  696. )
  697. }
  698. return {}
  699. } catch {
  700. return {}
  701. }
  702. }
  703. /** Marks older than the list window are dead weight; prune on every write. */
  704. const READ_MARK_MAX_AGE_MS = 35 * 24 * 60 * 60 * 1000
  705. export interface BoardNote {
  706. text: string
  707. by: string
  708. at: string
  709. }
  710. /** A lost or malformed notes key degrades to a board with no annotations. */
  711. async function readNotes(env: Env): Promise<Record<string, BoardNote>> {
  712. const raw = await env.DASHBOARD.get(KEY.notes)
  713. if (!raw) return {}
  714. try {
  715. const parsed = JSON.parse(raw) as unknown
  716. if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
  717. return Object.fromEntries(
  718. Object.entries(parsed as Record<string, unknown>).filter(
  719. (e): e is [string, BoardNote] =>
  720. !!e[1] && typeof (e[1] as BoardNote).text === 'string' && typeof (e[1] as BoardNote).by === 'string',
  721. ),
  722. )
  723. }
  724. return {}
  725. } catch {
  726. return {}
  727. }
  728. }
  729. /**
  730. * Notes ride into the pre-rendered page at serve time, like the theme stamp:
  731. * the pages in KV are yesterday's render, and a note typed a minute ago must
  732. * show on the next load without waiting for a cron. The payload is a JSON
  733. * <script> the page's own script reads and renders via textContent. The note
  734. * text is user-written and must never be parsed as HTML, which is also why
  735. * `<` is escaped here: `</script>` inside a note would otherwise end the tag
  736. * and put the rest of the note into the document as markup.
  737. */
  738. export function injectNotes(html: string, notes: Record<string, BoardNote>): string {
  739. const json = JSON.stringify(notes).replace(/</g, '\\u003c')
  740. // Anchor on the last closing-body tag, not the first. Page text cannot
  741. // contain it (esc() encodes <), but a comment in the page's own shipped
  742. // script can, and a first-occurrence replace would splice the JSON into the
  743. // middle of that script.
  744. const at = html.lastIndexOf('</body>')
  745. if (at === -1) return html
  746. return `${html.slice(0, at)}<script id="board-notes" type="application/json">${json}</script>${html.slice(at)}`
  747. }
  748. const NOTE_TEXT_CAP = 2000
  749. /** Tabs the note form may bounce back to; anything else lands on the board. */
  750. const NOTE_BACK_TABS = new Set(['board', 'my-day', 'queue', 'briefing', 'elsewhere', 'health'])
  751. /**
  752. * The collaborators the reply handlers take, bound to this Env.
  753. *
  754. * Injected rather than imported so the handlers can be tested against a fake
  755. * Gmail and a fake clock. `sendMessage` is the one function in this repo that
  756. * reaches a system we cannot take an action back from; a test suite that could
  757. * only reach it through a real deploy would be a test suite that never ran it.
  758. */
  759. function sendDeps(env: Env): SendDeps {
  760. return {
  761. fetchBody: (mailbox, id, cred) => fetchBody(mailbox, id, cred),
  762. sendMessage: (mailbox, cred, msg) => sendMessage(mailbox, cred, msg),
  763. credentialFor: (mailbox) => {
  764. const conf = INBOX_MAILBOXES.find((m) => m.email === mailbox)
  765. return conf ? credentialFor(env, conf) : { error: `unknown mailbox: ${mailbox}` }
  766. },
  767. now: () => Date.now(),
  768. }
  769. }
  770. /** A one-line refusal page in the board's own shell. Never a bare string. */
  771. const inboxNote = (
  772. req: Request,
  773. showOnboarding: boolean,
  774. title: string,
  775. message: string,
  776. ): Response =>
  777. new Response(
  778. stampTheme(
  779. pageShell(
  780. `<section class="section">
  781. <div class="section-head"><h2>${title}</h2></div>
  782. <div class="card" style="padding:22px 28px">
  783. <p class="empty">${message}</p>
  784. <p class="note"><a href="/inbox">Back to the inbox</a></p>
  785. </div>
  786. </section>`,
  787. showOnboarding,
  788. ),
  789. themeFrom(req),
  790. ),
  791. { status: 200, headers: { ...PAGE_HEADERS, 'cache-control': 'private, no-store' } },
  792. )
  793. /**
  794. * How long after a manual sync before another is allowed.
  795. *
  796. * A full sync measures about two minutes, most of it Gmail reads paced to stay
  797. * inside the per-user quota. The cooldown has to outlast a run, or a second
  798. * click lands mid-sync and the two compete for the same rate limits, which is
  799. * the thing it exists to prevent. 150 seconds covers a run with margin.
  800. */
  801. const SYNC_COOLDOWN_MS = 150_000
  802. export interface SyncRequest {
  803. /** ISO, when the run was asked for. */
  804. at: string
  805. /** Who pressed it. Display only; every reader of the board may press it. */
  806. by: string
  807. /**
  808. * Set by the minute cron before it starts, so a request runs once. Two
  809. * invocations overlapping would each launch a full sync and compete for the
  810. * very rate limits the cooldown exists to protect.
  811. */
  812. claimed?: boolean
  813. }
  814. async function readSyncRequest(env: Env): Promise<SyncRequest | null> {
  815. const raw = await env.DASHBOARD.get(KEY.syncRequest)
  816. if (!raw) return null
  817. try {
  818. const p = JSON.parse(raw) as SyncRequest
  819. return typeof p?.at === 'string' && typeof p.by === 'string' ? p : null
  820. } catch {
  821. return null
  822. }
  823. }
  824. /**
  825. * How stale a request may be and still be worth running.
  826. *
  827. * A press from an hour ago is not a request now: the person has closed the tab
  828. * and the quarter-hour cron has run four times since. Running it then would be
  829. * a sync nobody asked for, arriving with no explanation.
  830. *
  831. * Fifteen minutes rather than ten because KV propagation eats into it: a
  832. * request can take over three minutes to become visible to the minute cron.
  833. * The window has to outlast that delay by enough margin that a slow
  834. * propagation never silently drops a press.
  835. */
  836. const SYNC_REQUEST_TTL_MS = 15 * 60_000
  837. /**
  838. * Run a sync somebody asked for, if one is pending. The Sync now button's other
  839. * half.
  840. *
  841. * Why this is not in the route: a fetch handler's `ctx.waitUntil` does not get
  842. * the budget a scheduled one does, and a two-minute sync does not fit in it.
  843. * Run from the route, the sync is cut off silently: nothing is written, not
  844. * even `dashboard:payload`, and `sync:lastError` stays empty. The identical
  845. * sync() completes from a cron. So the button records the request and this
  846. * runs it where it can finish.
  847. */
  848. export async function runRequestedSync(env: Env, now: number = Date.now()): Promise<boolean> {
  849. const req = await readSyncRequest(env)
  850. if (!req || req.claimed) return false
  851. const age = now - Date.parse(req.at)
  852. if (!Number.isFinite(age) || age < 0 || age > SYNC_REQUEST_TTL_MS) return false
  853. // Claim before running. This cron fires every minute and a sync takes two, so
  854. // without the claim the second minute would start a duplicate mid-run.
  855. await env.DASHBOARD.put(KEY.syncRequest, JSON.stringify({ ...req, claimed: true }))
  856. await sync(env, { writeHistory: false })
  857. .then(() => undefined)
  858. .catch((err) =>
  859. env.DASHBOARD.put(
  860. KEY.lastError,
  861. JSON.stringify({ at: new Date().toISOString(), failures: [String(err)] }),
  862. ),
  863. )
  864. return true
  865. }
  866. export type SyncDecision =
  867. | { run: true; record: SyncRequest }
  868. | { run: false; sinceMs: number }
  869. /**
  870. * May this press start a sync?
  871. *
  872. * A pure function, because the route it serves cannot be reached by a test: /sync
  873. * sits behind Cloudflare Access and the suite has no way to mint an assertion for
  874. * it. Same reason inbox-send.ts exists. The decision is the part worth pinning;
  875. * the route around it is four lines.
  876. *
  877. * Fails open. A malformed or missing record means "go ahead": the cooldown
  878. * protects an upstream rate limit, and losing it costs one extra sync. Failing
  879. * closed would let one bad byte permanently disable the only manual control on
  880. * the board, which is a far worse outcome than a duplicated read.
  881. */
  882. export function decideSync(prior: SyncRequest | null, by: string, now: number): SyncDecision {
  883. const record: SyncRequest = { at: new Date(now).toISOString(), by }
  884. if (!prior) return { run: true, record }
  885. const since = now - Date.parse(prior.at)
  886. // A future timestamp (clock skew, a hand-edited key) is not a reason to
  887. // refuse; it is a reason to distrust the record.
  888. if (!Number.isFinite(since) || since < 0 || since >= SYNC_COOLDOWN_MS) {
  889. return { run: true, record }
  890. }
  891. return { run: false, sinceMs: since }
  892. }
  893. /**
  894. * The line the masthead shows about a manual sync, or ''.
  895. *
  896. * Stamped at serve time because the page in KV was rendered by a cron and
  897. * cannot know a sync started after it. Same mechanism as the theme stamp and
  898. * the notes injection.
  899. *
  900. * It reports elapsed time rather than claiming completion. This route cannot
  901. * know whether the run finished (the minute cron runs it), and a "done" it did
  902. * not observe is exactly the kind of unobserved claim the board must never
  903. * make (rule 1). The reader sees the sync was asked for, and the Synced
  904. * timestamp beside it changes when it actually lands.
  905. */
  906. export function syncStateHtml(req: SyncRequest | null, now: number): string {
  907. if (!req) return ''
  908. const age = now - Date.parse(req.at)
  909. if (!Number.isFinite(age) || age < 0) return ''
  910. // Past the cooldown it is just history; the Synced timestamp already says it.
  911. if (age > SYNC_COOLDOWN_MS) return ''
  912. const secs = Math.max(1, Math.round(age / 1000))
  913. // Both estimates are measured, not padded. A run takes about two minutes,
  914. // mostly the paced Gmail reads. Starting takes up to a few minutes because
  915. // KV is eventually consistent: a key written by the route is not
  916. // immediately visible to the minute cron, which can fire more than once
  917. // before it sees the request. A progress note that under-promises is a note
  918. // people stop believing.
  919. const started = req.claimed
  920. return `<span class="sync-state">Sync requested ${secs}s ago by ${esc(req.by)}: ${
  921. started
  922. ? 'running now; another minute or two'
  923. : 'queued; it starts within a few minutes and then takes a minute or two'
  924. }. Reload to see it land.</span>`
  925. }
  926. /** Replace the placeholder the masthead renders. No-op if it is absent. */
  927. export function stampSyncState(html: string, req: SyncRequest | null, now: number): string {
  928. return html.replace('<span id="sync-state"></span>', syncStateHtml(req, now))
  929. }
  930. async function readInboxData(env: Env): Promise<InboxData> {
  931. const raw = await env.DASHBOARD.get(KEY.inboxMessages)
  932. if (!raw) return { syncedAt: null, mailboxes: [] }
  933. try {
  934. return JSON.parse(raw) as InboxData
  935. } catch {
  936. return { syncedAt: null, mailboxes: [] }
  937. }
  938. }
  939. export default {
  940. async scheduled(event: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
  941. // Which cron fired decides what runs. The morning and 15-minute crons both
  942. // run the full sync: order statuses move all day, and a board showing this
  943. // morning's counts at 3pm is a board people stop trusting.
  944. //
  945. // Every source, Bitrix included, runs on the same 15 minutes. A slower
  946. // per-source cadence would oblige the page to date each source separately
  947. // ("as of 14:00" rather than "live"); one clock for every source is simpler
  948. // to trust. Revisiting that trade means taking on that labelling, not just
  949. // a code path.
  950. //
  951. // The difference between the two is history, not data. Only the morning
  952. // run records the day's row, so the day-over-day arrows keep comparing
  953. // mornings rather than quarter-hours. See sync().
  954. //
  955. // No model is called on either path. The written briefings are authored on
  956. // a laptop by analysis/brief.py and read from KV here, so refreshing the
  957. // numbers 96 times a day costs nothing in model spend.
  958. //
  959. // The minute cron is the Sync now button's runner, not a fourth sync. It
  960. // checks for a pending request and does nothing at all when there is none,
  961. // which is almost every minute. See runRequestedSync for why the button
  962. // cannot simply run the work itself.
  963. if (event.cron === '* * * * *') {
  964. ctx.waitUntil(
  965. runRequestedSync(env).catch((err) =>
  966. env.DASHBOARD.put(
  967. KEY.lastError,
  968. JSON.stringify({ at: new Date().toISOString(), failures: [String(err)] }),
  969. ),
  970. ),
  971. )
  972. return
  973. }
  974. const morningRun = event.cron === '0 11 * * 1-5'
  975. ctx.waitUntil(
  976. sync(env, { writeHistory: morningRun })
  977. .then(() => undefined)
  978. .catch((err) =>
  979. env.DASHBOARD.put(
  980. KEY.lastError,
  981. JSON.stringify({ at: new Date().toISOString(), failures: [String(err)] }),
  982. ),
  983. ),
  984. )
  985. },
  986. /**
  987. * Every route answers within its own request. `_ctx` is unused: work that
  988. * outlives a response (the Sync now button's sync) runs from the minute cron
  989. * instead, because a fetch handler's waitUntil cannot hold it. See
  990. * runRequestedSync.
  991. */
  992. async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
  993. const url = new URL(req.url)
  994. // Liveness first, and deliberately identity-free: it carries a timestamp and
  995. // the names of failing sources, no client data, and an uptime monitor cannot
  996. // hold an Access session. Everything below this line requires identity.
  997. if (url.pathname === '/health') return health(env)
  998. // Who is asking: verified from the signed Access assertion, never from the
  999. // email header (see access.ts). A request that cannot be identified is
  1000. // refused outright rather than being shown the company view: an unverifiable
  1001. // request is either Access misconfigured or something going around it, and
  1002. // neither should be answered with client names and order numbers.
  1003. const identity = await identify(
  1004. req,
  1005. env.ACCESS_TEAM_DOMAIN,
  1006. env.ACCESS_AUD,
  1007. (why) => { if (env.DEBUG === 'true') console.log(`access denied: ${why}`) },
  1008. )
  1009. if (!identity) return denied()
  1010. const viewer = viewerFor(identity.email)
  1011. // The briefing payload names every person's to-dos, Leadership Team included,
  1012. // so it is restricted to the readers who may see those, not to anyone who
  1013. // got through Access.
  1014. if (url.pathname === '/payload.json') {
  1015. if (!viewer.seesLeadership) return denied()
  1016. const payload = await env.DASHBOARD.get(KEY.payload)
  1017. return payload
  1018. ? new Response(payload, { headers: { 'content-type': 'application/json; charset=utf-8' } })
  1019. : new Response('{"error":"not synced yet"}', { status: 503, headers: { 'content-type': 'application/json' } })
  1020. }
  1021. // Sync now: re-reads all five sources and the seven mailboxes on demand.
  1022. //
  1023. // No model is called, and that needs no flag: sync() never calls one. The
  1024. // written briefings are authored by analysis/brief.py on a laptop and only
  1025. // ever read from KV here, so "sync without the AI" is simply what a sync is.
  1026. //
  1027. // writeHistory:false is load-bearing. Only the weekday-morning cron records
  1028. // the day's history row, so the day-over-day arrows keep comparing mornings
  1029. // (see sync()). A button that wrote history would let any click silently
  1030. // redefine what "since yesterday" means, and nothing on the page would show
  1031. // that it had happened.
  1032. //
  1033. // Anyone through the Access door may press it: the person who notices
  1034. // stale data is rarely the one who administers the board. The cooldown, not
  1035. // a permission, is what protects the upstream rate limits.
  1036. if (url.pathname === '/sync' && req.method === 'POST') {
  1037. const now = Date.now()
  1038. const decision = decideSync(
  1039. await readSyncRequest(env),
  1040. viewer.person?.name ?? viewer.email ?? 'someone',
  1041. now,
  1042. )
  1043. // Records the request and does not run it; the minute cron picks it up.
  1044. // A sync started here with ctx.waitUntil would be cut off silently (see
  1045. // runRequestedSync).
  1046. if (decision.run) {
  1047. await env.DASHBOARD.put(KEY.syncRequest, JSON.stringify(decision.record))
  1048. }
  1049. // 303 with a cache-buster: the board is served `private, max-age=60`, so
  1050. // without a throwaway param the reload hands back the cached page and the
  1051. // button looks like it did nothing. Same reason the note form does it.
  1052. const backRaw = String(new URL(req.url).searchParams.get('back') ?? '')
  1053. const hash = NOTE_BACK_TABS.has(backRaw) ? `#${backRaw}` : ''
  1054. return new Response(null, {
  1055. status: 303,
  1056. headers: { location: `/?t=${now.toString(36)}${hash}` },
  1057. })
  1058. }
  1059. // Theme preference: a cookie, set server-side so it works with JS off and
  1060. // is readable at serve time. POST-303, same shape as every action here.
  1061. if (url.pathname === '/theme' && req.method === 'POST') {
  1062. const form = await req.formData()
  1063. const v = String(form.get('theme') ?? 'system')
  1064. const value = v === 'dark' || v === 'light' ? v : 'system'
  1065. const back = req.headers.get('referer')
  1066. // Same-origin only: a Location echoed from headers is an open redirect.
  1067. const to = back && new URL(back, url.origin).origin === url.origin ? new URL(back).pathname : '/'
  1068. return new Response(null, {
  1069. status: 303,
  1070. headers: {
  1071. location: to,
  1072. 'set-cookie': `theme=${value}; Path=/; Max-Age=31536000; SameSite=Lax; Secure`,
  1073. },
  1074. })
  1075. }
  1076. // User notes on orders and tasks. Anyone through the Access door may write
  1077. // one: the note is signed with who wrote it, and the whole point is that
  1078. // leadership reads context anybody on the team can leave. Empty text
  1079. // removes the note: the same control that writes a mark can unwrite it.
  1080. if (url.pathname === '/note' && req.method === 'POST') {
  1081. const form = await req.formData()
  1082. const key = String(form.get('key') ?? '').trim()
  1083. const text = String(form.get('text') ?? '').trim().slice(0, NOTE_TEXT_CAP)
  1084. const by = viewer.person?.name ?? viewer.email
  1085. if (WORK_KEY_RE.test(key) && by) {
  1086. const notes = await readNotes(env)
  1087. if (text) notes[key] = { text, by, at: new Date().toISOString() }
  1088. else delete notes[key]
  1089. await env.DASHBOARD.put(KEY.notes, JSON.stringify(notes))
  1090. }
  1091. const backRaw = String(form.get('back') ?? '').trim()
  1092. const hash = NOTE_BACK_TABS.has(backRaw) ? backRaw : 'queue'
  1093. // The board page is cached private for 60s; the throwaway query param
  1094. // makes the post-save load a cache miss so the note is visible at once.
  1095. return new Response(null, {
  1096. status: 303,
  1097. headers: { location: `/?t=${Date.now().toString(36)}#${hash}` },
  1098. })
  1099. }
  1100. // The end-of-day note: three boxes, one person, once a day.
  1101. //
  1102. // Who may post is not who may read, and neither list is `seesLeadership`.
  1103. // `checkinAuthorFor` returns the roster name the row is filed under, or
  1104. // null, and null is a refusal, not a company-view fallback. Without that,
  1105. // anyone through the Access door could write a note under somebody else's
  1106. // name, because the name comes from the address and never from the form.
  1107. //
  1108. // Nothing is trusted from the body except the three answers. The person is
  1109. // the verified identity, the day is this Worker's clock, and a form
  1110. // claiming a different day is refused rather than written somewhere else.
  1111. // See decideCheckin.
  1112. if (url.pathname === '/checkin' && req.method === 'POST') {
  1113. const person = checkinAuthorFor(viewer)
  1114. if (!person) return denied()
  1115. const form = await req.formData()
  1116. const result = await saveCheckin(env, {
  1117. person,
  1118. // Non-null in practice: the grant is keyed by this address. Recorded
  1119. // beside the roster name so the two can be compared later.
  1120. authorEmail: viewer.email ?? '',
  1121. claimedDay: String(form.get('day') ?? '').trim(),
  1122. did: String(form.get('did') ?? ''),
  1123. unsure: String(form.get('unsure') ?? ''),
  1124. need: String(form.get('need') ?? ''),
  1125. })
  1126. // 303 so a refresh cannot repost, and so the form works with scripting
  1127. // off. `t` busts the 60-second private cache; without it the reload
  1128. // hands back the page from before the write and the button looks dead,
  1129. // the same reason the note and sync buttons carry one. The outcome rides
  1130. // in the query string and is re-validated on the way back in.
  1131. return new Response(null, {
  1132. status: 303,
  1133. headers: {
  1134. location: `/?t=${Date.now().toString(36)}&checkin=${result.outcome}#onboarding`,
  1135. },
  1136. })
  1137. }
  1138. /**
  1139. * The private list (rule 15).
  1140. *
  1141. * One path with an `action` field, not four routes. Every action shares the
  1142. * same identity gate, the same `AND person = ?` scoping and the same stale
  1143. * check, and four paths would be four places to forget one of them. The
  1144. * inbox filter route already branches this way.
  1145. *
  1146. * The person is re-derived from the viewer and never read from the form.
  1147. * There is no grant list guarding this table (config/own-list.ts says why
  1148. * the grant-list rule does not reach a store with one reader), so this line
  1149. * and the scoping clause in the SQL are the whole of the access control.
  1150. *
  1151. * No nonce and no confirmation page, deliberately. The nonce-and-confirm
  1152. * bar is for an irreversible outbound action, where the cost lands on a
  1153. * client and cannot be recalled; /inbox/send is the one thing here
  1154. * that clears it. Nothing on this list leaves the building and nothing here
  1155. * is irreversible: completing is undone by reopening, from the same card,
  1156. * in one click. The defence against a cross-site post is the one the rest
  1157. * of the board uses: Access, CSP form-action 'self', SameSite=Lax.
  1158. */
  1159. if (url.pathname === '/own' && req.method === 'POST') {
  1160. const person = ownListPersonFor(viewer)
  1161. if (!person || !hasOwnList(viewer)) return denied()
  1162. const form = await req.formData()
  1163. const result = await saveOwn(env, {
  1164. person,
  1165. // Non-null in practice: a roster match is what produced `person`.
  1166. // Recorded beside the name so the two can be compared later.
  1167. authorEmail: viewer.email ?? '',
  1168. action: String(form.get('action') ?? ''),
  1169. id: String(form.get('id') ?? ''),
  1170. seen: String(form.get('seen') ?? ''),
  1171. text: String(form.get('text') ?? ''),
  1172. due: String(form.get('due') ?? ''),
  1173. priority: String(form.get('priority') ?? ''),
  1174. link: String(form.get('link') ?? ''),
  1175. })
  1176. // 303 so a refresh cannot repost and the form works with scripting off.
  1177. // `t` busts the 60-second private cache; without it the reload hands
  1178. // back the page from before the write and the button looks dead. The
  1179. // fragment is a literal from the allow-list, never echoed from the body.
  1180. return new Response(null, {
  1181. status: 303,
  1182. headers: {
  1183. location: `/?t=${Date.now().toString(36)}&own=${result.outcome}#my-day`,
  1184. },
  1185. })
  1186. }
  1187. /**
  1188. * The training recap: a few hand-written bullets on what a new hire has
  1189. * been trained on, rendered from config/onboarding.ts.
  1190. *
  1191. * Gated on seeing the tab, not on reading the end-of-day notes. The recap
  1192. * prose and the Accomplished line are sentences in config/onboarding.ts, a
  1193. * reviewed file that diffs and deploys, written for exactly this audience.
  1194. * The trainee's own words live in D1 behind CHECKIN_READERS, a shorter list
  1195. * than this page's, and this module cannot see them: it imports
  1196. * config/onboarding.js and nothing else, and a test asserts it.
  1197. *
  1198. * The audience includes the trainee, deliberately: the recap says nothing
  1199. * they would not already hear in person.
  1200. *
  1201. * A viewer with no plan gets the same refusal as any other surface they
  1202. * cannot see, not an empty document: a recap of nothing reads as "nothing
  1203. * has been covered".
  1204. */
  1205. if (url.pathname === '/onboarding/export' && req.method === 'GET') {
  1206. if (!hasOnboarding(viewer)) return denied()
  1207. const plans = onboardingFor(viewer)
  1208. const raw = url.searchParams.get('days')
  1209. const win = parseWindow(raw, exportToday())
  1210. // A retired parameter, acknowledged on the page rather than ignored,
  1211. // because links carrying it exist.
  1212. const staleDetail = url.searchParams.has('detail')
  1213. // The plain-text twin, for somebody who would rather have the file than
  1214. // the page. Served as text/plain and inline. It is three lines of prose
  1215. // to copy, not a download, and a browser that offers to save it instead
  1216. // of showing it has made the one useful thing harder.
  1217. if ((url.searchParams.get('format') ?? '') === 'txt') {
  1218. return new Response(recapTextAll(plans, win), {
  1219. headers: {
  1220. 'content-type': 'text/plain; charset=utf-8',
  1221. 'content-disposition': 'inline',
  1222. 'cache-control': 'private, no-store',
  1223. 'x-content-type-options': 'nosniff',
  1224. },
  1225. })
  1226. }
  1227. const current = win.days === null ? 'all' : String(win.days)
  1228. return new Response(
  1229. stampTheme(
  1230. pageShell(recapPage(plans, win, current, staleDetail), true, 'Training recap', 'none'),
  1231. themeFrom(req),
  1232. ),
  1233. { headers: { ...PAGE_HEADERS, 'cache-control': 'private, no-store' } },
  1234. )
  1235. }
  1236. // The consolidated inbox. Other people's mail, so the permission check is
  1237. // the first thing here and it fails closed: `canReadInbox` reads
  1238. // INBOX_READERS in config/inbox.ts, and a viewer not on it gets the same
  1239. // denial as /payload.json. An access list guessed on someone's behalf is the
  1240. // default that gets asserted and forgotten (rule 13), and here the cost of
  1241. // guessing wrong is a colleague's mail.
  1242. if (url.pathname === '/inbox' || url.pathname.startsWith('/inbox/')) {
  1243. if (!canReadInbox(viewer)) return denied()
  1244. const readable = readableMailboxes(viewer)
  1245. // Every page below carries the board's tab bar, and the bar's Onboarding
  1246. // entry is this viewer's to have or not, decided once, here.
  1247. const shell = (body: string): string => pageShell(body, hasOnboarding(viewer))
  1248. if (url.pathname === '/inbox' && req.method === 'GET') {
  1249. // The recipient filter. Validated against this viewer's grants, so a
  1250. // pasted URL cannot select a mailbox the viewer may not read; an
  1251. // unknown or ungranted value quietly means "all", never an error page.
  1252. const rawSel = (url.searchParams.get('mailbox') ?? '').trim().toLowerCase()
  1253. const selected = readable.includes(rawSel) ? rawSel : null
  1254. const [data, filters, marks, replied] = await Promise.all([
  1255. readInboxData(env),
  1256. readFilters(env),
  1257. readReadMarks(env),
  1258. readReplied(env as unknown as SendEnv),
  1259. ])
  1260. return new Response(
  1261. stampTheme(
  1262. shell(
  1263. inboxPage(
  1264. data,
  1265. filters,
  1266. INBOX_MAILBOXES,
  1267. readable,
  1268. viewer.person?.name ?? viewer.email ?? 'unknown',
  1269. selected,
  1270. new Set(Object.keys(marks)),
  1271. // The replied marks are read from the board's own key, not
  1272. // derived from the message list, which is exactly why they
  1273. // survive the 15-minute rewrite of inbox:messages.
  1274. new Set(Object.keys(replied)),
  1275. // The New message button, only for someone who may send from
  1276. // something. Same list the Reply button consults.
  1277. sendableMailboxes(viewer).length > 0,
  1278. ),
  1279. ),
  1280. themeFrom(req),
  1281. ),
  1282. { headers: { ...PAGE_HEADERS, 'cache-control': 'private, no-store' } },
  1283. )
  1284. }
  1285. // Live search: the one read that goes to Gmail on the request path.
  1286. //
  1287. // The 30-day window runs to several thousand messages across the seven
  1288. // mailboxes, far more than the board holds, and nobody finds an email by
  1289. // scrolling 2,400 rows. So the board keeps a recent slice for browsing
  1290. // and search reaches everything.
  1291. //
  1292. // It costs a token refresh, a list and a batch per mailbox, on a request
  1293. // somebody is waiting for, so `countWindow: false` stops the id walk as
  1294. // soon as the cap is met rather than paging to the end to produce a total
  1295. // nobody asked for.
  1296. if (url.pathname === '/inbox/search' && req.method === 'GET') {
  1297. const raw = (url.searchParams.get('q') ?? '').trim()
  1298. const rawBox = (url.searchParams.get('mailbox') ?? '').trim().toLowerCase()
  1299. // Same rule as the list view: a pasted mailbox this viewer may not read
  1300. // quietly means "all of mine", never an error and never that mailbox.
  1301. const scopedTo = readable.includes(rawBox) ? rawBox : null
  1302. if (!raw) return new Response(null, { status: 303, headers: { location: '/inbox' } })
  1303. const targets = INBOX_MAILBOXES.filter((m) =>
  1304. scopedTo ? m.email === scopedTo : readable.includes(m.email),
  1305. )
  1306. const query = buildSearchQuery(raw)
  1307. const hits: SearchHit[] = await Promise.all(
  1308. targets.map(async (m): Promise<SearchHit> => {
  1309. const cred = credentialFor(env, m)
  1310. if ('error' in cred) {
  1311. return { mailbox: m.email, ok: false, error: cred.error, messages: [], truncated: false }
  1312. }
  1313. const r = await readMailbox(m.email, cred, {
  1314. query,
  1315. maxMessages: SEARCH_RESULT_CAP,
  1316. countWindow: false,
  1317. })
  1318. return {
  1319. mailbox: m.email,
  1320. ok: r.ok,
  1321. error: r.error,
  1322. messages: r.messages,
  1323. truncated: r.truncated,
  1324. }
  1325. }),
  1326. )
  1327. return new Response(
  1328. stampTheme(shell(searchResultsPage(raw, hits, INBOX_MAILBOXES, scopedTo)), themeFrom(req)),
  1329. // no-store: these rows are message metadata from someone's mailbox and
  1330. // must not sit in any cache, shared or not, same rule as the list.
  1331. { headers: { ...PAGE_HEADERS, 'cache-control': 'private, no-store' } },
  1332. )
  1333. }
  1334. // Read one message. Fetched from Gmail on the click, never from KV; the
  1335. // board stores metadata and does not accumulate seven people's mail. Costs
  1336. // a token refresh plus one Gmail call, for the message actually opened.
  1337. if (url.pathname === '/inbox/message' && req.method === 'GET') {
  1338. const mailbox = (url.searchParams.get('mailbox') ?? '').trim().toLowerCase()
  1339. const id = (url.searchParams.get('id') ?? '').trim()
  1340. // The mailbox must be one this viewer may read. Without this check the id
  1341. // in the query string is enough to read any mailbox the Worker holds a
  1342. // token for, which would make the per-mailbox grants decorative.
  1343. if (!mailbox || !id || !readable.includes(mailbox)) return denied()
  1344. const conf = INBOX_MAILBOXES.find((m) => m.email === mailbox)
  1345. const cred = conf ? credentialFor(env, conf) : { error: 'unknown mailbox' }
  1346. const [body, marks, data, sigs, replied] = await Promise.all([
  1347. 'error' in cred ? Promise.resolve({ error: cred.error }) : fetchBody(mailbox, id, cred),
  1348. readReadMarks(env),
  1349. readInboxData(env),
  1350. readSignatures(env as unknown as SendEnv),
  1351. readReplied(env as unknown as SendEnv),
  1352. ])
  1353. return new Response(
  1354. stampTheme(
  1355. shell(
  1356. messagePage(body, conf?.label ?? mailbox, `${mailbox}/${id}` in marks, {
  1357. states: sendStates(data, INBOX_MAILBOXES, sendableMailboxes(viewer), sigs),
  1358. // A fresh nonce per render. Two Reply clicks are two replies;
  1359. // a refresh of the same compose form is one, and the send
  1360. // route refuses the second.
  1361. nonce: crypto.randomUUID(),
  1362. sigs,
  1363. replied: replied[`${mailbox}/${id}`] ?? null,
  1364. }),
  1365. ),
  1366. themeFrom(req),
  1367. ),
  1368. {
  1369. // no-store: a message body must not sit in any cache, shared or not.
  1370. // MESSAGE_PAGE_HEADERS: the one CSP that permits the same-origin frame.
  1371. headers: { ...MESSAGE_PAGE_HEADERS, 'cache-control': 'private, no-store' },
  1372. },
  1373. )
  1374. }
  1375. // The frame: the only response a sender's HTML ever rides in. Same access
  1376. // checks as the page that embeds it: a direct hit on this URL is just a
  1377. // narrower view of the same message the viewer may already read.
  1378. if (url.pathname === '/inbox/message/frame' && req.method === 'GET') {
  1379. const mailbox = (url.searchParams.get('mailbox') ?? '').trim().toLowerCase()
  1380. const id = (url.searchParams.get('id') ?? '').trim()
  1381. if (!mailbox || !id || !readable.includes(mailbox)) return denied()
  1382. const conf = INBOX_MAILBOXES.find((m) => m.email === mailbox)
  1383. const cred = conf ? credentialFor(env, conf) : { error: 'unknown mailbox' }
  1384. const body = 'error' in cred ? { error: cred.error } : await fetchBody(mailbox, id, cred)
  1385. if ('error' in body || !body.html) {
  1386. // The parent page already rendered the text fallback or the error;
  1387. // an empty frame document keeps this route from ever inventing one.
  1388. return new Response('<!doctype html><meta charset="utf-8">', { headers: FRAME_HEADERS })
  1389. }
  1390. // <base target="_blank"> so the email's links escape the sandboxed frame
  1391. // into a new tab instead of dying inside it. Prepended, not injected into
  1392. // the sender's markup; the parser hoists it into <head> regardless.
  1393. return new Response(`<!doctype html><meta charset="utf-8"><base target="_blank">${body.html}`, {
  1394. headers: FRAME_HEADERS,
  1395. })
  1396. }
  1397. // ---------------------------------------------------------------------
  1398. // Sending: the one place this board writes to a system outside itself
  1399. // (rule 12).
  1400. //
  1401. // Every route below checks the send grant (canSendFrom, directly or
  1402. // through sendableMailboxes) and never readableMailboxes():
  1403. // the whole roster may read every mailbox, and none of that is permission
  1404. // to write to a client as somebody else. Refusals are 403 through the
  1405. // same denied() as everything else. The page never renders a control the
  1406. // viewer cannot use, so a 403 here means a hand-built request.
  1407. // ---------------------------------------------------------------------
  1408. // A new message: the compose box. Gated on the send grant and nothing
  1409. // else: reading seven mailboxes is not permission to write to
  1410. // a stranger as one of them. There is no message to read here, so this is
  1411. // the one outbound surface that touches no original at all.
  1412. if (url.pathname === '/inbox/new' && req.method === 'GET') {
  1413. const sendable = sendableMailboxes(viewer)
  1414. if (!sendable.length) return denied()
  1415. const [data, sigs] = await Promise.all([
  1416. readInboxData(env),
  1417. readSignatures(env as unknown as SendEnv),
  1418. ])
  1419. return new Response(
  1420. stampTheme(
  1421. shell(
  1422. newMessagePage(
  1423. sendStates(data, INBOX_MAILBOXES, sendable, sigs),
  1424. crypto.randomUUID(),
  1425. sigs,
  1426. // Their own mailbox preselected when they may send from it,
  1427. // the same default the reply form uses.
  1428. viewer.email && sendable.includes(viewer.email) ? viewer.email : null,
  1429. ),
  1430. ),
  1431. themeFrom(req),
  1432. ),
  1433. // MESSAGE_PAGE_HEADERS: the form frames the signature preview.
  1434. { headers: { ...MESSAGE_PAGE_HEADERS, 'cache-control': 'private, no-store' } },
  1435. )
  1436. }
  1437. // An attachment, straight from Gmail to the browser.
  1438. //
  1439. // The read grant on that mailbox is checked exactly as /inbox/message
  1440. // checks it: without it the id in a query string would be enough to pull
  1441. // a file out of any mailbox the Worker holds a token for.
  1442. //
  1443. // Addressed by index, not by Gmail's attachment id. The message is
  1444. // re-read and re-walked here, so the id used to fetch is one this request
  1445. // observed rather than one a URL carried; a URL-supplied attachment id
  1446. // would let a request name a part of a message it never opened.
  1447. //
  1448. // Always a download, never a render. The content type is discarded and
  1449. // replaced with application/octet-stream, with `Content-Disposition:
  1450. // attachment` and nosniff on top. An attachment is arbitrary bytes from
  1451. // whoever emailed us; served with its declared type on the board's own
  1452. // origin, an HTML or SVG one would run as this board. That is the same
  1453. // threat the message frame exists to contain (see body.ts), and this is
  1454. // the cheaper answer to it: nothing here is ever a document.
  1455. if (url.pathname === '/inbox/attachment' && req.method === 'GET') {
  1456. const mailbox = (url.searchParams.get('mailbox') ?? '').trim().toLowerCase()
  1457. const id = (url.searchParams.get('id') ?? '').trim()
  1458. const n = Number(url.searchParams.get('n') ?? '')
  1459. if (!mailbox || !id || !readable.includes(mailbox)) return denied()
  1460. if (!Number.isInteger(n) || n < 0) return denied()
  1461. const conf = INBOX_MAILBOXES.find((m) => m.email === mailbox)
  1462. const cred = conf ? credentialFor(env, conf) : { error: 'unknown mailbox' }
  1463. if ('error' in cred) return inboxNote(req, hasOnboarding(viewer), 'That attachment could not be read', esc(cred.error))
  1464. const body = await fetchBody(mailbox, id, cred)
  1465. if ('error' in body) {
  1466. return inboxNote(req, hasOnboarding(viewer), 'That attachment could not be read', esc(body.error))
  1467. }
  1468. const att = body.attachments[n]
  1469. if (!att) {
  1470. return inboxNote(
  1471. req,
  1472. hasOnboarding(viewer),
  1473. 'That attachment is not on this message',
  1474. `The message has ${body.attachments.length} ${body.attachments.length === 1 ? 'part' : 'parts'} with a filename, and this link asked for number ${n + 1}. Nothing was downloaded.`,
  1475. )
  1476. }
  1477. if (!att.attachmentId) {
  1478. return inboxNote(
  1479. req,
  1480. hasOnboarding(viewer),
  1481. 'Gmail returned no handle for that part',
  1482. 'There is nothing to fetch with, so it cannot be downloaded here. The message itself is in Gmail.',
  1483. )
  1484. }
  1485. if (att.size !== null && att.size > ATTACHMENT_DOWNLOAD_CAP) {
  1486. return inboxNote(
  1487. req,
  1488. hasOnboarding(viewer),
  1489. 'That attachment is too big for this board',
  1490. `It is ${att.size.toLocaleString()} bytes, over the ${ATTACHMENT_DOWNLOAD_CAP.toLocaleString()}-byte ceiling this board can hold in memory. Nothing was downloaded; open it in Gmail.`,
  1491. )
  1492. }
  1493. const got = await fetchAttachment(mailbox, id, att.attachmentId, cred)
  1494. if ('error' in got) {
  1495. return inboxNote(req, hasOnboarding(viewer), 'That attachment could not be read', esc(got.error))
  1496. }
  1497. return new Response(got.bytes, {
  1498. headers: {
  1499. 'content-type': 'application/octet-stream',
  1500. // mimeParam strips CR, LF, quote and backslash, because the filename came
  1501. // from whoever sent the mail, and it is going into a header.
  1502. 'content-disposition': `attachment; filename="${mimeParam(att.name || 'attachment')}"`,
  1503. 'content-length': String(got.bytes.byteLength),
  1504. 'content-security-policy': "default-src 'none'; sandbox",
  1505. 'x-content-type-options': 'nosniff',
  1506. 'referrer-policy': 'no-referrer',
  1507. 'x-robots-tag': 'noindex, nofollow',
  1508. 'cache-control': 'private, no-store',
  1509. },
  1510. })
  1511. }
  1512. // The compose form on its own page: the no-JavaScript path, and where
  1513. // the Reply link points before the page's script upgrades it to a modal.
  1514. if (url.pathname === '/inbox/compose' && req.method === 'GET') {
  1515. const mailbox = (url.searchParams.get('mailbox') ?? '').trim().toLowerCase()
  1516. const id = (url.searchParams.get('id') ?? '').trim()
  1517. if (!mailbox || !id || !readable.includes(mailbox)) return denied()
  1518. const conf = INBOX_MAILBOXES.find((m) => m.email === mailbox)
  1519. const cred = conf ? credentialFor(env, conf) : { error: 'unknown mailbox' }
  1520. const [body, data, sigs] = await Promise.all([
  1521. 'error' in cred ? Promise.resolve({ error: cred.error }) : fetchBody(mailbox, id, cred),
  1522. readInboxData(env),
  1523. readSignatures(env as unknown as SendEnv),
  1524. ])
  1525. return new Response(
  1526. stampTheme(
  1527. shell(
  1528. composePage(
  1529. body,
  1530. conf?.label ?? mailbox,
  1531. sendStates(data, INBOX_MAILBOXES, sendableMailboxes(viewer), sigs),
  1532. crypto.randomUUID(),
  1533. sigs,
  1534. ),
  1535. ),
  1536. themeFrom(req),
  1537. ),
  1538. // MESSAGE_PAGE_HEADERS: the form frames the signature preview.
  1539. { headers: { ...MESSAGE_PAGE_HEADERS, 'cache-control': 'private, no-store' } },
  1540. )
  1541. }
  1542. // The confirmation page. Sends nothing and writes nothing; it re-reads
  1543. // the original and shows what the send would actually do. Not a 303,
  1544. // because a preview is not an action: refreshing re-renders it.
  1545. if (url.pathname === '/inbox/compose' && req.method === 'POST') {
  1546. const form = await req.formData()
  1547. const out = await handleCompose(viewer, env as unknown as SendEnv, form, readable, sendDeps(env))
  1548. if (out.kind === 'denied') return denied()
  1549. if (out.kind === 'error') {
  1550. return inboxNote(req, hasOnboarding(viewer), 'Nothing was sent', esc(out.message))
  1551. }
  1552. if (out.kind === 'redirect') {
  1553. return new Response(null, { status: 303, headers: { location: out.location } })
  1554. }
  1555. const [data, sigs] = await Promise.all([
  1556. readInboxData(env),
  1557. readSignatures(env as unknown as SendEnv),
  1558. ])
  1559. const states = sendStates(data, INBOX_MAILBOXES, sendableMailboxes(viewer), sigs)
  1560. const st = states.find((x) => x.email === out.outgoing.from)
  1561. if (!st) return denied()
  1562. return new Response(
  1563. stampTheme(
  1564. shell(confirmPage(out.outgoing, out.orig, st, out.nonce, sigs, out.staged)),
  1565. themeFrom(req),
  1566. ),
  1567. { headers: { ...MESSAGE_PAGE_HEADERS, 'cache-control': 'private, no-store' } },
  1568. )
  1569. }
  1570. // The send. Irreversible, so: the nonce is claimed first, the record is
  1571. // written before Gmail is called, and the answer is a 303 so a refresh
  1572. // reloads the outcome page rather than reposting.
  1573. if (url.pathname === '/inbox/send' && req.method === 'POST') {
  1574. const form = await req.formData()
  1575. const out = await handleSend(viewer, env as unknown as SendEnv, form, readable, sendDeps(env))
  1576. if (out.kind === 'denied') return denied()
  1577. if (out.kind === 'error') return inboxNote(req, hasOnboarding(viewer), 'Nothing was sent', esc(out.message))
  1578. if (out.kind === 'confirm') return denied()
  1579. return new Response(null, { status: 303, headers: { location: out.location } })
  1580. }
  1581. // What this board has sent. Readable by anyone who may read the inbox;
  1582. // the point of the record is that a shared mailbox can see who answered.
  1583. if (url.pathname === '/inbox/sent' && req.method === 'GET') {
  1584. const refNonce = (url.searchParams.get('ref') ?? '').trim()
  1585. const [listed, ref] = await Promise.all([
  1586. listSent(env as unknown as SendEnv),
  1587. refNonce ? readRecord(env as unknown as SendEnv, refNonce) : Promise.resolve(null),
  1588. ])
  1589. return new Response(
  1590. stampTheme(
  1591. shell(
  1592. sentPage(listed.rows, listed.truncated, ref, url.searchParams.get('dup') === '1'),
  1593. ),
  1594. themeFrom(req),
  1595. ),
  1596. { headers: { ...PAGE_HEADERS, 'cache-control': 'private, no-store' } },
  1597. )
  1598. }
  1599. // Signatures. Read by anyone with the inbox; written only by someone who
  1600. // may send as that mailbox; a signature is what goes out over its name.
  1601. if (url.pathname === '/inbox/signatures' && req.method === 'GET') {
  1602. const sigs = await readSignatures(env as unknown as SendEnv)
  1603. const rejectedBox = (url.searchParams.get('rejected') ?? '').trim().toLowerCase()
  1604. const over = Number(url.searchParams.get('over') ?? '')
  1605. return new Response(
  1606. stampTheme(
  1607. shell(
  1608. signaturesPage(
  1609. INBOX_MAILBOXES,
  1610. sigs,
  1611. sendableMailboxes(viewer),
  1612. rejectedBox && Number.isFinite(over) && over > 0
  1613. ? { mailbox: rejectedBox, over }
  1614. : null,
  1615. ),
  1616. ),
  1617. themeFrom(req),
  1618. ),
  1619. // The page frames each signature preview.
  1620. { headers: { ...MESSAGE_PAGE_HEADERS, 'cache-control': 'private, no-store' } },
  1621. )
  1622. }
  1623. if (url.pathname === '/inbox/signatures' && req.method === 'POST') {
  1624. const form = await req.formData()
  1625. const out = await handleSignaturesPost(
  1626. viewer,
  1627. env as unknown as SendEnv,
  1628. form,
  1629. () => Date.now(),
  1630. )
  1631. if (out.kind === 'denied') return denied()
  1632. return new Response(null, { status: 303, headers: { location: out.location } })
  1633. }
  1634. // The signature frame: the only response a signature's HTML rides in,
  1635. // exactly as /inbox/message/frame is for a sender's. Same headers, same
  1636. // reasoning: it is user-written markup and must never reach a board page.
  1637. if (url.pathname === '/inbox/signatures/frame' && req.method === 'GET') {
  1638. const mailbox = (url.searchParams.get('mailbox') ?? '').trim().toLowerCase()
  1639. const sigs = await readSignatures(env as unknown as SendEnv)
  1640. const html = sigs.ok ? sigs.map[mailbox]?.html : undefined
  1641. if (!INBOX_MAILBOXES.some((m) => m.email === mailbox) || !html) {
  1642. return new Response('<!doctype html><meta charset="utf-8">', { headers: FRAME_HEADERS })
  1643. }
  1644. return new Response(`<!doctype html><meta charset="utf-8"><base target="_blank">${html}`, {
  1645. headers: FRAME_HEADERS,
  1646. })
  1647. }
  1648. // Mark read / unread: writes the board's own inbox:read key, never Gmail,
  1649. // so the tokens stay read-only. Same shape as the filter endpoints: the
  1650. // message must exist in a mailbox this viewer may read, or (for unread)
  1651. // already carry a mark to remove.
  1652. if (url.pathname === '/inbox/read' && req.method === 'POST') {
  1653. const form = await req.formData()
  1654. const mailbox = String(form.get('mailbox') ?? '').trim().toLowerCase()
  1655. const id = String(form.get('id') ?? '').trim()
  1656. const op = String(form.get('op') ?? '')
  1657. const key = `${mailbox}/${id}`
  1658. if (mailbox && id && (op === 'read' || op === 'unread') && readable.includes(mailbox)) {
  1659. const [data, marks] = await Promise.all([readInboxData(env), readReadMarks(env)])
  1660. const known = data.mailboxes
  1661. .find((m) => m.mailbox === mailbox)
  1662. ?.messages.some((x) => x.id === id)
  1663. if (op === 'read' ? known : key in marks) {
  1664. const now = Date.now()
  1665. const next = Object.fromEntries(
  1666. Object.entries(marks).filter(([k, at]) => k !== key && now - at < READ_MARK_MAX_AGE_MS),
  1667. )
  1668. if (op === 'read') next[key] = now
  1669. await env.DASHBOARD.put(KEY.inboxRead, JSON.stringify(next))
  1670. }
  1671. }
  1672. // 303 like the filter buttons: refresh-safe, works with JavaScript off,
  1673. // and only a mailbox this viewer may read is echoed into the redirect.
  1674. const backRaw = String(form.get('back') ?? '').trim().toLowerCase()
  1675. const back = readable.includes(backRaw) ? `/inbox?mailbox=${encodeURIComponent(backRaw)}` : '/inbox'
  1676. return new Response(null, { status: 303, headers: { location: back } })
  1677. }
  1678. const action =
  1679. url.pathname === '/inbox/filter' ? 'add' : url.pathname === '/inbox/unfilter' ? 'remove' : null
  1680. if (action && req.method === 'POST') {
  1681. const form = await req.formData()
  1682. const sender = String(form.get('sender') ?? '').trim().toLowerCase()
  1683. // The sender must be one we actually saw, in a mailbox this viewer may
  1684. // read. Without that check this endpoint writes arbitrary strings into
  1685. // KV on behalf of anyone through the Access door, and lets a reader act
  1686. // on a mailbox they cannot see.
  1687. const data = await readInboxData(env)
  1688. const known = new Set(
  1689. data.mailboxes
  1690. .filter((m) => readable.includes(m.mailbox))
  1691. .flatMap((m) => m.messages.map((x) => x.sender)),
  1692. )
  1693. const filters = await readFilters(env)
  1694. // An operational sender cannot be filtered: shipment tracking, failed
  1695. // payments and meeting notices (see KNOWN_NOT_MARKETING). matchesFilter
  1696. // already refuses to hide them, so a stray entry would be inert; refusing
  1697. // the write too means the list never contains a lie about what is hidden.
  1698. if (action === 'add' && isOperational(sender)) {
  1699. const opBack = String(form.get('mailbox') ?? '').trim().toLowerCase()
  1700. return new Response(null, {
  1701. status: 303,
  1702. headers: { location: readable.includes(opBack) ? `/inbox?mailbox=${encodeURIComponent(opBack)}` : '/inbox' },
  1703. })
  1704. }
  1705. if (sender && (known.has(sender) || (action === 'remove' && filters.includes(sender)))) {
  1706. const next =
  1707. action === 'add'
  1708. ? [...new Set([...filters, sender])].sort()
  1709. : filters.filter((f) => f !== sender)
  1710. await env.DASHBOARD.put(KEY.inboxFilters, JSON.stringify(next))
  1711. }
  1712. // 303 so a refresh does not repost, and so the button works with no
  1713. // JavaScript at all. The redirect returns to the same mailbox view the
  1714. // click came from, but only a mailbox this viewer may read is echoed
  1715. // into the location header; anything else falls back to /inbox. A
  1716. // redirect built from unvalidated form input is an open redirect.
  1717. const backRaw = String(form.get('mailbox') ?? '').trim().toLowerCase()
  1718. const back = readable.includes(backRaw) ? `/inbox?mailbox=${encodeURIComponent(backRaw)}` : '/inbox'
  1719. return new Response(null, { status: 303, headers: { location: back } })
  1720. }
  1721. return new Response('Not found', { status: 404, headers: PAGE_HEADERS })
  1722. }
  1723. // This viewer's page, and no fallback to a richer one. If their variant is
  1724. // missing the answer is "not synced yet", never the company page dressed up
  1725. // as theirs and never somebody else's. A fallback that quietly widened access
  1726. // would be indistinguishable from working.
  1727. //
  1728. // Whose end-of-day notes this viewer may read: usually nobody, and then D1
  1729. // is not touched at all. The query runs only for the readers and the
  1730. // author; everyone else's page costs no D1 read.
  1731. const checkinPeople = readableCheckinAuthors(viewer)
  1732. // The viewer's own private list, and the name comes from the roster via the
  1733. // verified identity, never from the query string, the form or a header.
  1734. // There is no plural read here on purpose (see apps/worker/src/own-list.ts).
  1735. const ownPerson = ownListPersonFor(viewer)
  1736. const [html, notes, syncReq, checkins, own] = await Promise.all([
  1737. env.DASHBOARD.get(variantKey(viewer.key)),
  1738. readNotes(env),
  1739. readSyncRequest(env),
  1740. checkinPeople.length
  1741. ? readCheckinsFor(env, checkinPeople)
  1742. : Promise.resolve({} as Record<string, CheckinStore>),
  1743. ownPerson ? readOwnList(env, ownPerson) : Promise.resolve(null),
  1744. ])
  1745. // Stamped at serve time, like the theme, the notes and the sync line. The
  1746. // page in KV was rendered by a cron that could not know which day it is
  1747. // when somebody opens it, let alone what was written this evening. The
  1748. // placeholder is only in the markup for a viewer with a grant, and this
  1749. // call re-checks the grant anyway: a page with no markers is returned
  1750. // untouched, and a viewer with no grant stamps an empty string over them.
  1751. const block = checkinPeople.length
  1752. ? checkinBlock(viewer, checkins, new Date(), parseOutcome(url.searchParams.get('checkin')))
  1753. : ''
  1754. // Also stamped at serve time. Two reasons it cannot be pre-rendered, and
  1755. // the second is the one that matters: a line added a minute ago must show
  1756. // on the next load, and no private text may ever be written into KV. What
  1757. // sits in KV is the placeholder.
  1758. const ownBlock = own
  1759. ? ownListBlock(
  1760. viewer,
  1761. own,
  1762. new Date(),
  1763. parseOwnOutcome(url.searchParams.get('own')),
  1764. // Whether "on my board" above this card is a dash rather than a
  1765. // number, so the card says the right sentence about what it is not
  1766. // counted in. The same expression peopleBoard() in collect.ts uses
  1767. // for `untracked`, which is what makes `load` null in the first
  1768. // place. It is read from the roster here because the Worker has the
  1769. // viewer and not the view model.
  1770. !viewer.person || viewer.person.coveredBy.length === 0,
  1771. )
  1772. : ''
  1773. return new Response(
  1774. html
  1775. ? stampOwnList(
  1776. stampCheckin(
  1777. stampSyncState(injectNotes(stampTheme(html, themeFrom(req)), notes), syncReq, Date.now()),
  1778. block,
  1779. ),
  1780. ownBlock,
  1781. )
  1782. : NOT_SYNCED_YET,
  1783. {
  1784. status: html ? 200 : 503,
  1785. headers: {
  1786. ...PAGE_HEADERS,
  1787. // The page is a snapshot, not live. A short cache keeps a team all
  1788. // opening it at 9:10 off the KV read path. Private, so no shared cache
  1789. // can hold one person's variant and hand it to another.
  1790. 'cache-control': 'private, max-age=60',
  1791. },
  1792. },
  1793. )
  1794. },
  1795. }