inbox-view.ts
Language TypeScript86.1 KB1831 lines
/*** The consolidated inbox, rendered: Gmail-shaped, Meridian-skinned.** Gmail's anatomy (sender, subject-with-preview on one line, unread bold, a* mailbox rail on the left, a time column on the right) with the board's* identity: light, Inter, the four-color rule. Not Gmail's chrome.** It is not in surfaces.ts with the other surfaces because they are* pre-rendered into KV by the cron from one snapshot, while this one renders* at request time from its own KV keys, so the filter buttons and the mailbox* rail take effect on the click. The cost is that the board is no longer one* self-contained file.** Runtime-neutral: no node:fs, because the Worker imports this (rule 7).*/import { esc, join, map, plural } from './html.js'import { MERIDIAN_TZ, localDay, localStamp } from '@meridian/ninety-client'import {ATTACHMENT_LIMITS,RECIPIENT_CAP,isOperational,matchesFilter,type Mailbox,} from '../../../config/inbox.js'import {ATTACHMENT_DOWNLOAD_CAP,BODY_CAP,HTML_CAP,type Attachment,type InboxMessage,type MailboxRead,type MessageBody,} from '@meridian/gmail-client'export interface InboxData {syncedAt: string | nullmailboxes: MailboxRead[]}export interface SenderGroup {sender: stringsenderName: stringcount: numbernewest: stringmailboxes: string[]messages: InboxMessage[]}/*** Group by sender. Used by the marketing bucket, where the action (un-filter)* is per-sender, and by the POST handler's validation.*/export function groupBySender(messages: InboxMessage[]): SenderGroup[] {const by = new Map<string, SenderGroup>()for (const m of messages) {const g = by.get(m.sender)if (g) {g.count++g.messages.push(m)if (m.date > g.newest) g.newest = m.dateif (!g.mailboxes.includes(m.mailbox)) g.mailboxes.push(m.mailbox)if (!g.senderName && m.senderName) g.senderName = m.senderName} else {by.set(m.sender, {sender: m.sender,senderName: m.senderName,count: 1,newest: m.date,mailboxes: [m.mailbox],messages: [m],})}}return [...by.values()].sort((a, b) => (a.newest < b.newest ? 1 : a.newest > b.newest ? -1 : 0))}/*** Gmail's time column: clock time today, month-day for anything older.* Both in Meridian's timezone; the reader is in Chicago, not UTC.*/export function rowTime(iso: string, now: Date = new Date()): string {const t = Date.parse(iso)if (!Number.isFinite(t) || t <= 0) return '–'if (localDay(new Date(t)) === localDay(now)) {return new Intl.DateTimeFormat('en-US', { timeZone: MERIDIAN_TZ, hour: 'numeric', minute: '2-digit' }).format(new Date(t))}return new Intl.DateTimeFormat('en-US', { timeZone: MERIDIAN_TZ, month: 'short', day: 'numeric' }).format(new Date(t))}const agoText = (iso: string | null): string => {if (!iso) return 'never synced'const mins = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 60_000))return mins < 1 ? 'synced just now' : mins < 60 ? `synced ${mins} min ago` : `synced ${Math.round(mins / 60)}h ago`}/*** The inbox's own stylesheet, served with the page by the Worker shell.* Gmail's anatomy, the board's tokens. Kept beside the markup that uses it.*/export const INBOX_CSS = `.inbox-layout{display:grid;grid-template-columns:232px 1fr;gap:20px;align-items:start}.inbox-rail{position:sticky;top:14px;background:var(--surface-card);border:1px solid var(--border-default);border-radius:var(--radius-md);padding:8px;display:flex;flex-direction:column;gap:2px}.inbox-rail a{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:9px 12px;border-radius:var(--radius-sm);color:var(--text-body);font-size:13.5px;}.inbox-rail a:hover{background:var(--meridian-gray-050)}.inbox-rail a[aria-current="true"]{background:var(--wash);border-left:2px solid var(--strong);padding-left:10px;font-weight:600}.inbox-rail .cnt{font-size:12px;color:var(--text-muted);font-variant-numeric:tabular-nums}.inbox-rail .dead{font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;background:var(--hatch-ink);color:var(--surface-card);padding:2px 7px;border-radius:3px}:root[data-theme="dark"] .inbox-rail .dead{color:var(--slab)}.inbox-fail{border:1px solid var(--strong);border-radius:var(--radius-sm);overflow:hidden;margin:0 0 12px}.inbox-fail .strip{height:4px;background:var(--hatch-ink)}.inbox-fail .in{padding:10px 14px;font-size:12.5px}.inbox-fail .err{font-family:var(--font-mono);font-size:11.5px;background:var(--meridian-gray-050);padding:1px 5px;border-radius:3px}.msg-section{display:flex;align-items:center;gap:10px;margin:0;padding:12px 16px 4px;font-size:10px;font-weight:600;letter-spacing:.16em;text-transform:uppercase;color:var(--text-muted)}.msg-section::after{content:"";flex:1;height:1px;background:var(--hairline)}/* The yield order is deliberate: THE SUBJECT NEVER YIELDS. It holds a 170pxfloor; the mailbox chip collapses first (min-width:0), then the sendershrinks toward 96px. The action column is FIXED so it stops competing: aplain 1fr+auto pair collapses the subject to zero between ~720 and 1050px,which is inside the supported desktop range. */.msg-row{display:grid;grid-template-columns:2px 10px minmax(96px,168px) minmax(170px,1fr) 104px minmax(0,116px) 58px;column-gap:11px;padding:10px 18px 10px 0;align-items:center;border-bottom:1px solid var(--hairline);background:var(--surface-card2);position:relative}.msg-row .edge{align-self:stretch}.msg-row.unread{background:var(--surface-card)}.msg-row.unread .edge{background:var(--hue-ours)}.msg-row:hover{background:var(--hover-row)}.msg-row .dot{width:8px;height:8px;border-radius:50%;border:1px solid var(--border-default)}.msg-row.unread .dot{background:var(--hue-ours);border-color:var(--hue-ours)}.msg-from{font-size:13.5px;color:var(--text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.msg-subj{font-size:13.5px;color:var(--text-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.msg-subj .snip{color:var(--text-muted);font-weight:400}.msg-row.unread .msg-from,.msg-row.unread .msg-subj{font-weight:600;color:var(--text-body)}.msg-link{position:absolute;inset:0;z-index:1}.msg-box{font-size:10.5px;padding:2px 8px;border:1px solid var(--border-default);border-radius:3px;color:var(--text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0;background:var(--surface-card)}.msg-time{font-size:12px;color:var(--text-muted);white-space:nowrap;text-align:right;font-variant-numeric:tabular-nums}.msg-act{position:relative;z-index:2;visibility:hidden;width:104px;display:flex;flex-direction:column;gap:4px;align-items:stretch}.msg-act form{margin:0}.msg-act button{width:100%}.msg-row:hover .msg-act,.msg-row:focus-within .msg-act{visibility:visible}.msg-act button{cursor:pointer;font-size:11px;padding:3px 9px;border:1px solid var(--border-default);border-radius:var(--radius-sm);background:var(--surface-card);color:var(--text-body)}.msg-act button:hover{border-color:var(--hue-waiting);color:var(--hue-waiting)}/* Email HTML is designed for a white page; the frame stays white in both themes,like Gmail's own message pane, rather than inverting a sender's colors. */.mail-frame{width:100%;border:1px solid var(--border-default);border-radius:4px;background:#fff;min-height:240px;display:block}/* The open-message action bar, above the body, so the two things a reader does(leave, mark handled) never require scrolling to the end of a long email. */.msg-actions{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;padding:12px 28px;border-bottom:1px solid var(--border-default)}.msg-actions form{margin:0}.msg-btn{display:inline-block;cursor:pointer;font:600 12px/1 var(--font-body,inherit);padding:8px 14px;border:1px solid var(--border-default);border-radius:4px;background:var(--surface-card);color:var(--text-body);text-decoration:none}.msg-btn:hover{border-color:var(--hue-waiting);color:var(--hue-waiting)}/* Inside a row the same form renders at row scale; .msg-act's rules win onsize, this puts the weight back so both row buttons match. */.msg-act .msg-btn{font-weight:400}.msg-act .op{display:inline-block;font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;border:1px solid var(--strong);color:var(--text-body);padding:2px 7px;border-radius:3px}.inbox-live{display:flex;gap:8px;align-items:center;margin:10px 0 0;flex-wrap:wrap}.inbox-live .inbox-search{margin:0;flex:1 1 320px}.inbox-search{margin:0 0 10px;width:100%;max-width:420px;padding:8px 14px;font:inherit;font-size:13px;border:1px solid var(--border-default);border-radius:var(--radius-sm);background:var(--surface-card);color:var(--text-body)}/* The filtered bucket is an ink drawer: present, quiet, unmistakably elsewhere. */.inbox-drawer{background:var(--slab);color:var(--on-slab-dim)}.inbox-drawer summary{cursor:pointer;font-weight:600;font-size:13px;color:var(--on-slab);padding:12px 16px}.inbox-drawer .row{border-bottom:1px solid var(--slab-line)}.inbox-drawer .addr{font-family:var(--font-mono);font-size:11px;color:var(--on-slab-dim)}.inbox-drawer b{color:var(--on-slab)}.inbox-drawer button{cursor:pointer;font-size:11px;padding:3px 10px;border:1px solid var(--on-slab-dim);border-radius:var(--radius-sm);background:transparent;color:var(--on-slab)}.inbox-drawer button:hover{background:var(--slab-line)}/* THE SEARCH BOX FILTERED NOTHING FOR THREE DAYS, and this rule is why it doesnow. SEARCH_SCRIPT hides a row by setting its hidden property, which relies onthe user-agent stylesheet's [hidden] rule, and a UA rule loses to ANY authorrule, whatever the specificity. .msg-row is display:grid two dozen lines up,so every "hidden" row stayed on screen. Typing in the box did visibly nothing.theme.ts has a .surface[hidden] rule but it is scoped to .surface and does notreach here. If you give a filtered element a display of its own, add it tothis selector or you will reintroduce the bug.(No backticks in this comment on purpose: INBOX_CSS is a template literal andone backtick here would end the stylesheet mid-file.) */.msg-row[hidden],.msg-sect[hidden],.msg-none[hidden]{display:none}.msg-none{padding:20px 16px;font-size:13px;color:var(--text-muted)}/* REPLY, COMPOSE AND SIGNATURES (2026-09-04). *//* The replied chip. Board-owned and PERSISTENT: it is not derived from the15-minute sync and is not cleared by marking read, because "somebody alreadyanswered this" is the fact a shared mailbox most needs to keep. */.msg-replied{display:inline-block;font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;border:1px solid var(--hue-done,var(--strong));color:var(--hue-done,var(--text-body));padding:2px 7px;border-radius:3px;white-space:nowrap}.compose-dialog{border:1px solid var(--border-default);border-radius:var(--radius-md);background:var(--surface-card);color:var(--text-body);padding:0;width:min(640px,92vw);max-height:88vh;overflow:auto}.compose-dialog::backdrop{background:rgba(0,0,0,.45)}.compose-dialog .in{padding:20px 24px 22px}.compose-head{font-family:var(--font-display);font-size:19px;margin:0 0 4px}.from-list{display:flex;flex-direction:column;gap:2px;margin:8px 0 4px;border:1px solid var(--hairline);border-radius:var(--radius-sm);padding:6px}.from-list label{display:flex;gap:9px;align-items:flex-start;padding:6px 8px;border-radius:var(--radius-sm);font-size:13px;line-height:1.45}.from-list label:hover{background:var(--meridian-gray-050)}.from-list input[disabled]+span{color:var(--text-muted)}.from-list .why{display:block;font-size:11.5px;color:var(--text-muted)}.from-list .ro{display:inline-block;font-size:9.5px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;border:1px solid var(--border-default);color:var(--text-muted);padding:1px 6px;border-radius:3px;margin-left:6px}.compose-body{width:100%;min-height:170px;padding:10px 12px;font:14px/1.6 var(--font-body,system-ui);border:1px solid var(--border-default);border-radius:var(--radius-sm);background:var(--surface-card);color:var(--text-body);resize:vertical}.compose-meta{font-size:12px;color:var(--text-muted);line-height:1.65;margin:10px 0 0}.compose-meta b{color:var(--text-body)}/* THE CAVEATS ARE NOT META. Sam, 2026-09-08: bright and much larger, so thatanyone sending from a shared mailbox sees what this board does not carry:the Cc, and attachments. It kept the muted 12px of the metadata beside it,which is the size the page uses for things nobody has to read. Overrides.compose-meta by order, so it must stay AFTER it. */.compose-caveats{font-size:17px;font-weight:600;color:var(--caveat);line-height:1.5;margin:14px 0 0;padding:12px 14px;border:2px solid var(--caveat);border-radius:var(--radius-sm)}.compose-caveats b{color:var(--caveat)}.compose-actions{display:flex;gap:10px;align-items:center;margin-top:14px;flex-wrap:wrap}.compose-field{width:100%;padding:8px 10px;font:13.5px/1.5 var(--font-body,system-ui);border:1px solid var(--border-default);border-radius:var(--radius-sm);background:var(--surface-card);color:var(--text-body);margin:2px 0 0}.compose-file{font-size:12.5px;margin:4px 0 0;color:var(--text-body)}/* The attachment list on a message, and the same shape on the confirmationpage. A row, not a chip: a name alone was all the old surface could show,and a size is half of what tells a reader whether to open it here. */.att-row{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;padding:4px 0;font-size:12.5px}.att-row .n{font-weight:600;color:var(--text-body);word-break:break-all}.att-row .s{color:var(--text-muted);font-variant-numeric:tabular-nums}.att-row .why{color:var(--text-muted)}/* .err is defined inside .inbox-fail; the compose surfaces show verbatimerrors too and must not lose the monospace that marks them as machine text. */.compose-meta .err,.from-list .err,.sent-detail .err{font-family:var(--font-mono);font-size:11.5px;background:var(--meridian-gray-050);padding:1px 5px;border-radius:3px}.sig-frame{width:100%;height:150px;border:1px solid var(--border-default);border-radius:4px;background:#fff;display:block}.sig-card{border:1px solid var(--border-default);border-radius:var(--radius-sm);padding:14px 16px;margin:0 0 12px}.sig-card h3{font-size:14px;margin:0 0 2px}.sent-row{display:grid;grid-template-columns:120px 1fr 96px;gap:12px;padding:10px 0;border-bottom:1px solid var(--hairline);font-size:13px;align-items:baseline}.sent-row .st{font-size:10px;font-weight:600;letter-spacing:.1em;text-transform:uppercase}.sent-row .st.sent{color:var(--hue-done,var(--text-body))}.sent-row .st.failed{color:var(--hue-ours,var(--strong))}.sent-row .st.sending{color:var(--text-muted)}@media (max-width:720px){.sent-row{grid-template-columns:1fr}.inbox-layout{grid-template-columns:1fr}.inbox-rail{position:static;flex-direction:row;overflow-x:auto;padding:6px}.inbox-rail a{white-space:nowrap;border:1px solid var(--border-default)}.inbox-rail a[aria-current="true"]{border-color:var(--strong)}/* Two-line rows, every cell placed explicitly; auto-placement once put thefilter button on top of the subject. */.msg-row{grid-template-columns:2px 10px minmax(0,1fr) auto;grid-template-rows:auto auto auto;row-gap:2px}.msg-row .edge{grid-row:1 / span 3}.msg-row .dot{grid-column:2;grid-row:1}.msg-from{grid-column:3;grid-row:1}.msg-time{grid-column:4;grid-row:1}.msg-subj{grid-column:3 / span 2;grid-row:2}.msg-box{display:none}.msg-act{grid-column:3 / span 2;grid-row:3;visibility:visible;justify-self:start;width:auto}.msg-act button{min-height:32px}}`/*** Enables the search box only when scripting is available; hidden otherwise.** It does three things, and the second and third exist because doing only the* first would leave the page lying. A section header carries a count rendered on* the server (`Unread · 12`); filtering the rows underneath it without* recounting leaves a number that no longer describes the list beneath it, which* is the failure rule 9 is about. And a query that matches nothing has* to say so, otherwise it is indistinguishable from an empty inbox.*/const SEARCH_SCRIPT = `<script>(function(){var i=document.getElementById('inbox-search');if(!i)return;i.parentElement.hidden=false;var rows=[].slice.call(document.querySelectorAll('.msg-row[data-search]'));var sects=[].slice.call(document.querySelectorAll('.msg-sect'));var none=document.getElementById('msg-none');i.addEventListener('input',function(){var q=i.value.trim().toLowerCase(),shown=0;rows.forEach(function(r){var hit=!q||r.getAttribute('data-search').indexOf(q)>=0;r.hidden=!hit;if(hit)shown++;});sects.forEach(function(s){var n=s.querySelectorAll('.msg-row:not([hidden])').length;var c=s.querySelector('.msg-count');if(c)c.textContent=String(n);s.hidden=n===0;});if(none)none.hidden=!(q&&shown===0);});})();</script>`/** The board's read-state key for one message. Matches the Worker's KV map. */export const readKey = (m: { mailbox: string; id: string }): string => `${m.mailbox}/${m.id}`/*** Mark read / mark unread: the board's memory, never Gmail's. The tokens stay* read-only, so this writes the board's own `inbox:read` KV key (rule 12's* bar: board-owned, falls back to Gmail's real unread state if the key is* lost, reversible from this button).* A message read in Gmail itself gets no button; there is no board state to* flip, and pretending to unmark Gmail's state would be a lie.*/function markForm(m: { mailbox: string; id: string }, op: 'read' | 'unread', selected: string | null): string {return `<form method="post" action="/inbox/read"><input type="hidden" name="mailbox" value="${esc(m.mailbox)}"><input type="hidden" name="id" value="${esc(m.id)}"><input type="hidden" name="op" value="${op}">${selected ? `<input type="hidden" name="back" value="${esc(selected)}">` : ''}<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></form>`}function messageRow(m: InboxMessage,label: (e: string) => string,selected: string | null,boardRead: boolean,replied: boolean,): string {const openHref = `/inbox/message?mailbox=${encodeURIComponent(m.mailbox)}&id=${encodeURIComponent(m.id)}`const haystack = `${m.senderName} ${m.sender} ${m.subject} ${m.snippet}`.toLowerCase()const displayUnread = m.unread && !boardReadreturn `<div class="msg-row${displayUnread ? ' unread' : ''}" data-search="${esc(haystack)}"><span class="edge" aria-hidden="true"></span><span class="dot" aria-hidden="true"></span><span class="msg-from">${esc(m.senderName || m.sender)}</span><span class="msg-subj">${// The chip rides inside the subject cell rather than taking a grid column// of its own: this row's columns are tuned so the subject never yields,// and an eighth child would undo that.replied ? '<span class="msg-replied">replied</span> ' : ''}${esc(m.subject || '(no subject)')}${m.snippet ? ` <span class="snip">· ${esc(m.snippet)}</span>` : ''}</span><span class="msg-box">${esc(label(m.mailbox))}</span><span class="msg-time">${esc(rowTime(m.date))}</span><a class="msg-link" href="${esc(openHref)}" aria-label="Open: ${esc(m.subject || '(no subject)')} from ${esc(m.senderName || m.sender)}"></a><span class="msg-act">${isOperational(m.sender)? `<span class="op" title="Protected in config/inbox.ts: shipments, payments and meeting notices are never filtered">operational</span>`: `<form method="post" action="/inbox/filter"><input type="hidden" name="sender" value="${esc(m.sender)}">${selected ? `<input type="hidden" name="mailbox" value="${esc(selected)}">` : ''}<button type="submit" title="Hide every message from ${esc(m.sender)} on this board">Filter sender</button></form>`}${displayUnread ? markForm(m, 'read', selected) : boardRead ? markForm(m, 'unread', selected) : ''}</span></div>`}/*** The header line for failures. A failed mailbox is named with its reason,* never folded into a total: an inbox nobody could read and an inbox with* nothing in it are not the same thing (the prime invariant, per mailbox).*/function failBanner(failed: MailboxRead[]): string {if (!failed.length) return ''return `<div class="inbox-fail"><div class="strip" aria-hidden="true"></div><div class="in"><b>${failed.length} ${plural(failed.length, 'inbox', 'inboxes')} could not be read.</b>${map(failed, (m) => `<div style="margin-top:4px">${esc(m.mailbox)}: <span class="err">${esc(m.error ?? 'unknown')}</span></div>`)}<div style="margin-top:4px;color:var(--text-muted)">Their mail is missing from this list, not absent.</div></div></div>`}/*** Signatures: one per mailbox, board-owned state (rule 12).** A missing signature and an unreadable signature store are different facts* and are rendered differently. Unlike the notes key, a malformed value here is not* swallowed into an empty map: a lost note is a missing annotation, but a* signature silently dropped changes a client-facing email that has already* gone out.*/export interface Signature {html: string/** Who set it. */by: string/** ISO. */at: string}export type SignatureStore =| { ok: true; map: Record<string, Signature> }| { ok: false; error: string }/*** Big enough for any hand-written HTML signature (2-8 KB is typical), small* enough to refuse a `data:` logo, which is what actually blows past it, and* which Gmail's web client does not render in received mail anyway. The page* says: host the image, paste the URL.*/export const SIGNATURE_CAP = 20_000/** One file on a sent message, as the audit row remembers it. Never bytes. */export interface SentAttachment {name: stringsize: number}/*** The board's record of one send attempt, success or failure.** No message text. Subject and addresses are what an audit needs; the body is* in the sending mailbox's Sent folder, which is where a copy of a client email* belongs. Storing the text here would make the board a place someone's* outgoing mail accumulates, which is the thing readMailbox is careful not to* be either.*/export interface SendRecord {nonce: stringat: stringby: stringbyEmail: stringfrom: stringto: stringsubject: string/*** The Cc that went out, or null for none.** Undefined on rows written before the record had a Cc field, and that is* not the same as null: an old row does not record "no Cc", it records* nothing, and the page says "not recorded" for it. Reading `undefined` as* "no Cc" would put a claim about what left the building onto a row that* never made one.*/cc?: string | null/** 'reply' or 'new'. Undefined on rows written before compose existed. */mode?: 'reply' | 'new'/*** The mailbox and id of the message being answered. Null for a new message,* which answers none. Never undefined: rows from before new messages existed* always carry a string.*/originalMailbox: string | nulloriginalId: string | nulloriginalMessageId: string | nullthreadIdSent: string | nullsignatureApplied: boolean/** Files that went with it. Undefined on rows from before attachments; see `cc`. */attachments?: SentAttachment[]/*** The listing's stand-in for `attachments`, because KV metadata caps at* 1,024 bytes and five filenames do not reliably fit. Null means the count* was not recorded, never zero.*/attachmentCount?: number | nulloutcome: 'sending' | 'sent' | 'failed'gmailId: string | nullgmailThreadId: string | nullerror: string | nullcandidates: string[]}/*** The board's persistent replied mark.** Keyed exactly like a read mark (`mailbox/id`) and stored in the board's own* KV, which is the whole mechanism. Deriving it from the message list would* lose it every fifteen minutes when the sync rewrites `inbox:messages`, and* deriving it from Gmail's own state would need a scope the board does not* have. Board-owned, so nothing upstream can erase it, and unaffected by* marking read: a message can be answered and still want reading.*/export interface RepliedMark {/** Epoch ms. */at: number/** Who replied: the viewer, not the mailbox. */by: string/** Which mailbox it went out as. */from: string}/*** Whether this viewer can send as each mailbox, and why not when they cannot.** Five states, and no two of them render the same (the prime invariant applied* to a permission rather than a number). The three that look alike at a glance* are the three that need opposite fixes:** readonly-token the credential works and lacks gmail.send -> re-mint it* scope-unknown Google did not report the scopes -> nothing to do; try it* failed the mailbox could not be read at all -> fix the credential** Collapsing any pair of those into "unavailable" would send someone to the* wrong page of the runbook.*/export interface MailboxSendState {email: stringlabel: stringstate: 'sendable' | 'readonly-token' | 'scope-unknown' | 'failed' | 'not-granted'/** Only for 'failed'. The mailbox's own read error, verbatim. */error: string | nullsignature: 'set' | 'none' | 'store-failed'}/*** Derive the per-mailbox send states. Order matters and is asserted by a test:* a mailbox nobody granted is `not-granted` even if its token is perfect, and a* failed read is `failed` before anything is said about its scopes, because on* a failed read we never learned them.*/export function sendStates(data: InboxData,mailboxes: Mailbox[],sendable: string[],sigs: SignatureStore,): MailboxSendState[] {return mailboxes.map((m) => {const read = data.mailboxes.find((x) => x.mailbox === m.email)const signature: MailboxSendState['signature'] = !sigs.ok? 'store-failed': sigs.map[m.email]? 'set': 'none'if (!sendable.includes(m.email)) {return { email: m.email, label: m.label, state: 'not-granted', error: null, signature }}if (read && !read.ok) {return { email: m.email, label: m.label, state: 'failed', error: read.error, signature }}const canSend = read?.canSend ?? nullconst state: MailboxSendState['state'] =canSend === false ? 'readonly-token' : canSend === null ? 'scope-unknown' : 'sendable'return { email: m.email, label: m.label, state, error: null, signature }})}/** The mailboxes a Reply button could actually go out from. */export const offerable = (states: MailboxSendState[]): MailboxSendState[] =>states.filter((s) => s.state === 'sendable' || s.state === 'scope-unknown')/*** Why the row is per-signature-state. Never shares wording between the two* absences. Says "message", not "reply", because the same control is on the* new-message form.*/function signatureNote(st: MailboxSendState, sigs: SignatureStore): string {if (st.signature === 'set') {return `Its signature will be appended. <a href="/inbox/signatures">Signatures</a>`}if (st.signature === 'store-failed') {return `The signature store could not be read${sigs.ok ? '' : `: <span class="err">${esc(sigs.error)}</span>`}, so this message goes out <b>without one</b>. That is a failed read, not an empty signature.`}return `No signature is set for this mailbox, so the message goes out without one. <a href="/inbox/signatures">Set one</a>`}/*** The compose form. One function, three surfaces: the modal on the message* page, the full page for a reader with JavaScript off, and (read-only) the* confirmation page. Keeping it one function is what stops the no-JS path from* quietly becoming a different form with different rules.** Note what is not here: no To field, no Subject field, no Cc, no thread id and* no attachment control. All of those are decided on the server from the* original message. A recipient taken from a form field is a recipient the* request can choose, and this is the one action on the board that cannot be* undone.*/export function composeForm(orig: MessageBody,states: MailboxSendState[],nonce: string,sigs: SignatureStore,): string {const can = offerable(states)const notGranted = states.filter((s) => s.state === 'not-granted')const blocked = states.filter((s) => s.state === 'readonly-token' || s.state === 'failed')const preselect = can.find((s) => s.email === orig.mailbox) ?? can[0]// Everyone the original names, for the reply-all label. The server decides// the actual list (it removes us and the primary recipient); this is the// header as it arrived, and the label says which it is.const others = [orig.to, orig.cc].filter(Boolean).join(', ')return `<form method="post" action="/inbox/compose" class="in" enctype="multipart/form-data"><h2 class="compose-head">Reply</h2><p class="compose-meta" style="margin-top:0">To <b>${esc(orig.replyTo || orig.sender)}</b>${orig.replyTo ? ' <span class="why">(the message asked for replies to go here, not to its From address)</span>' : ''}<br>Subject <b>Re: ${esc(orig.subject || '(no subject)')}</b></p><input type="hidden" name="mailbox" value="${esc(orig.mailbox)}"><input type="hidden" name="id" value="${esc(orig.id)}"><input type="hidden" name="nonce" value="${esc(nonce)}"><input type="hidden" name="mode" value="reply">${fromList([...can, ...blocked], preselect?.email ?? null, sigs, notGranted.length)}${others? `<p class="compose-meta"><b>Who gets it</b></p><div class="from-list"><label><input type="checkbox" name="all" value="1"><span>Reply to everyone on the original<span class="why">Adds the original’s To and Cc as Cc: ${esc(others)}.This board takes out you and the person you are answering, so the exactlist is the one on the next screen: at most ${RECIPIENT_CAP} in total,and over that it refuses rather than dropping anybody.</span></span></label></div>`: ''}<p class="compose-meta"><b>Message</b></p><textarea class="compose-body" name="body" requiredplaceholder="Type your reply. Plain text; the signature adds the formatting."></textarea>${attachmentField()}<p class="compose-meta compose-caveats">${orig.cc? `Cc on the original: ${esc(orig.cc)}. <b>Not included unless you tick “Reply to everyone” above.</b><br>`: ''}${attachmentCaveat()}<br>Nothing is sent yet. The next screen shows exactly what goes out.</p><div class="compose-actions"><button class="msg-btn" type="submit"${can.length ? '' : ' disabled'}>Review the reply →</button><button class="msg-btn" type="button" data-compose-cancel hidden>Cancel</button><a class="msg-btn" href="/inbox/message?mailbox=${encodeURIComponent(orig.mailbox)}&id=${encodeURIComponent(orig.id)}">Back to the message</a></div></form>`}/*** The "Send it as" radio list, shared by the reply and new-message forms. Two* copies of a list of which mailboxes may send is exactly the sort of pair* that drifts until one of them offers a mailbox the other refuses.*/function fromList(states: MailboxSendState[],preselect: string | null,sigs: SignatureStore,notGranted: number,): string {return `<p class="compose-meta"><b>Send it as</b></p><div class="from-list">${map(states,(st) => `<label><input type="radio" name="from" value="${esc(st.email)}"${st.state === 'readonly-token' || st.state === 'failed' ? 'disabled' : ''}${preselect && st.email === preselect ? 'checked' : ''}><span>${esc(st.label)} <${esc(st.email)}>${st.state === 'scope-unknown' ? '<span class="ro">unverified</span>' : ''}${st.state === 'readonly-token' ? '<span class="ro">read-only</span>' : ''}${st.state === 'failed' ? '<span class="ro">failed</span>' : ''}${st.state === 'readonly-token'? `<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>`: st.state === 'failed'? `<span class="why">Could not be read: <span class="err">${esc(st.error ?? 'unknown')}</span></span>`: st.state === 'scope-unknown'? `<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>`: `<span class="why">${signatureNote(st, sigs)}</span>`}</span></label>`,)}</div>${notGranted? `<p class="compose-meta">${notGranted} more ${plural(notGranted,'mailbox','mailboxes',)} 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>`: ''}`}/** The file picker, with the limits stated where the choosing happens. */function attachmentField(): string {return `<p class="compose-meta"><b>Attachments</b></p><input class="compose-file" type="file" name="files" multiple><p class="compose-meta">Up to ${ATTACHMENT_LIMITS.count} files,${fileSize(ATTACHMENT_LIMITS.perFile)} each, ${fileSize(ATTACHMENT_LIMITS.total)} in total.They are read into this page, shown to you on the next screen, and sent with the message;nothing is stored on this board.</p>`}/*** The attachment half of the red box. It states the limits; a caveat that has* stopped being true is worse in red than in grey.*/function attachmentCaveat(): string {return `Attachments: ${ATTACHMENT_LIMITS.count} files at most,${fileSize(ATTACHMENT_LIMITS.perFile)} each and ${fileSize(ATTACHMENT_LIMITS.total)} together.Over any of those, <b>nothing is sent</b> and the page says which file. Send that one from Gmail.`}/*** How many messages the all-inboxes view shows, and by what rule: the 25* highest priority, where priority is *unread and not marketing, newest* first*. Deliberately not a score: there is nothing to weight and nothing to explain.* A ranking nobody can state in one line is the kind of default that gets* asserted and forgotten (rule 2); this one fits on the page, and is printed* there.** Selecting a single mailbox shows everything held for it. This cap makes the* combined view a summary rather than seven inboxes poured together; it is not* a limit on what was fetched.*/export const ALL_INBOXES_CAP = 25/*** How many messages the sync holds per mailbox, and what the footer claims.** One constant for both, so the page cannot claim a depth the Worker does not* fetch. The footer is built from it rather than restating it in prose.** Cloudflare subrequests do not bind here: the Worker plan allows 10,000 per* invocation, and batching fetches 40 messages per request, so 250 costs a* handful. Do not derive a cap from them. The limits that do bind:** KV value size. About 466 bytes per message, so seven mailboxes at this* cap are well under a megabyte against the 25 MiB limit.** Read latency. The whole value is read and parsed on every /inbox request* and every filter and read POST. If the window ever needs to grow far past* this, the answer is per-mailbox keys, not a bigger blob.** Gmail's own quota. `messages.get` costs 5 units against 15,000 units per* minute per user, and at 500 per mailbox Gmail refused about a third of* every large one ("Units per minute per user"). No subrequest headroom* moves that.** Search asks Gmail directly, so this number is how much is browsable, not how* much is reachable. `windowTotal` on each MailboxRead counts what the window* actually holds, so the page says "250 of 2,400" rather than implying the list* is everything.*/export const INBOX_MAX_PER_MAILBOX = 250export function prioritised(kept: InboxMessage[],isUnread: (m: InboxMessage) => boolean,cap: number = ALL_INBOXES_CAP,): { unread: InboxMessage[]; read: InboxMessage[]; heldBack: number } {// `kept` arrives newest-first and marketing-free, so "newest first" needs no// second sort and "not marketing" needs no second filter.const allUnread = kept.filter(isUnread)const unread = allUnread.slice(0, cap)// Unread outranks read, so read only fills a remainder. With 25 unread// waiting, nothing already-read earns a place in a summary.const read = kept.filter((m) => !isUnread(m)).slice(0, Math.max(0, cap - unread.length))return { unread, read, heldBack: kept.length - unread.length - read.length }}/*** What this list is not showing, and why. Rule 5, and rule 9.** Three different absences, three different sentences, because they need three* different responses:* heldBack the combined view is a summary; open the mailbox to see it* truncated the window holds more than the board fetched; raise the cap* failedFetches Gmail was asked and did not answer; nobody can fix by clicking*/function shortfallNote(heldBack: number,boxes: MailboxRead[],summarised: boolean,): string {const truncated = boxes.filter((b) => b.ok && b.truncated)const failed = boxes.filter((b) => b.ok && b.failedFetches > 0)const lines: string[] = []if (summarised && heldBack > 0) {lines.push(`Showing the ${ALL_INBOXES_CAP} that most want attention: unread first, newest first,marketing excluded. <b>${heldBack} more ${plural(heldBack, 'message', 'messages')}</b>${plural(heldBack, 'is', 'are')} loaded and waiting behind a mailbox on the left; nothinghere is a limit on what was fetched.`,)}if (truncated.length) {lines.push(`${truncated.length} ${plural(truncated.length, 'mailbox', 'mailboxes')} held more in the30-day window than the board fetched:${truncated.map((b) =>b.windowTotal === null? `${esc(b.mailbox)} (more than the board counted)`: `${esc(b.mailbox)} (${b.windowTotal.toLocaleString()} in the window)`,).join(', ')}.Those are missing from this list, not absent from Gmail.`,)}if (failed.length) {lines.push(`${failed.reduce((n, b) => n + b.failedFetches, 0)} ${plural(failed.reduce((n, b) => n + b.failedFetches, 0), 'message', 'messages',)} could not be read back from Gmail this sync. That is a failed fetch, not an empty inbox.`,)}if (!lines.length) return ''return `<div style="padding:10px 16px;font-size:12px;line-height:1.6;color:var(--text-muted);border-bottom:1px solid var(--hairline)">${lines.join('<br>')}</div>`}export function inboxPage(data: InboxData,filters: string[],mailboxes: Mailbox[],readable: string[],viewerLabel: string,selected: string | null = null,readIds: ReadonlySet<string> = new Set(),repliedIds: ReadonlySet<string> = new Set(),/*** Does this viewer hold a send grant on any mailbox?** The New message button appears only then, and defaults to false, like* every permission on this board (rule 13): a control that 403s when pressed* is worse than one that is absent, and someone who holds no send grant sees* the reason on the message page rather than a dead button here.*/canCompose = false,): string {const label = (email: string) => mailboxes.find((m) => m.email === email)?.label ?? emailconst visible = data.mailboxes.filter((m) => readable.includes(m.mailbox))const shown = selected ? visible.filter((m) => m.mailbox === selected) : visibleconst failed = shown.filter((m) => !m.ok)// "Unread" on this page = unread in Gmail AND not marked read on the board.// The board's mark is shared by every reader, like the filter list: this is// a team inbox, and "someone already dealt with it" is the fact the button// records.const isUnread = (m: InboxMessage): boolean => m.unread && !readIds.has(readKey(m))const all = shown.flatMap((m) => m.messages).sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))const kept = all.filter((m) => !matchesFilter(m.sender, filters))const hiddenGroups = groupBySender(all.filter((m) => matchesFilter(m.sender, filters)))// One mailbox shows everything held; the combined view is a summary. The cap// is a display choice, applies only here, and is stated with a count of what// it holds back.const summarised = selected === nullconst { unread, read, heldBack } = summarised? prioritised(kept, isUnread): { unread: kept.filter(isUnread), read: kept.filter((m) => !isUnread(m)), heldBack: 0 }// The recipient rail, filtering by mailbox. Counts are unread counts, like// Gmail's; a failed mailbox shows its state// where its count would be, because a count it cannot have would be a lie.const rail = `<nav class="inbox-rail" aria-label="Mailboxes"><a href="/inbox" ${selected === null ? 'aria-current="true"' : ''}><span>All inboxes</span><span class="cnt">${visible.flatMap((m) => m.messages).filter((x) => isUnread(x) && !matchesFilter(x.sender, filters)).length}</span></a>${map(mailboxes.filter((m) => readable.includes(m.email)),(m) => {const box = visible.find((v) => v.mailbox === m.email)const n = box?.ok? box.messages.filter((x) => isUnread(x) && !matchesFilter(x.sender, filters)).length: nullreturn `<a href="/inbox?mailbox=${encodeURIComponent(m.email)}" ${selected === m.email ? 'aria-current="true"' : ''}><span>${esc(m.label)}</span>${box && !box.ok ? `<span class="dead" title="${esc(box.error ?? '')}">failed</span>` : `<span class="cnt">${n ?? 0}</span>`}</a>`},)}</nav>`// Header and rows are one element so the search can hide them together. As// siblings, filtering every row in a section would leave its header stranded// above nothing, still claiming a count.const section = (title: string, rows: InboxMessage[]): string =>rows.length? `<div class="msg-sect"><h2 class="msg-section">${esc(title)} · <span class="msg-count">${rows.length}</span></h2>${map(rows, (m) => messageRow(m, label, selected, readIds.has(readKey(m)), repliedIds.has(readKey(m))))}</div>`: ''const bucketCount = hiddenGroups.reduce((n, g) => n + g.count, 0)return `<section class="section"><div class="inbox-layout">${rail}<div class="card" style="overflow:hidden"><div class="rule"></div><div style="padding:18px 16px 12px;border-bottom:1px solid var(--border-default)"><div style="display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap"><h1 style="font-family:var(--font-display);font-size:22px;color:var(--text-body)">${esc(selected ? label(selected) : 'All inboxes')}</h1><span class="note">${esc(agoText(data.syncedAt))} · every 15 min, or <a href="/">Sync now</a></span>${canCompose ? `<a class="msg-btn" href="/inbox/new">New message</a>` : ''}</div><div hidden style="margin-top:10px"><input id="inbox-search" class="inbox-search" type="search"placeholder="Filter the messages below" aria-label="Filter loaded messages"></div><!-- TWO SEARCHES, and the difference is stated because it matters.The box above filters what is already on the page, instantly andwithout a request. This one asks Gmail, which is the only way toreach mail outside the loaded window, which was measured atseveral times what the board holds. It is a plain GET form, soit works with JavaScript off. --><form class="inbox-live" method="get" action="/inbox/search" role="search">${selected ? `<input type="hidden" name="mailbox" value="${esc(selected)}">` : ''}<input class="inbox-search" type="search" name="q" requiredplaceholder="Search all mail in Gmail (from:, has:attachment, \"quoted phrase\")"aria-label="Search Gmail"><button class="msg-btn" type="submit">Search Gmail</button></form></div><div style="padding:0 0 8px">${failBanner(failed)}${shortfallNote(heldBack, shown, summarised)}${kept.length? join([section('Unread', unread),section('Everything else', read),// Shown only while a query is active and nothing matched. An// inbox with no results and an inbox with no mail must not// look the same, so this says which one it is.`<p class="msg-none" id="msg-none" hidden>No messages match that search.This is a filter on what is loaded, not a search of Gmail; oldermail is still in Gmail.</p>`,]): `<p class="empty" style="padding:20px 16px">${shown.some((m) => m.ok)? 'Nothing here that is not filtered.': 'No inbox could be read, so this list is empty because nothing can see them, not because there is no mail.'}</p>`}</div><div class="inbox-drawer" style="border-top:1px solid var(--border-default)"><details${hiddenGroups.length ? '' : ' open'} style="padding:0 16px 12px"><summary>Filtered marketing: ${hiddenGroups.length} ${plural(hiddenGroups.length, 'sender')} · ${bucketCount} ${plural(bucketCount, 'message')} · collapsed, never deleted</summary>${hiddenGroups.length? map(hiddenGroups,(g) => `<div class="row" style="grid-template-columns:1fr auto auto;gap:12px"><span><b>${esc(g.senderName || g.sender)}</b> <span class="addr">${esc(g.sender)}</span> · ${g.count} ${plural(g.count, 'message')}</span><span style="white-space:nowrap;font-size:12px">${esc(rowTime(g.newest))}</span><form method="post" action="/inbox/unfilter" style="display:inline"><input type="hidden" name="sender" value="${esc(g.sender)}">${selected ? `<input type="hidden" name="mailbox" value="${esc(selected)}">` : ''}<button type="submit">Not marketing</button></form></div>`,): '<p class="empty">Nothing filtered yet.</p>'}</details></div><div style="padding:10px 16px 14px;font-size:11.5px;line-height:1.6;color:var(--text-muted);border-top:1px solid var(--hairline)">Last 30 days, up to ${INBOX_MAX_PER_MAILBOX.toLocaleString()} per mailbox; older mail is in Gmail, not gone. Bodies arefetched when you open a message and never stored. Filtering hides a sender<b>here only</b>, and Mark read is this board’s shared memory; Gmail itself stillshows the message unread. <b>Replied</b> stays on a message once someone answersit from here; it survives the sync and marking read, and every reply is listed at<a href="/inbox/sent">Sent from this board</a>. Signed in as ${esc(viewerLabel)} · ${readable.length} of ${mailboxes.length}${plural(mailboxes.length, 'mailbox', 'mailboxes')} granted in <code>config/inbox.ts</code>.</div></div></div></section>${SEARCH_SCRIPT}`}/*** A byte count as a person reads it, and "size not reported" when there is* none.** Null is not zero here either. Gmail omits `body.size` on some parts, and a* "0 KB" beside a file that plainly has contents is the kind of number that* gets believed. See the Attachment interface.*/export function fileSize(bytes: number | null): string {if (bytes === null) return 'size not reported'if (bytes < 1024) return `${bytes} B`if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`return `${(bytes / (1024 * 1024)).toFixed(1)} MB`}/*** The attachments on an incoming message, each with what can be done with it.** Three outcomes and no two of them look alike (the prime invariant, applied* to a control rather than a number):** downloadable a link, with the size beside it* over the ceiling the size, and the ceiling, and "it is in Gmail"* no handle Gmail returned the part without an attachment id, so* there is nothing to fetch; said, not hidden*/function attachmentList(body: MessageBody): string {if (!body.attachments.length) return ''const real = body.attachments.filter((a) => !a.inline)const inline = body.attachments.length - real.lengthconst href = (a: Attachment): string =>`/inbox/attachment?mailbox=${encodeURIComponent(body.mailbox)}&id=${encodeURIComponent(body.id)}&n=${a.index}`return `<div style="margin-top:10px"><p class="note" style="margin:0 0 2px"><b>${real.length ? `${real.length} ${plural(real.length, 'attachment')}` : 'No attachments'}</b>${inline? ` · ${inline} inline ${plural(inline, 'image')} (a signature logo or a picture inside the message, not a file someone attached)`: ''}</p>${map(body.attachments,(a) => `<div class="att-row"><span class="n">${esc(a.name || '(unnamed)')}</span><span class="s">${esc(fileSize(a.size))}</span>${a.inline ? '<span class="why">inline</span>' : ''}${!a.attachmentId? '<span class="why">Gmail returned no handle for this part, so it cannot be fetched. It is in Gmail.</span>': a.size !== null && a.size > ATTACHMENT_DOWNLOAD_CAP? `<span class="why">Over this board’s ${fileSize(ATTACHMENT_DOWNLOAD_CAP)} download ceiling. Open it in Gmail.</span>`: `<a href="${href(a)}">Download</a>`}</div>`,)}<p class="note" style="margin:6px 0 0">Downloads come straight from Gmail on the click and arenot stored on this board. Every one is served as a file, never opened in the page.</p></div>`}/*** Everything the open-message page needs to offer a Reply, or to say why it* cannot. Optional as a whole: with none, the page renders the message alone,* which keeps the error and fixture paths simple.*/export interface ReplyContext {states: MailboxSendState[]nonce: stringsigs: SignatureStore/** The board's persistent replied mark for this message, if there is one. */replied: RepliedMark | null}/*** One message, read.** The sender's HTML never touches this document. When the message has an HTML* part it renders through an <iframe> pointed at /inbox/message/frame, a* separate response with its own CSP (no scripts), and the iframe is* sandboxed without allow-scripts, so nothing the sender wrote can execute* even if both layers had holes. `body.html` must not appear in this string;* everything that does appear goes through `esc`.*/export function messagePage(body: MessageBody | { error: string },mailboxLabel: string,boardRead = false,reply: ReplyContext | null = null,): string {if ('error' in body) {return `<section class="section"><div class="section-head"><h2>Message</h2></div><div class="card" style="padding:22px 28px"><p class="empty">Could not read that message: ${esc(body.error)}</p><p class="note">That is a failed read, not an empty message. <a href="/inbox">Back to the inbox</a>.</p></div></section>`}return `<section class="section"><div class="section-head"><h2>Message</h2></div><div class="card"><div class="rule"></div><div style="padding:22px 28px 16px;border-bottom:1px solid var(--border-default)"><div class="eyebrow">${esc(mailboxLabel)}</div><h1 style="font-family:var(--font-display);font-size:22px;color:var(--text-body)">${esc(body.subject || '(no subject)')}</h1><p class="note" style="margin-top:6px">From <b>${esc(body.senderName || body.sender)}</b>${body.senderName ? `<${esc(body.sender)}>` : ''}<br>To ${esc(body.to || '–')}<br>${esc(localStamp(body.date))} CT</p>${attachmentList(body)}</div><div class="msg-actions"><a class="msg-btn" href="/inbox">← Back to the inbox</a>${replyControl(body, reply)}${body.unread && !boardRead? markForm(body, 'read', null): boardRead? markForm(body, 'unread', null): ''}</div>${repliedBanner(reply?.replied ?? null)}<div style="padding:18px 28px">${body.html? // The sandbox has no allow-scripts. allow-same-origin exists only so// the parent's resize script can read the frame's height; the frame// itself cannot run anything, by attribute here and by CSP there.`<iframe class="mail-frame" title="Message body, rendered in an isolated frame"src="/inbox/message/frame?mailbox=${encodeURIComponent(body.mailbox)}&id=${encodeURIComponent(body.id)}"sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"></iframe><p class="note" style="margin-top:10px">Rendered in an isolated frame: the sender’sscripts are blocked and cannot run, and links open in a new tab. Remote images loadfrom the sender’s servers while this page is open.</p>${body.htmlTruncated? `<p class="note" style="margin-top:8px"><b>The HTML was cut at ${HTML_CAP.toLocaleString()} characters.</b>The full message is in Gmail.</p>`: ''}`: body.kind === 'none'? '<p class="empty">This message has no readable text part. It is probably images only.</p>': `<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>${body.truncated? `<p class="note" style="margin-top:12px"><b>Cut at ${BODY_CAP.toLocaleString()} characters.</b>The rest is in Gmail; this board is not a mail client.</p>`: ''}`}</div></div></section>${reply && offerable(reply.states).length ? composeDialog(body, reply) : ''}${body.html? `<script>(function(){var f=document.querySelector('.mail-frame');if(!f)return;function fit(){try{var d=f.contentDocument;if(!d)return;var h=Math.max(d.documentElement?d.documentElement.scrollHeight:0,d.body?d.body.scrollHeight:0);if(h)f.style.height=Math.min(Math.max(h+24,240),12000)+'px'}catch(e){}}f.addEventListener('load',function(){fit();setTimeout(fit,400);setTimeout(fit,1600)});})();</script>`: ''}`}/*** The Reply control or, when there is nothing to offer, the reason.** Rule 9: a surface that withholds a control says why. No button and no* explanation would leave a reader to conclude the feature is broken when* they simply hold no grant.*/function replyControl(body: MessageBody, reply: ReplyContext | null): string {if (!reply) return ''const can = offerable(reply.states)if (can.length) {// A link, not a button. With JavaScript it is intercepted and opens the// dialog; without it, it navigates to the same form on its own page. The// no-JS path is not a degraded copy; it posts to the same route.return `<a class="msg-btn" id="compose-open"href="/inbox/compose?mailbox=${encodeURIComponent(body.mailbox)}&id=${encodeURIComponent(body.id)}">Reply</a>`}const granted = reply.states.filter((s) => s.state !== 'not-granted')if (!granted.length) {return `<span class="note">You may read this mailbox but not send from any of them.Send grants are separate from read grants and live in <code>config/inbox.ts</code>(<code>INBOX_SENDERS</code>).</span>`}const readonly = granted.filter((s) => s.state === 'readonly-token').lengthconst failed = granted.filter((s) => s.state === 'failed').lengthreturn `<span class="note">No mailbox you may send from can send right now:${readonly ? `${readonly} ${plural(readonly, 'has', 'have')} a read-only token (re-mint with <code>pnpm inbox:auth</code>)` : ''}${readonly && failed ? '; ' : ''}${failed ? `${failed} could not be read at all` : ''}. This is a credential state, not an empty inbox.</span>`}/** The persistent replied mark, said in full on the message itself. */function repliedBanner(mark: RepliedMark | null): string {if (!mark) return ''return `<p class="note" style="padding:10px 28px 0;margin:0"><span class="msg-replied">replied</span>Answered from this board by ${esc(mark.by)} as <b>${esc(mark.from)}</b> on${esc(localStamp(mark.at))} CT.The sent copy is in ${esc(mark.from)}’s Sent folder in Gmail;<a href="/inbox/sent">this board’s record</a> is separate and cannot be edited.</p>`}/*** The modal. Native <dialog> and one small script, in the same style as the* frame-resize script at the foot of this page. No framework: it adds no build* step, no bundle to keep in sync and no CSP directive, and the no-JS fallback* is then the same form rather than a second implementation of it.*/function composeDialog(body: MessageBody, reply: ReplyContext): string {return `<dialog class="compose-dialog" id="compose-dialog" aria-label="Reply">${composeForm(body, reply.states, reply.nonce, reply.sigs)}</dialog><script>(function(){var d=document.getElementById('compose-dialog'),a=document.getElementById('compose-open');if(!d||!a||!d.showModal)return;a.addEventListener('click',function(e){e.preventDefault();d.showModal();var t=d.querySelector('.compose-body');if(t)t.focus();});var c=d.querySelector('[data-compose-cancel]');if(c){c.hidden=false;c.addEventListener('click',function(){d.close();});}})();</script>`}/*** The compose form on its own page.** This is the no-JavaScript path and the target of the Reply link before the* script upgrades it, so it is never a stale second copy: same `composeForm`,* same POST, same server-side decisions.*/export function composePage(body: MessageBody | { error: string },mailboxLabel: string,states: MailboxSendState[],nonce: string,sigs: SignatureStore,): string {if ('error' in body) {return `<section class="section"><div class="section-head"><h2>Reply</h2></div><div class="card" style="padding:22px 28px"><p class="empty">Could not read the message you are replying to: ${esc(body.error)}</p><p class="note">Nothing was sent. That is a failed read, not an empty message.<a href="/inbox">Back to the inbox</a>.</p></div></section>`}return `<section class="section"><div class="section-head"><h2>Reply</h2></div><div class="card"><div class="rule"></div><div style="padding:18px 24px 6px"><div class="eyebrow">${esc(mailboxLabel)}</div><p class="note" style="margin:2px 0 0">In reply to<b>${esc(body.senderName || body.sender)}</b>: ${esc(body.subject || '(no subject)')}</p></div>${composeForm(body, states, nonce, sigs)}</div></section>`}/*** A new message: the compose box.** The one form on this board that decides who gets mail. Every other outbound* surface answers something a client sent us, and takes the recipient off that* message on the server; this one cannot, because there is nothing to take it* from. So it says exactly that, in the red box, where the person about to* send from a shared mailbox will read it:** - the recipients are what they typed, parsed and re-emitted as addresses* (display names are dropped; see parseRecipients);* - at most RECIPIENT_CAP of them across To and Cc, refused over that;* - it starts a new thread rather than joining one;* - and it still goes through the same confirmation, nonce and audit row as* a reply, because it is the same irreversible thing.** Shares `fromList` and the attachment field with the reply form, so a mailbox* that may not send is refused identically here, and the attachment limits* cannot drift between the two.*/export function newMessageForm(states: MailboxSendState[],nonce: string,sigs: SignatureStore,/** Sender's own mailbox, preselected when they may send from it. */preferred: string | null = null,/*** Its own heading: true for a dialog, false inside a page that already has* one (`newMessagePage` renders a section head saying the same words, and* two "New message" headings one under the other is noise). The reply form* keeps its heading because its dialog is the surface people actually use.*/heading = true,): string {const can = offerable(states)const notGranted = states.filter((s) => s.state === 'not-granted')const blocked = states.filter((s) => s.state === 'readonly-token' || s.state === 'failed')const preselect = can.find((s) => s.email === preferred) ?? can[0]return `<form method="post" action="/inbox/compose" class="in" enctype="multipart/form-data">${heading ? '<h2 class="compose-head">New message</h2>' : ''}<input type="hidden" name="nonce" value="${esc(nonce)}"><input type="hidden" name="mode" value="new">${fromList([...can, ...blocked], preselect?.email ?? null, sigs, notGranted.length)}<p class="compose-meta"><b>To</b></p><input class="compose-field" type="text" name="to" required autocomplete="off"placeholder="client@example.com, someone@else.com"><p class="compose-meta"><b>Cc</b> <span class="why">optional</span></p><input class="compose-field" type="text" name="cc" autocomplete="off" placeholder=""><p class="compose-meta"><b>Subject</b></p><input class="compose-field" type="text" name="subject" autocomplete="off"placeholder="(no subject)"><p class="compose-meta"><b>Message</b></p><textarea class="compose-body" name="body" requiredplaceholder="Type your message. Plain text; the signature adds the formatting."></textarea>${attachmentField()}<p class="compose-meta compose-caveats"><b>You are choosing the recipients.</b> Every other send on this board answers a messagea client sent us and reads the address off it; this one goes wherever you type.Separate addresses with commas, at most ${RECIPIENT_CAP} across To and Cc, and overthat nothing is sent. Names are dropped and the address is used.<br>${attachmentCaveat()}<br>Nothing is sent yet. The next screen shows exactly what goes out.</p><div class="compose-actions"><button class="msg-btn" type="submit"${can.length ? '' : ' disabled'}>Review the message →</button><a class="msg-btn" href="/inbox">Back to the inbox</a></div></form>`}/** The new-message form on its own page, with the same shell as the reply. */export function newMessagePage(states: MailboxSendState[],nonce: string,sigs: SignatureStore,preferred: string | null = null,): string {const can = offerable(states)if (!can.length) {// Rule 9: no control, and the reason. The reply surface says this too, and// for the same reader: someone who may read seven mailboxes and send from// none of them.const granted = states.filter((s) => s.state !== 'not-granted')return `<section class="section"><div class="section-head"><h2>New message</h2></div><div class="card" style="padding:22px 28px"><p class="empty">${granted.length? 'No mailbox you may send from can send right now.': 'You may read the inbox but not send from any mailbox.'}</p><p class="note">${granted.length? `${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.`: '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.'} <a href="/inbox">Back to the inbox</a>.</p></div></section>`}return `<section class="section"><div class="section-head"><h2>New message</h2></div><div class="card"><div class="rule"></div>${newMessageForm(states, nonce, sigs, preferred, false)}</div></section>`}/*** The confirmation page. Nothing has been sent when this renders.** It exists because a send cannot be undone and because every field that* matters (recipient, subject, thread) was decided on the server rather than* typed, so this is the one place a person can see what those decisions* actually produced before it leaves.*/export function confirmPage(outgoing: {from: stringto: stringcc?: string | nullsubject: stringtext: stringinReplyTo: string | nullthreadId: string | null},orig: MessageBody | null,st: MailboxSendState,nonce: string,sigs: SignatureStore,/** The files, still staged. Carried in a hidden field; see StagedAttachment. */staged: { name: string; mimeType: string; b64: string }[] = [],): string {const sameMailbox = orig ? outgoing.from === orig.mailbox : falseconst backHref = orig? `/inbox/message?mailbox=${encodeURIComponent(orig.mailbox)}&id=${encodeURIComponent(orig.id)}`: '/inbox'return `<section class="section"><div class="section-head"><h2>${orig ? 'Send this reply?' : 'Send this message?'}</h2></div><div class="card"><div class="rule"></div><div style="padding:20px 24px"><p class="compose-meta" style="margin-top:0">From <b>${esc(outgoing.from)}</b><br>To <b>${esc(outgoing.to)}</b>${orig?.replyTo ? ' <span class="why">(from the message’s Reply-To, which wins over its From)</span>' : ''}<br>${// The Cc is shown as the server settled it, which for a reply-all// is not the header the original carried: this board removes the// sending mailbox and the person being answered. This line is the// one place the difference is visible before it goes.outgoing.cc? `Cc <b>${esc(outgoing.cc)}</b> <span class="why">(${outgoing.cc.split(',').length} ${plural(outgoing.cc.split(',').length, 'address', 'addresses')}, after taking out you and the sender)</span><br>`: ''}Subject <b>${esc(outgoing.subject)}</b></p><p class="compose-meta"><b>Message</b></p><pre style="white-space:pre-wrap;word-break:break-word;font:14px/1.65 var(--font-body,system-ui);margin:0;padding:12px 14px;border:1px solid var(--hairline);border-radius:4px">${esc(outgoing.text)}</pre><p class="compose-meta"><b>Signature</b><br>${st.signature === 'set'? 'Appended below your message, exactly as previewed:': st.signature === 'store-failed'? `The signature store could not be read${sigs.ok ? '' : `: <span class="err">${esc(sigs.error)}</span>`}. This reply goes out <b>without a signature</b>.`: 'No signature is set for this mailbox. This reply goes out without one.'}</p>${st.signature === 'set'? `<iframe class="sig-frame" title="Signature preview"src="/inbox/signatures/frame?mailbox=${encodeURIComponent(outgoing.from)}"sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"></iframe>`: ''}${staged.length? `<p class="compose-meta"><b>${staged.length} ${plural(staged.length, 'attachment')}</b>,going out with it:</p>${map(staged,(f) => `<div class="att-row"><span class="n">${esc(f.name)}</span><span class="s">${esc(fileSize(Math.floor((f.b64.length * 3) / 4)))}</span><span class="why">${esc(f.mimeType)}</span></div>`,)}`: `<p class="compose-meta">No attachments.</p>`}<p class="compose-meta">${orig? outgoing.inReplyTo? 'It will thread under the original in the recipient’s mail client.': '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.': 'This is a new message: it carries no threading headers and will start its own conversation.'}<br>${!orig? `The copy lands in <b>${esc(outgoing.from)}</b>’s Sent folder.`: sameMailbox? `The copy lands in <b>${esc(outgoing.from)}</b>’s Sent folder, in the same thread.`: `You are replying from a different mailbox than the one this arrived in, so Gmail thread ids do not carry across. The recipient’s client still threads it; <b>${esc(outgoing.from)}</b>’s Sent folder will show it as a new thread.`}<br>Nothing about who clicked Send appears in the message itself. It is recorded on<a href="/inbox/sent">this board</a> and nowhere the recipient can see.</p><form method="post" action="/inbox/send" class="compose-actions" id="send-form">${orig? `<input type="hidden" name="mailbox" value="${esc(orig.mailbox)}"><input type="hidden" name="id" value="${esc(orig.id)}"><input type="hidden" name="mode" value="reply"><input type="hidden" name="all" value="${outgoing.cc ? '1' : '0'}">`: `<input type="hidden" name="mode" value="new"><input type="hidden" name="to" value="${esc(outgoing.to)}"><input type="hidden" name="cc" value="${esc(outgoing.cc ?? '')}"><input type="hidden" name="subject" value="${esc(outgoing.subject)}">`}<input type="hidden" name="nonce" value="${esc(nonce)}"><input type="hidden" name="from" value="${esc(outgoing.from)}"><input type="hidden" name="body" value="${esc(outgoing.text)}">${// The files themselves, base64, crossing the confirmation step in// the form rather than in the board's storage. See StagedAttachment// in the Worker for why not KV. The send route decodes and// re-applies every cap before it calls Gmail.staged.length? `<input type="hidden" name="staged" value="${esc(JSON.stringify(staged))}">`: ''}<button class="msg-btn" type="submit">Send it</button><a class="msg-btn" href="${backHref}">Cancel</a></form></div></div></section><script>(function(){var f=document.getElementById('send-form');if(!f)return;f.addEventListener('submit',function(){var b=f.querySelector('button[type=submit]');if(b){b.disabled=true;b.textContent='Sending...';}});})();</script>`}/*** The board's record of what it sent. Append-only, deliberately.** Every other piece of state this board owns is reversible from the page that* wrote it. This one is not, and that is the point: it records that something* irreversible happened. A failed attempt is here too; an attempt that left no* trace would be the one worth hiding.*/export function sentPage(rows: SendRecord[],truncated: boolean,ref: SendRecord | null,dup: boolean,): string {const detail = ref? `<div class="card sent-detail" style="padding:20px 24px;margin:0 0 16px">${dup ? `<p class="note"><b>This reply was already submitted.</b> It was not sent again.</p>` : ''}<p class="compose-meta" style="margin-top:0"><span class="st ${esc(ref.outcome)}">${esc(ref.outcome)}</span><br>From <b>${esc(ref.from)}</b> to <b>${esc(ref.to)}</b><br>${// Three states, not two. `undefined` is a row written before the// record had a Cc field; null is a row that recorded "no Cc". Rendering the first as the second would make// this audit log claim something about a send it never observed.ref.cc === undefined? 'Cc <span class="why">not recorded; this row predates reply-all</span><br>': ref.cc? `Cc <b>${esc(ref.cc)}</b><br>`: 'Cc none<br>'}${ref.mode === 'new'? 'A <b>new message</b>, not a reply: the recipients were typed on this board.<br>': ''}Subject ${esc(ref.subject)}<br>${ref.attachments === undefined? '<span class="why">Attachments not recorded; this row predates them.</span><br>': ref.attachments.length? `${ref.attachments.length} ${plural(ref.attachments.length, 'attachment')}:${ref.attachments.map((a) => `${esc(a.name)} (${esc(fileSize(a.size))})`).join(', ')}<br>`: 'No attachments<br>'}By ${esc(ref.by)} at ${esc(localStamp(ref.at))} CT</p>${ref.outcome === 'sent'? `<p class="compose-meta">Gmail accepted it (message<span class="err">${esc(ref.gmailId ?? '–')}</span>). The copy is in<b>${esc(ref.from)}</b>’s Sent folder in Gmail, which is the record thatoutlives this board.</p>`: ref.outcome === 'failed'? `<p class="compose-meta"><b>It was not sent.</b><span class="err">${esc(ref.error ?? 'no reason recorded')}</span></p><p class="compose-meta">Any of these could produce that, and none of them isbeing picked for you:</p><ul class="compose-meta">${map(ref.candidates, (c) => `<li>${esc(c)}</li>`)}</ul>`: `<p class="compose-meta"><b>Submitted, with no outcome recorded.</b> That is notthe same as failed and not the same as sent. Either the Worker was torn downbetween the send and the record, or the record has not propagated yet(KV is eventually consistent, up to about a minute). <b>Check${esc(ref.from)}’s Sent folder in Gmail before resending.</b></p>`}</div>`: ''return `<section class="section"><div class="section-head"><h2>Sent from this board</h2></div>${detail}<div class="card" style="padding:16px 24px 20px">${rows.length? `${map(rows,(r) => `<div class="sent-row"><span class="st ${esc(r.outcome)}">${esc(r.outcome)}</span><span><b>${esc(r.from)}</b> → ${esc(r.to)}${r.cc ? ` <span class="note">+${r.cc.split(',').length} cc</span>` : ''}<br><span class="note">${esc(r.subject)} · by ${esc(r.by)}${// The listing reads KV metadata, which carries a count// rather than the names (1,024-byte cap). Null means the// count was not recorded; zero prints nothing at all.r.attachmentCount ? ` · ${r.attachmentCount} ${plural(r.attachmentCount, 'file')}` : ''}</span></span><span class="note" style="text-align:right">${esc(localDay(r.at))}</span></div>`,)}${truncated? `<p class="note" style="margin-top:12px">Showing the newest ${rows.length}.There are more; this list is capped, not complete.</p>`: ''}`: `<p class="empty">Nothing has been sent from this board. That is zero replies, not afailed read: the record is written on every attempt, including failures.</p>`}<p class="note" style="margin-top:14px">This is the board’s own record and cannot beedited or deleted from here. Nothing in it appears in the mail the recipient gets.<a href="/inbox">Back to the inbox</a>.</p></div></section>`}/*** The signatures page.** The signature HTML never appears in this document. Same rule, and the same* reason, as a sender's message HTML: it is user-supplied markup, so it is* rendered only through the sandboxed frame route and in the outgoing MIME. A* test asserts that a signature containing an onerror handler is absent from* this page and present in the frame response.*/export function signaturesPage(mailboxes: Mailbox[],sigs: SignatureStore,sendable: string[],rejected: { mailbox: string; over: number } | null,): string {return `<section class="section"><div class="section-head"><h2>Signatures</h2></div><div class="card" style="padding:18px 24px 22px"><p class="note" style="margin-top:0">One HTML signature per mailbox, appended to everyreply that goes out from it. Paste the HTML, or upload a <code>.html</code> file.<b>Host your logo and link to it</b>: an embedded <code>data:</code> image blows pastthe ${SIGNATURE_CAP.toLocaleString()}-character cap and Gmail does not render one inreceived mail anyway. Editing a signature here is reversible; the replies already sentwith the old one are not.</p>${rejected? `<p class="note"><b>Not saved.</b> The signature for ${esc(rejected.mailbox)} was${rejected.over.toLocaleString()} characters, over the${SIGNATURE_CAP.toLocaleString()} cap. Nothing was written; the previoussignature, if any, is untouched.</p>`: ''}${sigs.ok? '': `<div class="inbox-fail"><div class="strip"></div><div class="in"><b>The signature store could not be read.</b><div style="margin-top:4px"><span class="err">${esc(sigs.error)}</span></div><div style="margin-top:4px;color:var(--text-muted)">Replies go out with nosignature until this is fixed. That is a failed read, not "none set"; thesaved signatures are not gone.</div></div></div>`}${map(mailboxes, (m) => {const sig = sigs.ok ? sigs.map[m.email] : undefinedconst may = sendable.includes(m.email)return `<div class="sig-card"><h3>${esc(m.label)} <span class="note"><${esc(m.email)}></span></h3><p class="note" style="margin:0 0 8px">${!sigs.ok? 'Unknown: the store could not be read.': sig? `Set by ${esc(sig.by)} on ${esc(localDay(sig.at))} · ${sig.html.length.toLocaleString()} characters`: 'Not set. Replies from this mailbox go out without a signature.'}</p>${sigs.ok && sig? `<iframe class="sig-frame" title="Signature preview for ${esc(m.email)}"src="/inbox/signatures/frame?mailbox=${encodeURIComponent(m.email)}"sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"></iframe>`: ''}${may? `<form method="post" action="/inbox/signatures" enctype="multipart/form-data" style="margin-top:10px"><input type="hidden" name="mailbox" value="${esc(m.email)}"><input type="hidden" name="op" value="set"><textarea class="compose-body" name="html" style="min-height:110px"placeholder="Paste the signature HTML. Leaving it empty and saving clears the signature."></textarea><div class="compose-actions"><input type="file" name="file" accept=".html,.htm,text/html"><button class="msg-btn" type="submit">Save</button></div></form>${sigs.ok && sig? `<form method="post" action="/inbox/signatures" style="margin-top:6px"><input type="hidden" name="mailbox" value="${esc(m.email)}"><input type="hidden" name="op" value="clear"><button class="msg-btn" type="submit">Clear it</button></form>`: ''}`: `<p class="note" style="margin:8px 0 0">You may not send as this mailbox, so youmay not set its signature. Send grants live in <code>config/inbox.ts</code>(<code>INBOX_SENDERS</code>).</p>`}</div>`})}<p class="note"><a href="/inbox">Back to the inbox</a> · <a href="/inbox/sent">What this board has sent</a></p></div></section>`}/*** One mailbox's answer to a live search.** Failure is per mailbox, like every read on this tab. Searching seven* mailboxes where one has a dead token must return six results and name the* seventh, not an error page, and not a short list that looks complete.*/export interface SearchHit {mailbox: stringok: booleanerror: string | nullmessages: InboxMessage[]/** True when more matched than this page fetched. */truncated: boolean}/** Per-mailbox cap on a live search. Enough to find one; not a second inbox. */export const SEARCH_RESULT_CAP = 50/*** Live search results.** The corpus is different from the rest of the tab and the page says so* plainly: no 30-day window, because reaching older mail is the whole point,* and still inbox-only, because archived mail, Sent, Spam and Trash have never* been on this board and a search box is not where that should quietly change.** Results are not read-marked, filtered or replied-chipped. Those states belong* to the board's own list of what it holds; a search reaches past that list,* and a row here that silently lacked a chip would read as "not replied" rather* than "not known".*/export function searchResultsPage(query: string,hits: SearchHit[],mailboxes: Mailbox[],scopedTo: string | null,): string {const label = (email: string) => mailboxes.find((m) => m.email === email)?.label ?? emailconst found = hits.flatMap((h) => h.messages).sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0))const failed = hits.filter((h) => !h.ok)const truncated = hits.filter((h) => h.ok && h.truncated)return `<section class="section"><div class="inbox-layout" style="grid-template-columns:1fr"><div class="card" style="overflow:hidden"><div class="rule"></div><div style="padding:18px 16px 12px;border-bottom:1px solid var(--border-default)"><h1 style="font-family:var(--font-display);font-size:22px;color:var(--text-body)">${found.length}${truncated.length ? '+' : ''} ${plural(found.length, 'result')}</h1><p class="note" style="margin-top:4px">For <b>${esc(query)}</b> in ${scopedTo ? `<b>${esc(label(scopedTo))}</b>` : `all ${hits.length} ${plural(hits.length, 'mailbox', 'mailboxes')} you may read`}.Asked of Gmail just now, so this reaches <b>past the 30 days</b> the board holds,and stays inside the inbox: archived mail, Sent, Spam and Trash are not searched.</p><p class="note" style="margin-top:6px"><a href="/inbox">← Back to the inbox</a></p></div><div style="padding:0 0 8px">${failed.length? `<div class="inbox-fail"><div class="strip" aria-hidden="true"></div><div class="in"><b>${failed.length} ${plural(failed.length, 'mailbox', 'mailboxes')} could not be searched.</b>${map(failed, (h) => `<div style="margin-top:4px">${esc(h.mailbox)}: <span class="err">${esc(h.error ?? 'unknown')}</span></div>`)}<div style="margin-top:4px;color:var(--text-muted)">Matches in ${plural(failed.length, 'it', 'them')} are missing from these results, not absent.</div></div></div>`: ''}${truncated.length? `<div style="padding:10px 16px;font-size:12px;color:var(--text-muted);border-bottom:1px solid var(--hairline)">More matched than are shown: capped at ${SEARCH_RESULT_CAP} per mailbox(${truncated.map((h) => esc(h.mailbox)).join(', ')}). Narrow the search to see the rest.</div>`: ''}${found.length? map(found, (m) => messageRow(m, label, null, false, false)): `<p class="empty" style="padding:20px 16px">Nothing in ${scopedTo ? esc(label(scopedTo)) : 'any mailbox you may read'} matches that. Gmail was asked, so this is an empty result rather than an unloaded one.</p>`}</div><div style="padding:10px 16px 14px;font-size:11.5px;line-height:1.6;color:var(--text-muted);border-top:1px solid var(--hairline)">These rows carry no read, filter or replied marks. Those belong to the board’s ownlist of what it holds; a search reaches past it, so a missing chip here would mean<b>not known</b> rather than <b>not done</b>. Open a message to act on it.</div></div></div></section>`}