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

inbox-view.ts

Language TypeScript86.1 KB1831 lines

apps/dashboard/src/inbox-view.ts

  1. /**
  2. * The consolidated inbox, rendered: Gmail-shaped, Meridian-skinned.
  3. *
  4. * Gmail's anatomy (sender, subject-with-preview on one line, unread bold, a
  5. * mailbox rail on the left, a time column on the right) with the board's
  6. * identity: light, Inter, the four-color rule. Not Gmail's chrome.
  7. *
  8. * It is not in surfaces.ts with the other surfaces because they are
  9. * pre-rendered into KV by the cron from one snapshot, while this one renders
  10. * at request time from its own KV keys, so the filter buttons and the mailbox
  11. * rail take effect on the click. The cost is that the board is no longer one
  12. * self-contained file.
  13. *
  14. * Runtime-neutral: no node:fs, because the Worker imports this (rule 7).
  15. */
  16. import { esc, join, map, plural } from './html.js'
  17. import { MERIDIAN_TZ, localDay, localStamp } from '@meridian/ninety-client'
  18. import {
  19. ATTACHMENT_LIMITS,
  20. RECIPIENT_CAP,
  21. isOperational,
  22. matchesFilter,
  23. type Mailbox,
  24. } from '../../../config/inbox.js'
  25. import {
  26. ATTACHMENT_DOWNLOAD_CAP,
  27. BODY_CAP,
  28. HTML_CAP,
  29. type Attachment,
  30. type InboxMessage,
  31. type MailboxRead,
  32. type MessageBody,
  33. } from '@meridian/gmail-client'
  34. export interface InboxData {
  35. syncedAt: string | null
  36. mailboxes: MailboxRead[]
  37. }
  38. export interface SenderGroup {
  39. sender: string
  40. senderName: string
  41. count: number
  42. newest: string
  43. mailboxes: string[]
  44. messages: InboxMessage[]
  45. }
  46. /**
  47. * Group by sender. Used by the marketing bucket, where the action (un-filter)
  48. * is per-sender, and by the POST handler's validation.
  49. */
  50. export function groupBySender(messages: InboxMessage[]): SenderGroup[] {
  51. const by = new Map<string, SenderGroup>()
  52. for (const m of messages) {
  53. const g = by.get(m.sender)
  54. if (g) {
  55. g.count++
  56. g.messages.push(m)
  57. if (m.date > g.newest) g.newest = m.date
  58. if (!g.mailboxes.includes(m.mailbox)) g.mailboxes.push(m.mailbox)
  59. if (!g.senderName && m.senderName) g.senderName = m.senderName
  60. } else {
  61. by.set(m.sender, {
  62. sender: m.sender,
  63. senderName: m.senderName,
  64. count: 1,
  65. newest: m.date,
  66. mailboxes: [m.mailbox],
  67. messages: [m],
  68. })
  69. }
  70. }
  71. return [...by.values()].sort((a, b) => (a.newest < b.newest ? 1 : a.newest > b.newest ? -1 : 0))
  72. }
  73. /**
  74. * Gmail's time column: clock time today, month-day for anything older.
  75. * Both in Meridian's timezone; the reader is in Chicago, not UTC.
  76. */
  77. export function rowTime(iso: string, now: Date = new Date()): string {
  78. const t = Date.parse(iso)
  79. if (!Number.isFinite(t) || t <= 0) return '–'
  80. if (localDay(new Date(t)) === localDay(now)) {
  81. return new Intl.DateTimeFormat('en-US', { timeZone: MERIDIAN_TZ, hour: 'numeric', minute: '2-digit' }).format(new Date(t))
  82. }
  83. return new Intl.DateTimeFormat('en-US', { timeZone: MERIDIAN_TZ, month: 'short', day: 'numeric' }).format(new Date(t))
  84. }
  85. const agoText = (iso: string | null): string => {
  86. if (!iso) return 'never synced'
  87. const mins = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 60_000))
  88. return mins < 1 ? 'synced just now' : mins < 60 ? `synced ${mins} min ago` : `synced ${Math.round(mins / 60)}h ago`
  89. }
  90. /**
  91. * The inbox's own stylesheet, served with the page by the Worker shell.
  92. * Gmail's anatomy, the board's tokens. Kept beside the markup that uses it.
  93. */
  94. export const INBOX_CSS = `
  95. .inbox-layout{display:grid;grid-template-columns:232px 1fr;gap:20px;align-items:start}
  96. .inbox-rail{position:sticky;top:14px;background:var(--surface-card);border:1px solid var(--border-default);
  97. border-radius:var(--radius-md);padding:8px;display:flex;flex-direction:column;gap:2px}
  98. .inbox-rail a{
  99. display:flex;justify-content:space-between;align-items:center;gap:8px;
  100. padding:9px 12px;border-radius:var(--radius-sm);color:var(--text-body);font-size:13.5px;
  101. }
  102. .inbox-rail a:hover{background:var(--meridian-gray-050)}
  103. .inbox-rail a[aria-current="true"]{background:var(--wash);border-left:2px solid var(--strong);
  104. padding-left:10px;font-weight:600}
  105. .inbox-rail .cnt{font-size:12px;color:var(--text-muted);font-variant-numeric:tabular-nums}
  106. .inbox-rail .dead{font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;
  107. background:var(--hatch-ink);color:var(--surface-card);padding:2px 7px;border-radius:3px}
  108. :root[data-theme="dark"] .inbox-rail .dead{color:var(--slab)}
  109. .inbox-fail{border:1px solid var(--strong);border-radius:var(--radius-sm);overflow:hidden;margin:0 0 12px}
  110. .inbox-fail .strip{height:4px;background:var(--hatch-ink)}
  111. .inbox-fail .in{padding:10px 14px;font-size:12.5px}
  112. .inbox-fail .err{font-family:var(--font-mono);font-size:11.5px;background:var(--meridian-gray-050);
  113. padding:1px 5px;border-radius:3px}
  114. .msg-section{display:flex;align-items:center;gap:10px;margin:0;padding:12px 16px 4px;font-size:10px;
  115. font-weight:600;letter-spacing:.16em;text-transform:uppercase;color:var(--text-muted)}
  116. .msg-section::after{content:"";flex:1;height:1px;background:var(--hairline)}
  117. /* The yield order is deliberate: THE SUBJECT NEVER YIELDS. It holds a 170px
  118. floor; the mailbox chip collapses first (min-width:0), then the sender
  119. shrinks toward 96px. The action column is FIXED so it stops competing: a
  120. plain 1fr+auto pair collapses the subject to zero between ~720 and 1050px,
  121. which is inside the supported desktop range. */
  122. .msg-row{display:grid;grid-template-columns:2px 10px minmax(96px,168px) minmax(170px,1fr) 104px minmax(0,116px) 58px;
  123. column-gap:11px;padding:10px 18px 10px 0;align-items:center;
  124. border-bottom:1px solid var(--hairline);background:var(--surface-card2);position:relative}
  125. .msg-row .edge{align-self:stretch}
  126. .msg-row.unread{background:var(--surface-card)}
  127. .msg-row.unread .edge{background:var(--hue-ours)}
  128. .msg-row:hover{background:var(--hover-row)}
  129. .msg-row .dot{width:8px;height:8px;border-radius:50%;border:1px solid var(--border-default)}
  130. .msg-row.unread .dot{background:var(--hue-ours);border-color:var(--hue-ours)}
  131. .msg-from{font-size:13.5px;color:var(--text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  132. .msg-subj{font-size:13.5px;color:var(--text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  133. .msg-subj .snip{color:var(--text-muted);font-weight:400}
  134. .msg-row.unread .msg-from,.msg-row.unread .msg-subj{font-weight:600;color:var(--text-body)}
  135. .msg-link{position:absolute;inset:0;z-index:1}
  136. .msg-box{font-size:10.5px;padding:2px 8px;border:1px solid var(--border-default);border-radius:3px;
  137. color:var(--text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;
  138. background:var(--surface-card)}
  139. .msg-time{font-size:12px;color:var(--text-muted);white-space:nowrap;text-align:right;font-variant-numeric:tabular-nums}
  140. .msg-act{position:relative;z-index:2;visibility:hidden;width:104px;
  141. display:flex;flex-direction:column;gap:4px;align-items:stretch}
  142. .msg-act form{margin:0}
  143. .msg-act button{width:100%}
  144. .msg-row:hover .msg-act,.msg-row:focus-within .msg-act{visibility:visible}
  145. .msg-act button{cursor:pointer;font-size:11px;padding:3px 9px;border:1px solid var(--border-default);
  146. border-radius:var(--radius-sm);background:var(--surface-card);color:var(--text-body)}
  147. .msg-act button:hover{border-color:var(--hue-waiting);color:var(--hue-waiting)}
  148. /* Email HTML is designed for a white page; the frame stays white in both themes,
  149. like Gmail's own message pane, rather than inverting a sender's colors. */
  150. .mail-frame{width:100%;border:1px solid var(--border-default);border-radius:4px;
  151. background:#fff;min-height:240px;display:block}
  152. /* The open-message action bar, above the body, so the two things a reader does
  153. (leave, mark handled) never require scrolling to the end of a long email. */
  154. .msg-actions{display:flex;justify-content:space-between;align-items:center;gap:12px;
  155. flex-wrap:wrap;padding:12px 28px;border-bottom:1px solid var(--border-default)}
  156. .msg-actions form{margin:0}
  157. .msg-btn{display:inline-block;cursor:pointer;font:600 12px/1 var(--font-body,inherit);
  158. padding:8px 14px;border:1px solid var(--border-default);border-radius:4px;
  159. background:var(--surface-card);color:var(--text-body);text-decoration:none}
  160. .msg-btn:hover{border-color:var(--hue-waiting);color:var(--hue-waiting)}
  161. /* Inside a row the same form renders at row scale; .msg-act's rules win on
  162. size, this puts the weight back so both row buttons match. */
  163. .msg-act .msg-btn{font-weight:400}
  164. .msg-act .op{display:inline-block;font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;
  165. border:1px solid var(--strong);color:var(--text-body);padding:2px 7px;border-radius:3px}
  166. .inbox-live{display:flex;gap:8px;align-items:center;margin:10px 0 0;flex-wrap:wrap}
  167. .inbox-live .inbox-search{margin:0;flex:1 1 320px}
  168. .inbox-search{margin:0 0 10px;width:100%;max-width:420px;padding:8px 14px;font:inherit;font-size:13px;
  169. border:1px solid var(--border-default);border-radius:var(--radius-sm);background:var(--surface-card);
  170. color:var(--text-body)}
  171. /* The filtered bucket is an ink drawer: present, quiet, unmistakably elsewhere. */
  172. .inbox-drawer{background:var(--slab);color:var(--on-slab-dim)}
  173. .inbox-drawer summary{cursor:pointer;font-weight:600;font-size:13px;color:var(--on-slab);padding:12px 16px}
  174. .inbox-drawer .row{border-bottom:1px solid var(--slab-line)}
  175. .inbox-drawer .addr{font-family:var(--font-mono);font-size:11px;color:var(--on-slab-dim)}
  176. .inbox-drawer b{color:var(--on-slab)}
  177. .inbox-drawer button{cursor:pointer;font-size:11px;padding:3px 10px;border:1px solid var(--on-slab-dim);
  178. border-radius:var(--radius-sm);background:transparent;color:var(--on-slab)}
  179. .inbox-drawer button:hover{background:var(--slab-line)}
  180. /* THE SEARCH BOX FILTERED NOTHING FOR THREE DAYS, and this rule is why it does
  181. now. SEARCH_SCRIPT hides a row by setting its hidden property, which relies on
  182. the user-agent stylesheet's [hidden] rule, and a UA rule loses to ANY author
  183. rule, whatever the specificity. .msg-row is display:grid two dozen lines up,
  184. so every "hidden" row stayed on screen. Typing in the box did visibly nothing.
  185. theme.ts has a .surface[hidden] rule but it is scoped to .surface and does not
  186. reach here. If you give a filtered element a display of its own, add it to
  187. this selector or you will reintroduce the bug.
  188. (No backticks in this comment on purpose: INBOX_CSS is a template literal and
  189. one backtick here would end the stylesheet mid-file.) */
  190. .msg-row[hidden],.msg-sect[hidden],.msg-none[hidden]{display:none}
  191. .msg-none{padding:20px 16px;font-size:13px;color:var(--text-muted)}
  192. /* REPLY, COMPOSE AND SIGNATURES (2026-09-04). */
  193. /* The replied chip. Board-owned and PERSISTENT: it is not derived from the
  194. 15-minute sync and is not cleared by marking read, because "somebody already
  195. answered this" is the fact a shared mailbox most needs to keep. */
  196. .msg-replied{display:inline-block;font-size:10px;font-weight:600;letter-spacing:.1em;
  197. text-transform:uppercase;border:1px solid var(--hue-done,var(--strong));color:var(--hue-done,var(--text-body));
  198. padding:2px 7px;border-radius:3px;white-space:nowrap}
  199. .compose-dialog{border:1px solid var(--border-default);border-radius:var(--radius-md);
  200. background:var(--surface-card);color:var(--text-body);padding:0;width:min(640px,92vw);max-height:88vh;
  201. overflow:auto}
  202. .compose-dialog::backdrop{background:rgba(0,0,0,.45)}
  203. .compose-dialog .in{padding:20px 24px 22px}
  204. .compose-head{font-family:var(--font-display);font-size:19px;margin:0 0 4px}
  205. .from-list{display:flex;flex-direction:column;gap:2px;margin:8px 0 4px;
  206. border:1px solid var(--hairline);border-radius:var(--radius-sm);padding:6px}
  207. .from-list label{display:flex;gap:9px;align-items:flex-start;padding:6px 8px;border-radius:var(--radius-sm);
  208. font-size:13px;line-height:1.45}
  209. .from-list label:hover{background:var(--meridian-gray-050)}
  210. .from-list input[disabled]+span{color:var(--text-muted)}
  211. .from-list .why{display:block;font-size:11.5px;color:var(--text-muted)}
  212. .from-list .ro{display:inline-block;font-size:9.5px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;
  213. border:1px solid var(--border-default);color:var(--text-muted);padding:1px 6px;border-radius:3px;margin-left:6px}
  214. .compose-body{width:100%;min-height:170px;padding:10px 12px;font:14px/1.6 var(--font-body,system-ui);
  215. border:1px solid var(--border-default);border-radius:var(--radius-sm);background:var(--surface-card);
  216. color:var(--text-body);resize:vertical}
  217. .compose-meta{font-size:12px;color:var(--text-muted);line-height:1.65;margin:10px 0 0}
  218. .compose-meta b{color:var(--text-body)}
  219. /* THE CAVEATS ARE NOT META. Sam, 2026-09-08: bright and much larger, so that
  220. anyone sending from a shared mailbox sees what this board does not carry:
  221. the Cc, and attachments. It kept the muted 12px of the metadata beside it,
  222. which is the size the page uses for things nobody has to read. Overrides
  223. .compose-meta by order, so it must stay AFTER it. */
  224. .compose-caveats{font-size:17px;font-weight:600;color:var(--caveat);line-height:1.5;
  225. margin:14px 0 0;padding:12px 14px;border:2px solid var(--caveat);border-radius:var(--radius-sm)}
  226. .compose-caveats b{color:var(--caveat)}
  227. .compose-actions{display:flex;gap:10px;align-items:center;margin-top:14px;flex-wrap:wrap}
  228. .compose-field{width:100%;padding:8px 10px;font:13.5px/1.5 var(--font-body,system-ui);
  229. border:1px solid var(--border-default);border-radius:var(--radius-sm);background:var(--surface-card);
  230. color:var(--text-body);margin:2px 0 0}
  231. .compose-file{font-size:12.5px;margin:4px 0 0;color:var(--text-body)}
  232. /* The attachment list on a message, and the same shape on the confirmation
  233. page. A row, not a chip: a name alone was all the old surface could show,
  234. and a size is half of what tells a reader whether to open it here. */
  235. .att-row{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;padding:4px 0;font-size:12.5px}
  236. .att-row .n{font-weight:600;color:var(--text-body);word-break:break-all}
  237. .att-row .s{color:var(--text-muted);font-variant-numeric:tabular-nums}
  238. .att-row .why{color:var(--text-muted)}
  239. /* .err is defined inside .inbox-fail; the compose surfaces show verbatim
  240. errors too and must not lose the monospace that marks them as machine text. */
  241. .compose-meta .err,.from-list .err,.sent-detail .err{font-family:var(--font-mono);font-size:11.5px;
  242. background:var(--meridian-gray-050);padding:1px 5px;border-radius:3px}
  243. .sig-frame{width:100%;height:150px;border:1px solid var(--border-default);border-radius:4px;background:#fff;display:block}
  244. .sig-card{border:1px solid var(--border-default);border-radius:var(--radius-sm);padding:14px 16px;margin:0 0 12px}
  245. .sig-card h3{font-size:14px;margin:0 0 2px}
  246. .sent-row{display:grid;grid-template-columns:120px 1fr 96px;gap:12px;padding:10px 0;
  247. border-bottom:1px solid var(--hairline);font-size:13px;align-items:baseline}
  248. .sent-row .st{font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase}
  249. .sent-row .st.sent{color:var(--hue-done,var(--text-body))}
  250. .sent-row .st.failed{color:var(--hue-ours,var(--strong))}
  251. .sent-row .st.sending{color:var(--text-muted)}
  252. @media (max-width:720px){
  253. .sent-row{grid-template-columns:1fr}
  254. .inbox-layout{grid-template-columns:1fr}
  255. .inbox-rail{position:static;flex-direction:row;overflow-x:auto;padding:6px}
  256. .inbox-rail a{white-space:nowrap;border:1px solid var(--border-default)}
  257. .inbox-rail a[aria-current="true"]{border-color:var(--strong)}
  258. /* Two-line rows, every cell placed explicitly; auto-placement once put the
  259. filter button on top of the subject. */
  260. .msg-row{grid-template-columns:2px 10px minmax(0,1fr) auto;grid-template-rows:auto auto auto;row-gap:2px}
  261. .msg-row .edge{grid-row:1 / span 3}
  262. .msg-row .dot{grid-column:2;grid-row:1}
  263. .msg-from{grid-column:3;grid-row:1}
  264. .msg-time{grid-column:4;grid-row:1}
  265. .msg-subj{grid-column:3 / span 2;grid-row:2}
  266. .msg-box{display:none}
  267. .msg-act{grid-column:3 / span 2;grid-row:3;visibility:visible;justify-self:start;width:auto}
  268. .msg-act button{min-height:32px}
  269. }
  270. `
  271. /**
  272. * Enables the search box only when scripting is available; hidden otherwise.
  273. *
  274. * It does three things, and the second and third exist because doing only the
  275. * first would leave the page lying. A section header carries a count rendered on
  276. * the server (`Unread · 12`); filtering the rows underneath it without
  277. * recounting leaves a number that no longer describes the list beneath it, which
  278. * is the failure rule 9 is about. And a query that matches nothing has
  279. * to say so, otherwise it is indistinguishable from an empty inbox.
  280. */
  281. const SEARCH_SCRIPT = `<script>(function(){
  282. var i=document.getElementById('inbox-search');if(!i)return;
  283. i.parentElement.hidden=false;
  284. var rows=[].slice.call(document.querySelectorAll('.msg-row[data-search]'));
  285. var sects=[].slice.call(document.querySelectorAll('.msg-sect'));
  286. var none=document.getElementById('msg-none');
  287. i.addEventListener('input',function(){
  288. var q=i.value.trim().toLowerCase(),shown=0;
  289. rows.forEach(function(r){
  290. var hit=!q||r.getAttribute('data-search').indexOf(q)>=0;
  291. r.hidden=!hit;
  292. if(hit)shown++;
  293. });
  294. sects.forEach(function(s){
  295. var n=s.querySelectorAll('.msg-row:not([hidden])').length;
  296. var c=s.querySelector('.msg-count');
  297. if(c)c.textContent=String(n);
  298. s.hidden=n===0;
  299. });
  300. if(none)none.hidden=!(q&&shown===0);
  301. });
  302. })();</script>`
  303. /** The board's read-state key for one message. Matches the Worker's KV map. */
  304. export const readKey = (m: { mailbox: string; id: string }): string => `${m.mailbox}/${m.id}`
  305. /**
  306. * Mark read / mark unread: the board's memory, never Gmail's. The tokens stay
  307. * read-only, so this writes the board's own `inbox:read` KV key (rule 12's
  308. * bar: board-owned, falls back to Gmail's real unread state if the key is
  309. * lost, reversible from this button).
  310. * A message read in Gmail itself gets no button; there is no board state to
  311. * flip, and pretending to unmark Gmail's state would be a lie.
  312. */
  313. function markForm(m: { mailbox: string; id: string }, op: 'read' | 'unread', selected: string | null): string {
  314. return `<form method="post" action="/inbox/read">
  315. <input type="hidden" name="mailbox" value="${esc(m.mailbox)}">
  316. <input type="hidden" name="id" value="${esc(m.id)}">
  317. <input type="hidden" name="op" value="${op}">
  318. ${selected ? `<input type="hidden" name="back" value="${esc(selected)}">` : ''}
  319. <button class="msg-btn" type="submit" title="${op === 'read' ? 'Mark read on this board. Gmail still shows it unread' : 'Put it back in Unread on this board'}">${op === 'read' ? 'Mark read' : 'Mark unread'}</button>
  320. </form>`
  321. }
  322. function messageRow(
  323. m: InboxMessage,
  324. label: (e: string) => string,
  325. selected: string | null,
  326. boardRead: boolean,
  327. replied: boolean,
  328. ): string {
  329. const openHref = `/inbox/message?mailbox=${encodeURIComponent(m.mailbox)}&id=${encodeURIComponent(m.id)}`
  330. const haystack = `${m.senderName} ${m.sender} ${m.subject} ${m.snippet}`.toLowerCase()
  331. const displayUnread = m.unread && !boardRead
  332. return `<div class="msg-row${displayUnread ? ' unread' : ''}" data-search="${esc(haystack)}">
  333. <span class="edge" aria-hidden="true"></span>
  334. <span class="dot" aria-hidden="true"></span>
  335. <span class="msg-from">${esc(m.senderName || m.sender)}</span>
  336. <span class="msg-subj">${
  337. // The chip rides inside the subject cell rather than taking a grid column
  338. // of its own: this row's columns are tuned so the subject never yields,
  339. // and an eighth child would undo that.
  340. replied ? '<span class="msg-replied">replied</span> ' : ''
  341. }${esc(m.subject || '(no subject)')}${
  342. m.snippet ? ` <span class="snip">· ${esc(m.snippet)}</span>` : ''
  343. }</span>
  344. <span class="msg-box">${esc(label(m.mailbox))}</span>
  345. <span class="msg-time">${esc(rowTime(m.date))}</span>
  346. <a class="msg-link" href="${esc(openHref)}" aria-label="Open: ${esc(m.subject || '(no subject)')} from ${esc(m.senderName || m.sender)}"></a>
  347. <span class="msg-act">${
  348. isOperational(m.sender)
  349. ? `<span class="op" title="Protected in config/inbox.ts: shipments, payments and meeting notices are never filtered">operational</span>`
  350. : `<form method="post" action="/inbox/filter">
  351. <input type="hidden" name="sender" value="${esc(m.sender)}">
  352. ${selected ? `<input type="hidden" name="mailbox" value="${esc(selected)}">` : ''}
  353. <button type="submit" title="Hide every message from ${esc(m.sender)} on this board">Filter sender</button>
  354. </form>`
  355. }${displayUnread ? markForm(m, 'read', selected) : boardRead ? markForm(m, 'unread', selected) : ''}</span>
  356. </div>`
  357. }
  358. /**
  359. * The header line for failures. A failed mailbox is named with its reason,
  360. * never folded into a total: an inbox nobody could read and an inbox with
  361. * nothing in it are not the same thing (the prime invariant, per mailbox).
  362. */
  363. function failBanner(failed: MailboxRead[]): string {
  364. if (!failed.length) return ''
  365. return `<div class="inbox-fail">
  366. <div class="strip" aria-hidden="true"></div>
  367. <div class="in">
  368. <b>${failed.length} ${plural(failed.length, 'inbox', 'inboxes')} could not be read.</b>
  369. ${map(failed, (m) => `<div style="margin-top:4px">${esc(m.mailbox)}: <span class="err">${esc(m.error ?? 'unknown')}</span></div>`)}
  370. <div style="margin-top:4px;color:var(--text-muted)">Their mail is missing from this list, not absent.</div>
  371. </div>
  372. </div>`
  373. }
  374. /**
  375. * Signatures: one per mailbox, board-owned state (rule 12).
  376. *
  377. * A missing signature and an unreadable signature store are different facts
  378. * and are rendered differently. Unlike the notes key, a malformed value here is not
  379. * swallowed into an empty map: a lost note is a missing annotation, but a
  380. * signature silently dropped changes a client-facing email that has already
  381. * gone out.
  382. */
  383. export interface Signature {
  384. html: string
  385. /** Who set it. */
  386. by: string
  387. /** ISO. */
  388. at: string
  389. }
  390. export type SignatureStore =
  391. | { ok: true; map: Record<string, Signature> }
  392. | { ok: false; error: string }
  393. /**
  394. * Big enough for any hand-written HTML signature (2-8 KB is typical), small
  395. * enough to refuse a `data:` logo, which is what actually blows past it, and
  396. * which Gmail's web client does not render in received mail anyway. The page
  397. * says: host the image, paste the URL.
  398. */
  399. export const SIGNATURE_CAP = 20_000
  400. /** One file on a sent message, as the audit row remembers it. Never bytes. */
  401. export interface SentAttachment {
  402. name: string
  403. size: number
  404. }
  405. /**
  406. * The board's record of one send attempt, success or failure.
  407. *
  408. * No message text. Subject and addresses are what an audit needs; the body is
  409. * in the sending mailbox's Sent folder, which is where a copy of a client email
  410. * belongs. Storing the text here would make the board a place someone's
  411. * outgoing mail accumulates, which is the thing readMailbox is careful not to
  412. * be either.
  413. */
  414. export interface SendRecord {
  415. nonce: string
  416. at: string
  417. by: string
  418. byEmail: string
  419. from: string
  420. to: string
  421. subject: string
  422. /**
  423. * The Cc that went out, or null for none.
  424. *
  425. * Undefined on rows written before the record had a Cc field, and that is
  426. * not the same as null: an old row does not record "no Cc", it records
  427. * nothing, and the page says "not recorded" for it. Reading `undefined` as
  428. * "no Cc" would put a claim about what left the building onto a row that
  429. * never made one.
  430. */
  431. cc?: string | null
  432. /** 'reply' or 'new'. Undefined on rows written before compose existed. */
  433. mode?: 'reply' | 'new'
  434. /**
  435. * The mailbox and id of the message being answered. Null for a new message,
  436. * which answers none. Never undefined: rows from before new messages existed
  437. * always carry a string.
  438. */
  439. originalMailbox: string | null
  440. originalId: string | null
  441. originalMessageId: string | null
  442. threadIdSent: string | null
  443. signatureApplied: boolean
  444. /** Files that went with it. Undefined on rows from before attachments; see `cc`. */
  445. attachments?: SentAttachment[]
  446. /**
  447. * The listing's stand-in for `attachments`, because KV metadata caps at
  448. * 1,024 bytes and five filenames do not reliably fit. Null means the count
  449. * was not recorded, never zero.
  450. */
  451. attachmentCount?: number | null
  452. outcome: 'sending' | 'sent' | 'failed'
  453. gmailId: string | null
  454. gmailThreadId: string | null
  455. error: string | null
  456. candidates: string[]
  457. }
  458. /**
  459. * The board's persistent replied mark.
  460. *
  461. * Keyed exactly like a read mark (`mailbox/id`) and stored in the board's own
  462. * KV, which is the whole mechanism. Deriving it from the message list would
  463. * lose it every fifteen minutes when the sync rewrites `inbox:messages`, and
  464. * deriving it from Gmail's own state would need a scope the board does not
  465. * have. Board-owned, so nothing upstream can erase it, and unaffected by
  466. * marking read: a message can be answered and still want reading.
  467. */
  468. export interface RepliedMark {
  469. /** Epoch ms. */
  470. at: number
  471. /** Who replied: the viewer, not the mailbox. */
  472. by: string
  473. /** Which mailbox it went out as. */
  474. from: string
  475. }
  476. /**
  477. * Whether this viewer can send as each mailbox, and why not when they cannot.
  478. *
  479. * Five states, and no two of them render the same (the prime invariant applied
  480. * to a permission rather than a number). The three that look alike at a glance
  481. * are the three that need opposite fixes:
  482. *
  483. * readonly-token the credential works and lacks gmail.send -> re-mint it
  484. * scope-unknown Google did not report the scopes -> nothing to do; try it
  485. * failed the mailbox could not be read at all -> fix the credential
  486. *
  487. * Collapsing any pair of those into "unavailable" would send someone to the
  488. * wrong page of the runbook.
  489. */
  490. export interface MailboxSendState {
  491. email: string
  492. label: string
  493. state: 'sendable' | 'readonly-token' | 'scope-unknown' | 'failed' | 'not-granted'
  494. /** Only for 'failed'. The mailbox's own read error, verbatim. */
  495. error: string | null
  496. signature: 'set' | 'none' | 'store-failed'
  497. }
  498. /**
  499. * Derive the per-mailbox send states. Order matters and is asserted by a test:
  500. * a mailbox nobody granted is `not-granted` even if its token is perfect, and a
  501. * failed read is `failed` before anything is said about its scopes, because on
  502. * a failed read we never learned them.
  503. */
  504. export function sendStates(
  505. data: InboxData,
  506. mailboxes: Mailbox[],
  507. sendable: string[],
  508. sigs: SignatureStore,
  509. ): MailboxSendState[] {
  510. return mailboxes.map((m) => {
  511. const read = data.mailboxes.find((x) => x.mailbox === m.email)
  512. const signature: MailboxSendState['signature'] = !sigs.ok
  513. ? 'store-failed'
  514. : sigs.map[m.email]
  515. ? 'set'
  516. : 'none'
  517. if (!sendable.includes(m.email)) {
  518. return { email: m.email, label: m.label, state: 'not-granted', error: null, signature }
  519. }
  520. if (read && !read.ok) {
  521. return { email: m.email, label: m.label, state: 'failed', error: read.error, signature }
  522. }
  523. const canSend = read?.canSend ?? null
  524. const state: MailboxSendState['state'] =
  525. canSend === false ? 'readonly-token' : canSend === null ? 'scope-unknown' : 'sendable'
  526. return { email: m.email, label: m.label, state, error: null, signature }
  527. })
  528. }
  529. /** The mailboxes a Reply button could actually go out from. */
  530. export const offerable = (states: MailboxSendState[]): MailboxSendState[] =>
  531. states.filter((s) => s.state === 'sendable' || s.state === 'scope-unknown')
  532. /**
  533. * Why the row is per-signature-state. Never shares wording between the two
  534. * absences. Says "message", not "reply", because the same control is on the
  535. * new-message form.
  536. */
  537. function signatureNote(st: MailboxSendState, sigs: SignatureStore): string {
  538. if (st.signature === 'set') {
  539. return `Its signature will be appended. <a href="/inbox/signatures">Signatures</a>`
  540. }
  541. if (st.signature === 'store-failed') {
  542. return `The signature store could not be read${
  543. sigs.ok ? '' : `: <span class="err">${esc(sigs.error)}</span>`
  544. }, so this message goes out <b>without one</b>. That is a failed read, not an empty signature.`
  545. }
  546. return `No signature is set for this mailbox, so the message goes out without one. <a href="/inbox/signatures">Set one</a>`
  547. }
  548. /**
  549. * The compose form. One function, three surfaces: the modal on the message
  550. * page, the full page for a reader with JavaScript off, and (read-only) the
  551. * confirmation page. Keeping it one function is what stops the no-JS path from
  552. * quietly becoming a different form with different rules.
  553. *
  554. * Note what is not here: no To field, no Subject field, no Cc, no thread id and
  555. * no attachment control. All of those are decided on the server from the
  556. * original message. A recipient taken from a form field is a recipient the
  557. * request can choose, and this is the one action on the board that cannot be
  558. * undone.
  559. */
  560. export function composeForm(
  561. orig: MessageBody,
  562. states: MailboxSendState[],
  563. nonce: string,
  564. sigs: SignatureStore,
  565. ): string {
  566. const can = offerable(states)
  567. const notGranted = states.filter((s) => s.state === 'not-granted')
  568. const blocked = states.filter((s) => s.state === 'readonly-token' || s.state === 'failed')
  569. const preselect = can.find((s) => s.email === orig.mailbox) ?? can[0]
  570. // Everyone the original names, for the reply-all label. The server decides
  571. // the actual list (it removes us and the primary recipient); this is the
  572. // header as it arrived, and the label says which it is.
  573. const others = [orig.to, orig.cc].filter(Boolean).join(', ')
  574. return `<form method="post" action="/inbox/compose" class="in" enctype="multipart/form-data">
  575. <h2 class="compose-head">Reply</h2>
  576. <p class="compose-meta" style="margin-top:0">
  577. To <b>${esc(orig.replyTo || orig.sender)}</b>${
  578. orig.replyTo ? ' <span class="why">(the message asked for replies to go here, not to its From address)</span>' : ''
  579. }<br>
  580. Subject <b>Re: ${esc(orig.subject || '(no subject)')}</b>
  581. </p>
  582. <input type="hidden" name="mailbox" value="${esc(orig.mailbox)}">
  583. <input type="hidden" name="id" value="${esc(orig.id)}">
  584. <input type="hidden" name="nonce" value="${esc(nonce)}">
  585. <input type="hidden" name="mode" value="reply">
  586. ${fromList([...can, ...blocked], preselect?.email ?? null, sigs, notGranted.length)}
  587. ${
  588. others
  589. ? `<p class="compose-meta"><b>Who gets it</b></p>
  590. <div class="from-list">
  591. <label>
  592. <input type="checkbox" name="all" value="1">
  593. <span>Reply to everyone on the original
  594. <span class="why">Adds the original&rsquo;s To and Cc as Cc: ${esc(others)}.
  595. This board takes out you and the person you are answering, so the exact
  596. list is the one on the next screen: at most ${RECIPIENT_CAP} in total,
  597. and over that it refuses rather than dropping anybody.</span>
  598. </span>
  599. </label>
  600. </div>`
  601. : ''
  602. }
  603. <p class="compose-meta"><b>Message</b></p>
  604. <textarea class="compose-body" name="body" required
  605. placeholder="Type your reply. Plain text; the signature adds the formatting."></textarea>
  606. ${attachmentField()}
  607. <p class="compose-meta compose-caveats">
  608. ${
  609. orig.cc
  610. ? `Cc on the original: ${esc(orig.cc)}. <b>Not included unless you tick &ldquo;Reply to everyone&rdquo; above.</b><br>`
  611. : ''
  612. }
  613. ${attachmentCaveat()}<br>
  614. Nothing is sent yet. The next screen shows exactly what goes out.
  615. </p>
  616. <div class="compose-actions">
  617. <button class="msg-btn" type="submit"${can.length ? '' : ' disabled'}>Review the reply &rarr;</button>
  618. <button class="msg-btn" type="button" data-compose-cancel hidden>Cancel</button>
  619. <a class="msg-btn" href="/inbox/message?mailbox=${encodeURIComponent(orig.mailbox)}&amp;id=${encodeURIComponent(orig.id)}">Back to the message</a>
  620. </div>
  621. </form>`
  622. }
  623. /**
  624. * The "Send it as" radio list, shared by the reply and new-message forms. Two
  625. * copies of a list of which mailboxes may send is exactly the sort of pair
  626. * that drifts until one of them offers a mailbox the other refuses.
  627. */
  628. function fromList(
  629. states: MailboxSendState[],
  630. preselect: string | null,
  631. sigs: SignatureStore,
  632. notGranted: number,
  633. ): string {
  634. return `<p class="compose-meta"><b>Send it as</b></p>
  635. <div class="from-list">
  636. ${map(
  637. states,
  638. (st) => `<label>
  639. <input type="radio" name="from" value="${esc(st.email)}"
  640. ${st.state === 'readonly-token' || st.state === 'failed' ? 'disabled' : ''}
  641. ${preselect && st.email === preselect ? 'checked' : ''}>
  642. <span>${esc(st.label)} &lt;${esc(st.email)}&gt;${
  643. st.state === 'scope-unknown' ? '<span class="ro">unverified</span>' : ''
  644. }${st.state === 'readonly-token' ? '<span class="ro">read-only</span>' : ''}${
  645. st.state === 'failed' ? '<span class="ro">failed</span>' : ''
  646. }
  647. ${
  648. st.state === 'readonly-token'
  649. ? `<span class="why">Cannot send from this mailbox: its token is read-only. Re-mint it with <code>pnpm inbox:auth</code>; the board rechecks every 15 minutes.</span>`
  650. : st.state === 'failed'
  651. ? `<span class="why">Could not be read: <span class="err">${esc(st.error ?? 'unknown')}</span></span>`
  652. : st.state === 'scope-unknown'
  653. ? `<span class="why">Google did not report this credential's scopes, so whether it can send is unknown. A failed send will say so.</span>`
  654. : `<span class="why">${signatureNote(st, sigs)}</span>`
  655. }
  656. </span>
  657. </label>`,
  658. )}
  659. </div>
  660. ${
  661. notGranted
  662. ? `<p class="compose-meta">${notGranted} more ${plural(
  663. notGranted,
  664. 'mailbox',
  665. 'mailboxes',
  666. )} you may read but not send from. Grants live in <code>config/inbox.ts</code> (<code>INBOX_SENDERS</code>) and are a human edit, not a setting.</p>`
  667. : ''
  668. }`
  669. }
  670. /** The file picker, with the limits stated where the choosing happens. */
  671. function attachmentField(): string {
  672. return `<p class="compose-meta"><b>Attachments</b></p>
  673. <input class="compose-file" type="file" name="files" multiple>
  674. <p class="compose-meta">Up to ${ATTACHMENT_LIMITS.count} files,
  675. ${fileSize(ATTACHMENT_LIMITS.perFile)} each, ${fileSize(ATTACHMENT_LIMITS.total)} in total.
  676. They are read into this page, shown to you on the next screen, and sent with the message;
  677. nothing is stored on this board.</p>`
  678. }
  679. /**
  680. * The attachment half of the red box. It states the limits; a caveat that has
  681. * stopped being true is worse in red than in grey.
  682. */
  683. function attachmentCaveat(): string {
  684. return `Attachments: ${ATTACHMENT_LIMITS.count} files at most,
  685. ${fileSize(ATTACHMENT_LIMITS.perFile)} each and ${fileSize(ATTACHMENT_LIMITS.total)} together.
  686. Over any of those, <b>nothing is sent</b> and the page says which file. Send that one from Gmail.`
  687. }
  688. /**
  689. * How many messages the all-inboxes view shows, and by what rule: the 25
  690. * highest priority, where priority is *unread and not marketing, newest
  691. * first*. Deliberately not a score: there is nothing to weight and nothing to explain.
  692. * A ranking nobody can state in one line is the kind of default that gets
  693. * asserted and forgotten (rule 2); this one fits on the page, and is printed
  694. * there.
  695. *
  696. * Selecting a single mailbox shows everything held for it. This cap makes the
  697. * combined view a summary rather than seven inboxes poured together; it is not
  698. * a limit on what was fetched.
  699. */
  700. export const ALL_INBOXES_CAP = 25
  701. /**
  702. * How many messages the sync holds per mailbox, and what the footer claims.
  703. *
  704. * One constant for both, so the page cannot claim a depth the Worker does not
  705. * fetch. The footer is built from it rather than restating it in prose.
  706. *
  707. * Cloudflare subrequests do not bind here: the Worker plan allows 10,000 per
  708. * invocation, and batching fetches 40 messages per request, so 250 costs a
  709. * handful. Do not derive a cap from them. The limits that do bind:
  710. *
  711. * KV value size. About 466 bytes per message, so seven mailboxes at this
  712. * cap are well under a megabyte against the 25 MiB limit.
  713. *
  714. * Read latency. The whole value is read and parsed on every /inbox request
  715. * and every filter and read POST. If the window ever needs to grow far past
  716. * this, the answer is per-mailbox keys, not a bigger blob.
  717. *
  718. * Gmail's own quota. `messages.get` costs 5 units against 15,000 units per
  719. * minute per user, and at 500 per mailbox Gmail refused about a third of
  720. * every large one ("Units per minute per user"). No subrequest headroom
  721. * moves that.
  722. *
  723. * Search asks Gmail directly, so this number is how much is browsable, not how
  724. * much is reachable. `windowTotal` on each MailboxRead counts what the window
  725. * actually holds, so the page says "250 of 2,400" rather than implying the list
  726. * is everything.
  727. */
  728. export const INBOX_MAX_PER_MAILBOX = 250
  729. export function prioritised(
  730. kept: InboxMessage[],
  731. isUnread: (m: InboxMessage) => boolean,
  732. cap: number = ALL_INBOXES_CAP,
  733. ): { unread: InboxMessage[]; read: InboxMessage[]; heldBack: number } {
  734. // `kept` arrives newest-first and marketing-free, so "newest first" needs no
  735. // second sort and "not marketing" needs no second filter.
  736. const allUnread = kept.filter(isUnread)
  737. const unread = allUnread.slice(0, cap)
  738. // Unread outranks read, so read only fills a remainder. With 25 unread
  739. // waiting, nothing already-read earns a place in a summary.
  740. const read = kept.filter((m) => !isUnread(m)).slice(0, Math.max(0, cap - unread.length))
  741. return { unread, read, heldBack: kept.length - unread.length - read.length }
  742. }
  743. /**
  744. * What this list is not showing, and why. Rule 5, and rule 9.
  745. *
  746. * Three different absences, three different sentences, because they need three
  747. * different responses:
  748. * heldBack the combined view is a summary; open the mailbox to see it
  749. * truncated the window holds more than the board fetched; raise the cap
  750. * failedFetches Gmail was asked and did not answer; nobody can fix by clicking
  751. */
  752. function shortfallNote(
  753. heldBack: number,
  754. boxes: MailboxRead[],
  755. summarised: boolean,
  756. ): string {
  757. const truncated = boxes.filter((b) => b.ok && b.truncated)
  758. const failed = boxes.filter((b) => b.ok && b.failedFetches > 0)
  759. const lines: string[] = []
  760. if (summarised && heldBack > 0) {
  761. lines.push(
  762. `Showing the ${ALL_INBOXES_CAP} that most want attention: unread first, newest first,
  763. marketing excluded. <b>${heldBack} more ${plural(heldBack, 'message', 'messages')}</b>
  764. ${plural(heldBack, 'is', 'are')} loaded and waiting behind a mailbox on the left; nothing
  765. here is a limit on what was fetched.`,
  766. )
  767. }
  768. if (truncated.length) {
  769. lines.push(
  770. `${truncated.length} ${plural(truncated.length, 'mailbox', 'mailboxes')} held more in the
  771. 30-day window than the board fetched:
  772. ${truncated
  773. .map((b) =>
  774. b.windowTotal === null
  775. ? `${esc(b.mailbox)} (more than the board counted)`
  776. : `${esc(b.mailbox)} (${b.windowTotal.toLocaleString()} in the window)`,
  777. )
  778. .join(', ')}.
  779. Those are missing from this list, not absent from Gmail.`,
  780. )
  781. }
  782. if (failed.length) {
  783. lines.push(
  784. `${failed.reduce((n, b) => n + b.failedFetches, 0)} ${plural(
  785. failed.reduce((n, b) => n + b.failedFetches, 0), 'message', 'messages',
  786. )} could not be read back from Gmail this sync. That is a failed fetch, not an empty inbox.`,
  787. )
  788. }
  789. if (!lines.length) return ''
  790. return `<div style="padding:10px 16px;font-size:12px;line-height:1.6;color:var(--text-muted);
  791. border-bottom:1px solid var(--hairline)">${lines.join('<br>')}</div>`
  792. }
  793. export function inboxPage(
  794. data: InboxData,
  795. filters: string[],
  796. mailboxes: Mailbox[],
  797. readable: string[],
  798. viewerLabel: string,
  799. selected: string | null = null,
  800. readIds: ReadonlySet<string> = new Set(),
  801. repliedIds: ReadonlySet<string> = new Set(),
  802. /**
  803. * Does this viewer hold a send grant on any mailbox?
  804. *
  805. * The New message button appears only then, and defaults to false, like
  806. * every permission on this board (rule 13): a control that 403s when pressed
  807. * is worse than one that is absent, and someone who holds no send grant sees
  808. * the reason on the message page rather than a dead button here.
  809. */
  810. canCompose = false,
  811. ): string {
  812. const label = (email: string) => mailboxes.find((m) => m.email === email)?.label ?? email
  813. const visible = data.mailboxes.filter((m) => readable.includes(m.mailbox))
  814. const shown = selected ? visible.filter((m) => m.mailbox === selected) : visible
  815. const failed = shown.filter((m) => !m.ok)
  816. // "Unread" on this page = unread in Gmail AND not marked read on the board.
  817. // The board's mark is shared by every reader, like the filter list: this is
  818. // a team inbox, and "someone already dealt with it" is the fact the button
  819. // records.
  820. const isUnread = (m: InboxMessage): boolean => m.unread && !readIds.has(readKey(m))
  821. const all = shown
  822. .flatMap((m) => m.messages)
  823. .sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))
  824. const kept = all.filter((m) => !matchesFilter(m.sender, filters))
  825. const hiddenGroups = groupBySender(all.filter((m) => matchesFilter(m.sender, filters)))
  826. // One mailbox shows everything held; the combined view is a summary. The cap
  827. // is a display choice, applies only here, and is stated with a count of what
  828. // it holds back.
  829. const summarised = selected === null
  830. const { unread, read, heldBack } = summarised
  831. ? prioritised(kept, isUnread)
  832. : { unread: kept.filter(isUnread), read: kept.filter((m) => !isUnread(m)), heldBack: 0 }
  833. // The recipient rail, filtering by mailbox. Counts are unread counts, like
  834. // Gmail's; a failed mailbox shows its state
  835. // where its count would be, because a count it cannot have would be a lie.
  836. const rail = `<nav class="inbox-rail" aria-label="Mailboxes">
  837. <a href="/inbox" ${selected === null ? 'aria-current="true"' : ''}>
  838. <span>All inboxes</span>
  839. <span class="cnt">${visible.flatMap((m) => m.messages).filter((x) => isUnread(x) && !matchesFilter(x.sender, filters)).length}</span>
  840. </a>
  841. ${map(
  842. mailboxes.filter((m) => readable.includes(m.email)),
  843. (m) => {
  844. const box = visible.find((v) => v.mailbox === m.email)
  845. const n = box?.ok
  846. ? box.messages.filter((x) => isUnread(x) && !matchesFilter(x.sender, filters)).length
  847. : null
  848. return `<a href="/inbox?mailbox=${encodeURIComponent(m.email)}" ${selected === m.email ? 'aria-current="true"' : ''}>
  849. <span>${esc(m.label)}</span>
  850. ${box && !box.ok ? `<span class="dead" title="${esc(box.error ?? '')}">failed</span>` : `<span class="cnt">${n ?? 0}</span>`}
  851. </a>`
  852. },
  853. )}
  854. </nav>`
  855. // Header and rows are one element so the search can hide them together. As
  856. // siblings, filtering every row in a section would leave its header stranded
  857. // above nothing, still claiming a count.
  858. const section = (title: string, rows: InboxMessage[]): string =>
  859. rows.length
  860. ? `<div class="msg-sect">
  861. <h2 class="msg-section">${esc(title)} · <span class="msg-count">${rows.length}</span></h2>
  862. ${map(rows, (m) => messageRow(m, label, selected, readIds.has(readKey(m)), repliedIds.has(readKey(m))))}
  863. </div>`
  864. : ''
  865. const bucketCount = hiddenGroups.reduce((n, g) => n + g.count, 0)
  866. return `<section class="section">
  867. <div class="inbox-layout">
  868. ${rail}
  869. <div class="card" style="overflow:hidden">
  870. <div class="rule"></div>
  871. <div style="padding:18px 16px 12px;border-bottom:1px solid var(--border-default)">
  872. <div style="display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap">
  873. <h1 style="font-family:var(--font-display);font-size:22px;color:var(--text-body)">
  874. ${esc(selected ? label(selected) : 'All inboxes')}
  875. </h1>
  876. <span class="note">${esc(agoText(data.syncedAt))} · every 15 min, or <a href="/">Sync now</a></span>
  877. ${canCompose ? `<a class="msg-btn" href="/inbox/new">New message</a>` : ''}
  878. </div>
  879. <div hidden style="margin-top:10px">
  880. <input id="inbox-search" class="inbox-search" type="search"
  881. placeholder="Filter the messages below" aria-label="Filter loaded messages">
  882. </div>
  883. <!-- TWO SEARCHES, and the difference is stated because it matters.
  884. The box above filters what is already on the page, instantly and
  885. without a request. This one asks Gmail, which is the only way to
  886. reach mail outside the loaded window, which was measured at
  887. several times what the board holds. It is a plain GET form, so
  888. it works with JavaScript off. -->
  889. <form class="inbox-live" method="get" action="/inbox/search" role="search">
  890. ${selected ? `<input type="hidden" name="mailbox" value="${esc(selected)}">` : ''}
  891. <input class="inbox-search" type="search" name="q" required
  892. placeholder="Search all mail in Gmail (from:, has:attachment, \"quoted phrase\")"
  893. aria-label="Search Gmail">
  894. <button class="msg-btn" type="submit">Search Gmail</button>
  895. </form>
  896. </div>
  897. <div style="padding:0 0 8px">
  898. ${failBanner(failed)}
  899. ${shortfallNote(heldBack, shown, summarised)}
  900. ${
  901. kept.length
  902. ? join([
  903. section('Unread', unread),
  904. section('Everything else', read),
  905. // Shown only while a query is active and nothing matched. An
  906. // inbox with no results and an inbox with no mail must not
  907. // look the same, so this says which one it is.
  908. `<p class="msg-none" id="msg-none" hidden>No messages match that search.
  909. This is a filter on what is loaded, not a search of Gmail; older
  910. mail is still in Gmail.</p>`,
  911. ])
  912. : `<p class="empty" style="padding:20px 16px">${
  913. shown.some((m) => m.ok)
  914. ? 'Nothing here that is not filtered.'
  915. : 'No inbox could be read, so this list is empty because nothing can see them, not because there is no mail.'
  916. }</p>`
  917. }
  918. </div>
  919. <div class="inbox-drawer" style="border-top:1px solid var(--border-default)">
  920. <details${hiddenGroups.length ? '' : ' open'} style="padding:0 16px 12px">
  921. <summary>
  922. Filtered marketing: ${hiddenGroups.length} ${plural(hiddenGroups.length, 'sender')} · ${bucketCount} ${plural(bucketCount, 'message')} · collapsed, never deleted
  923. </summary>
  924. ${
  925. hiddenGroups.length
  926. ? map(
  927. hiddenGroups,
  928. (g) => `<div class="row" style="grid-template-columns:1fr auto auto;gap:12px">
  929. <span><b>${esc(g.senderName || g.sender)}</b> <span class="addr">${esc(g.sender)}</span> · ${g.count} ${plural(g.count, 'message')}</span>
  930. <span style="white-space:nowrap;font-size:12px">${esc(rowTime(g.newest))}</span>
  931. <form method="post" action="/inbox/unfilter" style="display:inline">
  932. <input type="hidden" name="sender" value="${esc(g.sender)}">
  933. ${selected ? `<input type="hidden" name="mailbox" value="${esc(selected)}">` : ''}
  934. <button type="submit">Not marketing</button>
  935. </form>
  936. </div>`,
  937. )
  938. : '<p class="empty">Nothing filtered yet.</p>'
  939. }
  940. </details>
  941. </div>
  942. <div style="padding:10px 16px 14px;font-size:11.5px;line-height:1.6;color:var(--text-muted);border-top:1px solid var(--hairline)">
  943. Last 30 days, up to ${INBOX_MAX_PER_MAILBOX.toLocaleString()} per mailbox; older mail is in Gmail, not gone. Bodies are
  944. fetched when you open a message and never stored. Filtering hides a sender
  945. <b>here only</b>, and Mark read is this board&rsquo;s shared memory; Gmail itself still
  946. shows the message unread. <b>Replied</b> stays on a message once someone answers
  947. it from here; it survives the sync and marking read, and every reply is listed at
  948. <a href="/inbox/sent">Sent from this board</a>. Signed in as ${esc(viewerLabel)} · ${readable.length} of ${mailboxes.length}
  949. ${plural(mailboxes.length, 'mailbox', 'mailboxes')} granted in <code>config/inbox.ts</code>.
  950. </div>
  951. </div>
  952. </div>
  953. </section>
  954. ${SEARCH_SCRIPT}`
  955. }
  956. /**
  957. * A byte count as a person reads it, and "size not reported" when there is
  958. * none.
  959. *
  960. * Null is not zero here either. Gmail omits `body.size` on some parts, and a
  961. * "0 KB" beside a file that plainly has contents is the kind of number that
  962. * gets believed. See the Attachment interface.
  963. */
  964. export function fileSize(bytes: number | null): string {
  965. if (bytes === null) return 'size not reported'
  966. if (bytes < 1024) return `${bytes} B`
  967. if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
  968. return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
  969. }
  970. /**
  971. * The attachments on an incoming message, each with what can be done with it.
  972. *
  973. * Three outcomes and no two of them look alike (the prime invariant, applied
  974. * to a control rather than a number):
  975. *
  976. * downloadable a link, with the size beside it
  977. * over the ceiling the size, and the ceiling, and "it is in Gmail"
  978. * no handle Gmail returned the part without an attachment id, so
  979. * there is nothing to fetch; said, not hidden
  980. */
  981. function attachmentList(body: MessageBody): string {
  982. if (!body.attachments.length) return ''
  983. const real = body.attachments.filter((a) => !a.inline)
  984. const inline = body.attachments.length - real.length
  985. const href = (a: Attachment): string =>
  986. `/inbox/attachment?mailbox=${encodeURIComponent(body.mailbox)}&amp;id=${encodeURIComponent(body.id)}&amp;n=${a.index}`
  987. return `<div style="margin-top:10px">
  988. <p class="note" style="margin:0 0 2px"><b>${real.length ? `${real.length} ${plural(real.length, 'attachment')}` : 'No attachments'}</b>${
  989. inline
  990. ? ` &middot; ${inline} inline ${plural(inline, 'image')} (a signature logo or a picture inside the message, not a file someone attached)`
  991. : ''
  992. }</p>
  993. ${map(
  994. body.attachments,
  995. (a) => `<div class="att-row">
  996. <span class="n">${esc(a.name || '(unnamed)')}</span>
  997. <span class="s">${esc(fileSize(a.size))}</span>
  998. ${a.inline ? '<span class="why">inline</span>' : ''}
  999. ${
  1000. !a.attachmentId
  1001. ? '<span class="why">Gmail returned no handle for this part, so it cannot be fetched. It is in Gmail.</span>'
  1002. : a.size !== null && a.size > ATTACHMENT_DOWNLOAD_CAP
  1003. ? `<span class="why">Over this board&rsquo;s ${fileSize(ATTACHMENT_DOWNLOAD_CAP)} download ceiling. Open it in Gmail.</span>`
  1004. : `<a href="${href(a)}">Download</a>`
  1005. }
  1006. </div>`,
  1007. )}
  1008. <p class="note" style="margin:6px 0 0">Downloads come straight from Gmail on the click and are
  1009. not stored on this board. Every one is served as a file, never opened in the page.</p>
  1010. </div>`
  1011. }
  1012. /**
  1013. * Everything the open-message page needs to offer a Reply, or to say why it
  1014. * cannot. Optional as a whole: with none, the page renders the message alone,
  1015. * which keeps the error and fixture paths simple.
  1016. */
  1017. export interface ReplyContext {
  1018. states: MailboxSendState[]
  1019. nonce: string
  1020. sigs: SignatureStore
  1021. /** The board's persistent replied mark for this message, if there is one. */
  1022. replied: RepliedMark | null
  1023. }
  1024. /**
  1025. * One message, read.
  1026. *
  1027. * The sender's HTML never touches this document. When the message has an HTML
  1028. * part it renders through an <iframe> pointed at /inbox/message/frame, a
  1029. * separate response with its own CSP (no scripts), and the iframe is
  1030. * sandboxed without allow-scripts, so nothing the sender wrote can execute
  1031. * even if both layers had holes. `body.html` must not appear in this string;
  1032. * everything that does appear goes through `esc`.
  1033. */
  1034. export function messagePage(
  1035. body: MessageBody | { error: string },
  1036. mailboxLabel: string,
  1037. boardRead = false,
  1038. reply: ReplyContext | null = null,
  1039. ): string {
  1040. if ('error' in body) {
  1041. return `<section class="section">
  1042. <div class="section-head"><h2>Message</h2></div>
  1043. <div class="card" style="padding:22px 28px">
  1044. <p class="empty">Could not read that message: ${esc(body.error)}</p>
  1045. <p class="note">That is a failed read, not an empty message. <a href="/inbox">Back to the inbox</a>.</p>
  1046. </div>
  1047. </section>`
  1048. }
  1049. return `<section class="section">
  1050. <div class="section-head"><h2>Message</h2></div>
  1051. <div class="card">
  1052. <div class="rule"></div>
  1053. <div style="padding:22px 28px 16px;border-bottom:1px solid var(--border-default)">
  1054. <div class="eyebrow">${esc(mailboxLabel)}</div>
  1055. <h1 style="font-family:var(--font-display);font-size:22px;color:var(--text-body)">
  1056. ${esc(body.subject || '(no subject)')}
  1057. </h1>
  1058. <p class="note" style="margin-top:6px">
  1059. From <b>${esc(body.senderName || body.sender)}</b>
  1060. ${body.senderName ? `&lt;${esc(body.sender)}&gt;` : ''}<br>
  1061. To ${esc(body.to || '–')}<br>
  1062. ${esc(localStamp(body.date))} CT
  1063. </p>
  1064. ${attachmentList(body)}
  1065. </div>
  1066. <div class="msg-actions">
  1067. <a class="msg-btn" href="/inbox">&larr; Back to the inbox</a>
  1068. ${replyControl(body, reply)}
  1069. ${
  1070. body.unread && !boardRead
  1071. ? markForm(body, 'read', null)
  1072. : boardRead
  1073. ? markForm(body, 'unread', null)
  1074. : ''
  1075. }
  1076. </div>
  1077. ${repliedBanner(reply?.replied ?? null)}
  1078. <div style="padding:18px 28px">
  1079. ${
  1080. body.html
  1081. ? // The sandbox has no allow-scripts. allow-same-origin exists only so
  1082. // the parent's resize script can read the frame's height; the frame
  1083. // itself cannot run anything, by attribute here and by CSP there.
  1084. `<iframe class="mail-frame" title="Message body, rendered in an isolated frame"
  1085. src="/inbox/message/frame?mailbox=${encodeURIComponent(body.mailbox)}&amp;id=${encodeURIComponent(body.id)}"
  1086. sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"></iframe>
  1087. <p class="note" style="margin-top:10px">Rendered in an isolated frame: the sender&rsquo;s
  1088. scripts are blocked and cannot run, and links open in a new tab. Remote images load
  1089. from the sender&rsquo;s servers while this page is open.</p>
  1090. ${
  1091. body.htmlTruncated
  1092. ? `<p class="note" style="margin-top:8px"><b>The HTML was cut at ${HTML_CAP.toLocaleString()} characters.</b>
  1093. The full message is in Gmail.</p>`
  1094. : ''
  1095. }`
  1096. : body.kind === 'none'
  1097. ? '<p class="empty">This message has no readable text part. It is probably images only.</p>'
  1098. : `<pre style="white-space:pre-wrap;word-break:break-word;font:14px/1.65 var(--font-body,system-ui);margin:0">${esc(body.text)}</pre>
  1099. ${
  1100. body.truncated
  1101. ? `<p class="note" style="margin-top:12px"><b>Cut at ${BODY_CAP.toLocaleString()} characters.</b>
  1102. The rest is in Gmail; this board is not a mail client.</p>`
  1103. : ''
  1104. }`
  1105. }
  1106. </div>
  1107. </div>
  1108. </section>
  1109. ${reply && offerable(reply.states).length ? composeDialog(body, reply) : ''}
  1110. ${
  1111. body.html
  1112. ? `<script>(function(){var f=document.querySelector('.mail-frame');if(!f)return;
  1113. function fit(){try{var d=f.contentDocument;if(!d)return;
  1114. var h=Math.max(d.documentElement?d.documentElement.scrollHeight:0,d.body?d.body.scrollHeight:0);
  1115. if(h)f.style.height=Math.min(Math.max(h+24,240),12000)+'px'}catch(e){}}
  1116. f.addEventListener('load',function(){fit();setTimeout(fit,400);setTimeout(fit,1600)});})();</script>`
  1117. : ''
  1118. }`
  1119. }
  1120. /**
  1121. * The Reply control or, when there is nothing to offer, the reason.
  1122. *
  1123. * Rule 9: a surface that withholds a control says why. No button and no
  1124. * explanation would leave a reader to conclude the feature is broken when
  1125. * they simply hold no grant.
  1126. */
  1127. function replyControl(body: MessageBody, reply: ReplyContext | null): string {
  1128. if (!reply) return ''
  1129. const can = offerable(reply.states)
  1130. if (can.length) {
  1131. // A link, not a button. With JavaScript it is intercepted and opens the
  1132. // dialog; without it, it navigates to the same form on its own page. The
  1133. // no-JS path is not a degraded copy; it posts to the same route.
  1134. return `<a class="msg-btn" id="compose-open"
  1135. href="/inbox/compose?mailbox=${encodeURIComponent(body.mailbox)}&amp;id=${encodeURIComponent(body.id)}">Reply</a>`
  1136. }
  1137. const granted = reply.states.filter((s) => s.state !== 'not-granted')
  1138. if (!granted.length) {
  1139. return `<span class="note">You may read this mailbox but not send from any of them.
  1140. Send grants are separate from read grants and live in <code>config/inbox.ts</code>
  1141. (<code>INBOX_SENDERS</code>).</span>`
  1142. }
  1143. const readonly = granted.filter((s) => s.state === 'readonly-token').length
  1144. const failed = granted.filter((s) => s.state === 'failed').length
  1145. return `<span class="note">No mailbox you may send from can send right now:
  1146. ${readonly ? `${readonly} ${plural(readonly, 'has', 'have')} a read-only token (re-mint with <code>pnpm inbox:auth</code>)` : ''}${readonly && failed ? '; ' : ''}${
  1147. failed ? `${failed} could not be read at all` : ''
  1148. }. This is a credential state, not an empty inbox.</span>`
  1149. }
  1150. /** The persistent replied mark, said in full on the message itself. */
  1151. function repliedBanner(mark: RepliedMark | null): string {
  1152. if (!mark) return ''
  1153. return `<p class="note" style="padding:10px 28px 0;margin:0">
  1154. <span class="msg-replied">replied</span>
  1155. Answered from this board by ${esc(mark.by)} as <b>${esc(mark.from)}</b> on
  1156. ${esc(localStamp(mark.at))} CT.
  1157. The sent copy is in ${esc(mark.from)}&rsquo;s Sent folder in Gmail;
  1158. <a href="/inbox/sent">this board&rsquo;s record</a> is separate and cannot be edited.
  1159. </p>`
  1160. }
  1161. /**
  1162. * The modal. Native <dialog> and one small script, in the same style as the
  1163. * frame-resize script at the foot of this page. No framework: it adds no build
  1164. * step, no bundle to keep in sync and no CSP directive, and the no-JS fallback
  1165. * is then the same form rather than a second implementation of it.
  1166. */
  1167. function composeDialog(body: MessageBody, reply: ReplyContext): string {
  1168. return `<dialog class="compose-dialog" id="compose-dialog" aria-label="Reply">
  1169. ${composeForm(body, reply.states, reply.nonce, reply.sigs)}
  1170. </dialog>
  1171. <script>(function(){
  1172. var d=document.getElementById('compose-dialog'),a=document.getElementById('compose-open');
  1173. if(!d||!a||!d.showModal)return;
  1174. a.addEventListener('click',function(e){e.preventDefault();d.showModal();
  1175. var t=d.querySelector('.compose-body');if(t)t.focus();});
  1176. var c=d.querySelector('[data-compose-cancel]');
  1177. if(c){c.hidden=false;c.addEventListener('click',function(){d.close();});}
  1178. })();</script>`
  1179. }
  1180. /**
  1181. * The compose form on its own page.
  1182. *
  1183. * This is the no-JavaScript path and the target of the Reply link before the
  1184. * script upgrades it, so it is never a stale second copy: same `composeForm`,
  1185. * same POST, same server-side decisions.
  1186. */
  1187. export function composePage(
  1188. body: MessageBody | { error: string },
  1189. mailboxLabel: string,
  1190. states: MailboxSendState[],
  1191. nonce: string,
  1192. sigs: SignatureStore,
  1193. ): string {
  1194. if ('error' in body) {
  1195. return `<section class="section">
  1196. <div class="section-head"><h2>Reply</h2></div>
  1197. <div class="card" style="padding:22px 28px">
  1198. <p class="empty">Could not read the message you are replying to: ${esc(body.error)}</p>
  1199. <p class="note">Nothing was sent. That is a failed read, not an empty message.
  1200. <a href="/inbox">Back to the inbox</a>.</p>
  1201. </div>
  1202. </section>`
  1203. }
  1204. return `<section class="section">
  1205. <div class="section-head"><h2>Reply</h2></div>
  1206. <div class="card">
  1207. <div class="rule"></div>
  1208. <div style="padding:18px 24px 6px">
  1209. <div class="eyebrow">${esc(mailboxLabel)}</div>
  1210. <p class="note" style="margin:2px 0 0">In reply to
  1211. <b>${esc(body.senderName || body.sender)}</b>: ${esc(body.subject || '(no subject)')}</p>
  1212. </div>
  1213. ${composeForm(body, states, nonce, sigs)}
  1214. </div>
  1215. </section>`
  1216. }
  1217. /**
  1218. * A new message: the compose box.
  1219. *
  1220. * The one form on this board that decides who gets mail. Every other outbound
  1221. * surface answers something a client sent us, and takes the recipient off that
  1222. * message on the server; this one cannot, because there is nothing to take it
  1223. * from. So it says exactly that, in the red box, where the person about to
  1224. * send from a shared mailbox will read it:
  1225. *
  1226. * - the recipients are what they typed, parsed and re-emitted as addresses
  1227. * (display names are dropped; see parseRecipients);
  1228. * - at most RECIPIENT_CAP of them across To and Cc, refused over that;
  1229. * - it starts a new thread rather than joining one;
  1230. * - and it still goes through the same confirmation, nonce and audit row as
  1231. * a reply, because it is the same irreversible thing.
  1232. *
  1233. * Shares `fromList` and the attachment field with the reply form, so a mailbox
  1234. * that may not send is refused identically here, and the attachment limits
  1235. * cannot drift between the two.
  1236. */
  1237. export function newMessageForm(
  1238. states: MailboxSendState[],
  1239. nonce: string,
  1240. sigs: SignatureStore,
  1241. /** Sender's own mailbox, preselected when they may send from it. */
  1242. preferred: string | null = null,
  1243. /**
  1244. * Its own heading: true for a dialog, false inside a page that already has
  1245. * one (`newMessagePage` renders a section head saying the same words, and
  1246. * two "New message" headings one under the other is noise). The reply form
  1247. * keeps its heading because its dialog is the surface people actually use.
  1248. */
  1249. heading = true,
  1250. ): string {
  1251. const can = offerable(states)
  1252. const notGranted = states.filter((s) => s.state === 'not-granted')
  1253. const blocked = states.filter((s) => s.state === 'readonly-token' || s.state === 'failed')
  1254. const preselect = can.find((s) => s.email === preferred) ?? can[0]
  1255. return `<form method="post" action="/inbox/compose" class="in" enctype="multipart/form-data">
  1256. ${heading ? '<h2 class="compose-head">New message</h2>' : ''}
  1257. <input type="hidden" name="nonce" value="${esc(nonce)}">
  1258. <input type="hidden" name="mode" value="new">
  1259. ${fromList([...can, ...blocked], preselect?.email ?? null, sigs, notGranted.length)}
  1260. <p class="compose-meta"><b>To</b></p>
  1261. <input class="compose-field" type="text" name="to" required autocomplete="off"
  1262. placeholder="client@example.com, someone@else.com">
  1263. <p class="compose-meta"><b>Cc</b> <span class="why">optional</span></p>
  1264. <input class="compose-field" type="text" name="cc" autocomplete="off" placeholder="">
  1265. <p class="compose-meta"><b>Subject</b></p>
  1266. <input class="compose-field" type="text" name="subject" autocomplete="off"
  1267. placeholder="(no subject)">
  1268. <p class="compose-meta"><b>Message</b></p>
  1269. <textarea class="compose-body" name="body" required
  1270. placeholder="Type your message. Plain text; the signature adds the formatting."></textarea>
  1271. ${attachmentField()}
  1272. <p class="compose-meta compose-caveats">
  1273. <b>You are choosing the recipients.</b> Every other send on this board answers a message
  1274. a client sent us and reads the address off it; this one goes wherever you type.
  1275. Separate addresses with commas, at most ${RECIPIENT_CAP} across To and Cc, and over
  1276. that nothing is sent. Names are dropped and the address is used.<br>
  1277. ${attachmentCaveat()}<br>
  1278. Nothing is sent yet. The next screen shows exactly what goes out.
  1279. </p>
  1280. <div class="compose-actions">
  1281. <button class="msg-btn" type="submit"${can.length ? '' : ' disabled'}>Review the message &rarr;</button>
  1282. <a class="msg-btn" href="/inbox">Back to the inbox</a>
  1283. </div>
  1284. </form>`
  1285. }
  1286. /** The new-message form on its own page, with the same shell as the reply. */
  1287. export function newMessagePage(
  1288. states: MailboxSendState[],
  1289. nonce: string,
  1290. sigs: SignatureStore,
  1291. preferred: string | null = null,
  1292. ): string {
  1293. const can = offerable(states)
  1294. if (!can.length) {
  1295. // Rule 9: no control, and the reason. The reply surface says this too, and
  1296. // for the same reader: someone who may read seven mailboxes and send from
  1297. // none of them.
  1298. const granted = states.filter((s) => s.state !== 'not-granted')
  1299. return `<section class="section">
  1300. <div class="section-head"><h2>New message</h2></div>
  1301. <div class="card" style="padding:22px 28px">
  1302. <p class="empty">${
  1303. granted.length
  1304. ? 'No mailbox you may send from can send right now.'
  1305. : 'You may read the inbox but not send from any mailbox.'
  1306. }</p>
  1307. <p class="note">${
  1308. granted.length
  1309. ? `${granted.filter((s) => s.state === 'readonly-token').length} of them hold a read-only token (re-mint with <code>pnpm inbox:auth</code>) and ${granted.filter((s) => s.state === 'failed').length} could not be read at all. This is a credential state, not a missing feature.`
  1310. : 'Send grants are separate from read grants and live in <code>config/inbox.ts</code> (<code>INBOX_SENDERS</code>). They are a human edit, not a setting.'
  1311. } <a href="/inbox">Back to the inbox</a>.</p>
  1312. </div>
  1313. </section>`
  1314. }
  1315. return `<section class="section">
  1316. <div class="section-head"><h2>New message</h2></div>
  1317. <div class="card">
  1318. <div class="rule"></div>
  1319. ${newMessageForm(states, nonce, sigs, preferred, false)}
  1320. </div>
  1321. </section>`
  1322. }
  1323. /**
  1324. * The confirmation page. Nothing has been sent when this renders.
  1325. *
  1326. * It exists because a send cannot be undone and because every field that
  1327. * matters (recipient, subject, thread) was decided on the server rather than
  1328. * typed, so this is the one place a person can see what those decisions
  1329. * actually produced before it leaves.
  1330. */
  1331. export function confirmPage(
  1332. outgoing: {
  1333. from: string
  1334. to: string
  1335. cc?: string | null
  1336. subject: string
  1337. text: string
  1338. inReplyTo: string | null
  1339. threadId: string | null
  1340. },
  1341. orig: MessageBody | null,
  1342. st: MailboxSendState,
  1343. nonce: string,
  1344. sigs: SignatureStore,
  1345. /** The files, still staged. Carried in a hidden field; see StagedAttachment. */
  1346. staged: { name: string; mimeType: string; b64: string }[] = [],
  1347. ): string {
  1348. const sameMailbox = orig ? outgoing.from === orig.mailbox : false
  1349. const backHref = orig
  1350. ? `/inbox/message?mailbox=${encodeURIComponent(orig.mailbox)}&amp;id=${encodeURIComponent(orig.id)}`
  1351. : '/inbox'
  1352. return `<section class="section">
  1353. <div class="section-head"><h2>${orig ? 'Send this reply?' : 'Send this message?'}</h2></div>
  1354. <div class="card">
  1355. <div class="rule"></div>
  1356. <div style="padding:20px 24px">
  1357. <p class="compose-meta" style="margin-top:0">
  1358. From <b>${esc(outgoing.from)}</b><br>
  1359. To <b>${esc(outgoing.to)}</b>${
  1360. orig?.replyTo ? ' <span class="why">(from the message&rsquo;s Reply-To, which wins over its From)</span>' : ''
  1361. }<br>
  1362. ${
  1363. // The Cc is shown as the server settled it, which for a reply-all
  1364. // is not the header the original carried: this board removes the
  1365. // sending mailbox and the person being answered. This line is the
  1366. // one place the difference is visible before it goes.
  1367. outgoing.cc
  1368. ? `Cc <b>${esc(outgoing.cc)}</b> <span class="why">(${
  1369. outgoing.cc.split(',').length
  1370. } ${plural(outgoing.cc.split(',').length, 'address', 'addresses')}, after taking out you and the sender)</span><br>`
  1371. : ''
  1372. }
  1373. Subject <b>${esc(outgoing.subject)}</b>
  1374. </p>
  1375. <p class="compose-meta"><b>Message</b></p>
  1376. <pre style="white-space:pre-wrap;word-break:break-word;font:14px/1.65 var(--font-body,system-ui);
  1377. margin:0;padding:12px 14px;border:1px solid var(--hairline);border-radius:4px">${esc(outgoing.text)}</pre>
  1378. <p class="compose-meta"><b>Signature</b><br>${
  1379. st.signature === 'set'
  1380. ? 'Appended below your message, exactly as previewed:'
  1381. : st.signature === 'store-failed'
  1382. ? `The signature store could not be read${
  1383. sigs.ok ? '' : `: <span class="err">${esc(sigs.error)}</span>`
  1384. }. This reply goes out <b>without a signature</b>.`
  1385. : 'No signature is set for this mailbox. This reply goes out without one.'
  1386. }</p>
  1387. ${
  1388. st.signature === 'set'
  1389. ? `<iframe class="sig-frame" title="Signature preview"
  1390. src="/inbox/signatures/frame?mailbox=${encodeURIComponent(outgoing.from)}"
  1391. sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"></iframe>`
  1392. : ''
  1393. }
  1394. ${
  1395. staged.length
  1396. ? `<p class="compose-meta"><b>${staged.length} ${plural(staged.length, 'attachment')}</b>,
  1397. going out with it:</p>
  1398. ${map(
  1399. staged,
  1400. (f) => `<div class="att-row">
  1401. <span class="n">${esc(f.name)}</span>
  1402. <span class="s">${esc(fileSize(Math.floor((f.b64.length * 3) / 4)))}</span>
  1403. <span class="why">${esc(f.mimeType)}</span>
  1404. </div>`,
  1405. )}`
  1406. : `<p class="compose-meta">No attachments.</p>`
  1407. }
  1408. <p class="compose-meta">
  1409. ${
  1410. orig
  1411. ? outgoing.inReplyTo
  1412. ? 'It will thread under the original in the recipient&rsquo;s mail client.'
  1413. : 'The original carried no Message-ID, so this will arrive as a <b>new conversation</b> rather than threaded. It still goes to the right person.'
  1414. : 'This is a new message: it carries no threading headers and will start its own conversation.'
  1415. }<br>
  1416. ${
  1417. !orig
  1418. ? `The copy lands in <b>${esc(outgoing.from)}</b>&rsquo;s Sent folder.`
  1419. : sameMailbox
  1420. ? `The copy lands in <b>${esc(outgoing.from)}</b>&rsquo;s Sent folder, in the same thread.`
  1421. : `You are replying from a different mailbox than the one this arrived in, so Gmail thread ids do not carry across. The recipient&rsquo;s client still threads it; <b>${esc(outgoing.from)}</b>&rsquo;s Sent folder will show it as a new thread.`
  1422. }<br>
  1423. Nothing about who clicked Send appears in the message itself. It is recorded on
  1424. <a href="/inbox/sent">this board</a> and nowhere the recipient can see.
  1425. </p>
  1426. <form method="post" action="/inbox/send" class="compose-actions" id="send-form">
  1427. ${
  1428. orig
  1429. ? `<input type="hidden" name="mailbox" value="${esc(orig.mailbox)}">
  1430. <input type="hidden" name="id" value="${esc(orig.id)}">
  1431. <input type="hidden" name="mode" value="reply">
  1432. <input type="hidden" name="all" value="${outgoing.cc ? '1' : '0'}">`
  1433. : `<input type="hidden" name="mode" value="new">
  1434. <input type="hidden" name="to" value="${esc(outgoing.to)}">
  1435. <input type="hidden" name="cc" value="${esc(outgoing.cc ?? '')}">
  1436. <input type="hidden" name="subject" value="${esc(outgoing.subject)}">`
  1437. }
  1438. <input type="hidden" name="nonce" value="${esc(nonce)}">
  1439. <input type="hidden" name="from" value="${esc(outgoing.from)}">
  1440. <input type="hidden" name="body" value="${esc(outgoing.text)}">
  1441. ${
  1442. // The files themselves, base64, crossing the confirmation step in
  1443. // the form rather than in the board's storage. See StagedAttachment
  1444. // in the Worker for why not KV. The send route decodes and
  1445. // re-applies every cap before it calls Gmail.
  1446. staged.length
  1447. ? `<input type="hidden" name="staged" value="${esc(JSON.stringify(staged))}">`
  1448. : ''
  1449. }
  1450. <button class="msg-btn" type="submit">Send it</button>
  1451. <a class="msg-btn" href="${backHref}">Cancel</a>
  1452. </form>
  1453. </div>
  1454. </div>
  1455. </section>
  1456. <script>(function(){var f=document.getElementById('send-form');if(!f)return;
  1457. f.addEventListener('submit',function(){var b=f.querySelector('button[type=submit]');
  1458. if(b){b.disabled=true;b.textContent='Sending...';}});})();</script>`
  1459. }
  1460. /**
  1461. * The board's record of what it sent. Append-only, deliberately.
  1462. *
  1463. * Every other piece of state this board owns is reversible from the page that
  1464. * wrote it. This one is not, and that is the point: it records that something
  1465. * irreversible happened. A failed attempt is here too; an attempt that left no
  1466. * trace would be the one worth hiding.
  1467. */
  1468. export function sentPage(
  1469. rows: SendRecord[],
  1470. truncated: boolean,
  1471. ref: SendRecord | null,
  1472. dup: boolean,
  1473. ): string {
  1474. const detail = ref
  1475. ? `<div class="card sent-detail" style="padding:20px 24px;margin:0 0 16px">
  1476. ${dup ? `<p class="note"><b>This reply was already submitted.</b> It was not sent again.</p>` : ''}
  1477. <p class="compose-meta" style="margin-top:0">
  1478. <span class="st ${esc(ref.outcome)}">${esc(ref.outcome)}</span><br>
  1479. From <b>${esc(ref.from)}</b> to <b>${esc(ref.to)}</b><br>
  1480. ${
  1481. // Three states, not two. `undefined` is a row written before the
  1482. // record had a Cc field; null is a row that recorded "no Cc". Rendering the first as the second would make
  1483. // this audit log claim something about a send it never observed.
  1484. ref.cc === undefined
  1485. ? 'Cc <span class="why">not recorded; this row predates reply-all</span><br>'
  1486. : ref.cc
  1487. ? `Cc <b>${esc(ref.cc)}</b><br>`
  1488. : 'Cc none<br>'
  1489. }
  1490. ${
  1491. ref.mode === 'new'
  1492. ? 'A <b>new message</b>, not a reply: the recipients were typed on this board.<br>'
  1493. : ''
  1494. }
  1495. Subject ${esc(ref.subject)}<br>
  1496. ${
  1497. ref.attachments === undefined
  1498. ? '<span class="why">Attachments not recorded; this row predates them.</span><br>'
  1499. : ref.attachments.length
  1500. ? `${ref.attachments.length} ${plural(ref.attachments.length, 'attachment')}:
  1501. ${ref.attachments.map((a) => `${esc(a.name)} (${esc(fileSize(a.size))})`).join(', ')}<br>`
  1502. : 'No attachments<br>'
  1503. }
  1504. By ${esc(ref.by)} at ${esc(localStamp(ref.at))} CT
  1505. </p>
  1506. ${
  1507. ref.outcome === 'sent'
  1508. ? `<p class="compose-meta">Gmail accepted it (message
  1509. <span class="err">${esc(ref.gmailId ?? '–')}</span>). The copy is in
  1510. <b>${esc(ref.from)}</b>&rsquo;s Sent folder in Gmail, which is the record that
  1511. outlives this board.</p>`
  1512. : ref.outcome === 'failed'
  1513. ? `<p class="compose-meta"><b>It was not sent.</b>
  1514. <span class="err">${esc(ref.error ?? 'no reason recorded')}</span></p>
  1515. <p class="compose-meta">Any of these could produce that, and none of them is
  1516. being picked for you:</p>
  1517. <ul class="compose-meta">${map(ref.candidates, (c) => `<li>${esc(c)}</li>`)}</ul>`
  1518. : `<p class="compose-meta"><b>Submitted, with no outcome recorded.</b> That is not
  1519. the same as failed and not the same as sent. Either the Worker was torn down
  1520. between the send and the record, or the record has not propagated yet
  1521. (KV is eventually consistent, up to about a minute). <b>Check
  1522. ${esc(ref.from)}&rsquo;s Sent folder in Gmail before resending.</b></p>`
  1523. }
  1524. </div>`
  1525. : ''
  1526. return `<section class="section">
  1527. <div class="section-head"><h2>Sent from this board</h2></div>
  1528. ${detail}
  1529. <div class="card" style="padding:16px 24px 20px">
  1530. ${
  1531. rows.length
  1532. ? `${map(
  1533. rows,
  1534. (r) => `<div class="sent-row">
  1535. <span class="st ${esc(r.outcome)}">${esc(r.outcome)}</span>
  1536. <span><b>${esc(r.from)}</b> &rarr; ${esc(r.to)}${
  1537. r.cc ? ` <span class="note">+${r.cc.split(',').length} cc</span>` : ''
  1538. }<br>
  1539. <span class="note">${esc(r.subject)} · by ${esc(r.by)}${
  1540. // The listing reads KV metadata, which carries a count
  1541. // rather than the names (1,024-byte cap). Null means the
  1542. // count was not recorded; zero prints nothing at all.
  1543. r.attachmentCount ? ` · ${r.attachmentCount} ${plural(r.attachmentCount, 'file')}` : ''
  1544. }</span></span>
  1545. <span class="note" style="text-align:right">${esc(localDay(r.at))}</span>
  1546. </div>`,
  1547. )}
  1548. ${
  1549. truncated
  1550. ? `<p class="note" style="margin-top:12px">Showing the newest ${rows.length}.
  1551. There are more; this list is capped, not complete.</p>`
  1552. : ''
  1553. }`
  1554. : `<p class="empty">Nothing has been sent from this board. That is zero replies, not a
  1555. failed read: the record is written on every attempt, including failures.</p>`
  1556. }
  1557. <p class="note" style="margin-top:14px">This is the board&rsquo;s own record and cannot be
  1558. edited or deleted from here. Nothing in it appears in the mail the recipient gets.
  1559. <a href="/inbox">Back to the inbox</a>.</p>
  1560. </div>
  1561. </section>`
  1562. }
  1563. /**
  1564. * The signatures page.
  1565. *
  1566. * The signature HTML never appears in this document. Same rule, and the same
  1567. * reason, as a sender's message HTML: it is user-supplied markup, so it is
  1568. * rendered only through the sandboxed frame route and in the outgoing MIME. A
  1569. * test asserts that a signature containing an onerror handler is absent from
  1570. * this page and present in the frame response.
  1571. */
  1572. export function signaturesPage(
  1573. mailboxes: Mailbox[],
  1574. sigs: SignatureStore,
  1575. sendable: string[],
  1576. rejected: { mailbox: string; over: number } | null,
  1577. ): string {
  1578. return `<section class="section">
  1579. <div class="section-head"><h2>Signatures</h2></div>
  1580. <div class="card" style="padding:18px 24px 22px">
  1581. <p class="note" style="margin-top:0">One HTML signature per mailbox, appended to every
  1582. reply that goes out from it. Paste the HTML, or upload a <code>.html</code> file.
  1583. <b>Host your logo and link to it</b>: an embedded <code>data:</code> image blows past
  1584. the ${SIGNATURE_CAP.toLocaleString()}-character cap and Gmail does not render one in
  1585. received mail anyway. Editing a signature here is reversible; the replies already sent
  1586. with the old one are not.</p>
  1587. ${
  1588. rejected
  1589. ? `<p class="note"><b>Not saved.</b> The signature for ${esc(rejected.mailbox)} was
  1590. ${rejected.over.toLocaleString()} characters, over the
  1591. ${SIGNATURE_CAP.toLocaleString()} cap. Nothing was written; the previous
  1592. signature, if any, is untouched.</p>`
  1593. : ''
  1594. }
  1595. ${
  1596. sigs.ok
  1597. ? ''
  1598. : `<div class="inbox-fail"><div class="strip"></div><div class="in">
  1599. <b>The signature store could not be read.</b>
  1600. <div style="margin-top:4px"><span class="err">${esc(sigs.error)}</span></div>
  1601. <div style="margin-top:4px;color:var(--text-muted)">Replies go out with no
  1602. signature until this is fixed. That is a failed read, not "none set"; the
  1603. saved signatures are not gone.</div>
  1604. </div></div>`
  1605. }
  1606. ${map(mailboxes, (m) => {
  1607. const sig = sigs.ok ? sigs.map[m.email] : undefined
  1608. const may = sendable.includes(m.email)
  1609. return `<div class="sig-card">
  1610. <h3>${esc(m.label)} <span class="note">&lt;${esc(m.email)}&gt;</span></h3>
  1611. <p class="note" style="margin:0 0 8px">${
  1612. !sigs.ok
  1613. ? 'Unknown: the store could not be read.'
  1614. : sig
  1615. ? `Set by ${esc(sig.by)} on ${esc(localDay(sig.at))} · ${sig.html.length.toLocaleString()} characters`
  1616. : 'Not set. Replies from this mailbox go out without a signature.'
  1617. }</p>
  1618. ${
  1619. sigs.ok && sig
  1620. ? `<iframe class="sig-frame" title="Signature preview for ${esc(m.email)}"
  1621. src="/inbox/signatures/frame?mailbox=${encodeURIComponent(m.email)}"
  1622. sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"></iframe>`
  1623. : ''
  1624. }
  1625. ${
  1626. may
  1627. ? `<form method="post" action="/inbox/signatures" enctype="multipart/form-data" style="margin-top:10px">
  1628. <input type="hidden" name="mailbox" value="${esc(m.email)}">
  1629. <input type="hidden" name="op" value="set">
  1630. <textarea class="compose-body" name="html" style="min-height:110px"
  1631. placeholder="Paste the signature HTML. Leaving it empty and saving clears the signature."></textarea>
  1632. <div class="compose-actions">
  1633. <input type="file" name="file" accept=".html,.htm,text/html">
  1634. <button class="msg-btn" type="submit">Save</button>
  1635. </div>
  1636. </form>
  1637. ${
  1638. sigs.ok && sig
  1639. ? `<form method="post" action="/inbox/signatures" style="margin-top:6px">
  1640. <input type="hidden" name="mailbox" value="${esc(m.email)}">
  1641. <input type="hidden" name="op" value="clear">
  1642. <button class="msg-btn" type="submit">Clear it</button>
  1643. </form>`
  1644. : ''
  1645. }`
  1646. : `<p class="note" style="margin:8px 0 0">You may not send as this mailbox, so you
  1647. may not set its signature. Send grants live in <code>config/inbox.ts</code>
  1648. (<code>INBOX_SENDERS</code>).</p>`
  1649. }
  1650. </div>`
  1651. })}
  1652. <p class="note"><a href="/inbox">Back to the inbox</a> · <a href="/inbox/sent">What this board has sent</a></p>
  1653. </div>
  1654. </section>`
  1655. }
  1656. /**
  1657. * One mailbox's answer to a live search.
  1658. *
  1659. * Failure is per mailbox, like every read on this tab. Searching seven
  1660. * mailboxes where one has a dead token must return six results and name the
  1661. * seventh, not an error page, and not a short list that looks complete.
  1662. */
  1663. export interface SearchHit {
  1664. mailbox: string
  1665. ok: boolean
  1666. error: string | null
  1667. messages: InboxMessage[]
  1668. /** True when more matched than this page fetched. */
  1669. truncated: boolean
  1670. }
  1671. /** Per-mailbox cap on a live search. Enough to find one; not a second inbox. */
  1672. export const SEARCH_RESULT_CAP = 50
  1673. /**
  1674. * Live search results.
  1675. *
  1676. * The corpus is different from the rest of the tab and the page says so
  1677. * plainly: no 30-day window, because reaching older mail is the whole point,
  1678. * and still inbox-only, because archived mail, Sent, Spam and Trash have never
  1679. * been on this board and a search box is not where that should quietly change.
  1680. *
  1681. * Results are not read-marked, filtered or replied-chipped. Those states belong
  1682. * to the board's own list of what it holds; a search reaches past that list,
  1683. * and a row here that silently lacked a chip would read as "not replied" rather
  1684. * than "not known".
  1685. */
  1686. export function searchResultsPage(
  1687. query: string,
  1688. hits: SearchHit[],
  1689. mailboxes: Mailbox[],
  1690. scopedTo: string | null,
  1691. ): string {
  1692. const label = (email: string) => mailboxes.find((m) => m.email === email)?.label ?? email
  1693. const found = hits.flatMap((h) => h.messages).sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))
  1694. const failed = hits.filter((h) => !h.ok)
  1695. const truncated = hits.filter((h) => h.ok && h.truncated)
  1696. return `<section class="section">
  1697. <div class="inbox-layout" style="grid-template-columns:1fr">
  1698. <div class="card" style="overflow:hidden">
  1699. <div class="rule"></div>
  1700. <div style="padding:18px 16px 12px;border-bottom:1px solid var(--border-default)">
  1701. <h1 style="font-family:var(--font-display);font-size:22px;color:var(--text-body)">
  1702. ${found.length}${truncated.length ? '+' : ''} ${plural(found.length, 'result')}
  1703. </h1>
  1704. <p class="note" style="margin-top:4px">
  1705. For <b>${esc(query)}</b> in ${
  1706. scopedTo ? `<b>${esc(label(scopedTo))}</b>` : `all ${hits.length} ${plural(hits.length, 'mailbox', 'mailboxes')} you may read`
  1707. }.
  1708. Asked of Gmail just now, so this reaches <b>past the 30 days</b> the board holds,
  1709. and stays inside the inbox: archived mail, Sent, Spam and Trash are not searched.
  1710. </p>
  1711. <p class="note" style="margin-top:6px"><a href="/inbox">&larr; Back to the inbox</a></p>
  1712. </div>
  1713. <div style="padding:0 0 8px">
  1714. ${
  1715. failed.length
  1716. ? `<div class="inbox-fail">
  1717. <div class="strip" aria-hidden="true"></div>
  1718. <div class="in">
  1719. <b>${failed.length} ${plural(failed.length, 'mailbox', 'mailboxes')} could not be searched.</b>
  1720. ${map(failed, (h) => `<div style="margin-top:4px">${esc(h.mailbox)}: <span class="err">${esc(h.error ?? 'unknown')}</span></div>`)}
  1721. <div style="margin-top:4px;color:var(--text-muted)">Matches in ${plural(failed.length, 'it', 'them')} are missing from these results, not absent.</div>
  1722. </div>
  1723. </div>`
  1724. : ''
  1725. }
  1726. ${
  1727. truncated.length
  1728. ? `<div style="padding:10px 16px;font-size:12px;color:var(--text-muted);border-bottom:1px solid var(--hairline)">
  1729. More matched than are shown: capped at ${SEARCH_RESULT_CAP} per mailbox
  1730. (${truncated.map((h) => esc(h.mailbox)).join(', ')}). Narrow the search to see the rest.
  1731. </div>`
  1732. : ''
  1733. }
  1734. ${
  1735. found.length
  1736. ? map(found, (m) => messageRow(m, label, null, false, false))
  1737. : `<p class="empty" style="padding:20px 16px">Nothing in ${
  1738. scopedTo ? esc(label(scopedTo)) : 'any mailbox you may read'
  1739. } matches that. Gmail was asked, so this is an empty result rather than an unloaded one.</p>`
  1740. }
  1741. </div>
  1742. <div style="padding:10px 16px 14px;font-size:11.5px;line-height:1.6;color:var(--text-muted);border-top:1px solid var(--hairline)">
  1743. These rows carry no read, filter or replied marks. Those belong to the board&rsquo;s own
  1744. list of what it holds; a search reaches past it, so a missing chip here would mean
  1745. <b>not known</b> rather than <b>not done</b>. Open a message to act on it.
  1746. </div>
  1747. </div>
  1748. </div>
  1749. </section>`
  1750. }