Skip to content

Dashboard: one-line lists, HTML mail in threads, label colors and received events - #143

Merged
heliocosta-dev merged 28 commits into
mainfrom
feat/inbox-ui-polish
Oct 16, 2026
Merged

heliocosta-dev merged 28 commits into
mainfrom
feat/inbox-ui-polish

Conversation

@heliocosta-dev

Copy link
Copy Markdown
Member

Polishes the inbox and list screens of the dashboard: one-line rows with icon tiles, HTML mail rendered in the thread pane, label colors you can pick and edit, and the received email's events drawn like the sent ones. Every icon whose meaning isn't obvious gets a tooltip, and text fields no longer draw a focus ring.

Lists

  • One shared lead cell (components/lead-cell.tsx) gives the Emails, Inboxes and All received lists the same row: a 30px tile, the address on one line (ellipsized, full value in a tooltip), an optional muted tail, and "+N". Every row is 43px.
  • Emails: "+N" sits inside the row, right after the address (it used to drop under the tile). The envelope tile's tooltip names the status. To is 35%, Status 14%.
  • Inboxes:
    • Each row leads with a tile for its kind: All received, Catch-all, Agent inbox or People inbox, each with a tooltip.
    • Name, expiry and forwarding move to the tail.
    • The catch-all link stays visible at laptop widths.
    • Type is 90px, Needs attention 22%.
  • All received:
    • The From cell leads with a tile for the message's state: Virus, Quarantined, Being screened, Spam, Suspicious, Not scanned (the scanner failed), else Clean. Each tile has a tooltip.
    • Attachments get a paperclip.
    • receiving.list now returns pending, quarantined and suspicious instead of held (one aggregate per row). Mail still being screened no longer shows "Quarantined".
    • The Clean filter now matches the Clean tile: a failed scanner isn't clean.

Threads, folders and labels

  • Read and unread: read threads keep the dot column with a hollow ring, so rows line up whether read or not. Both the ring and the dot have tooltips.
  • Close: the thread pane's Close is an X icon button with a tooltip. Esc closes the pane, except when a field, the reply editor, a menu, a dialog or a tooltip has it, or while a draft is being edited. Focus moves to the next thread.
  • Folders:
    • Not spam in Spam and Restore in Trash move the thread back to Inbox in one click.
    • Trash says threads there are deleted after 30 days. The number comes from core's retention constant.
  • Labels:
    • Creating a label shows the 11 palette colors, with a random one preselected so Enter still works, plus any custom color.
    • A pencil, or the right arrow, opens an edit view: rename, recolor, or delete with an inline confirm.
    • The palette, the 100-label cap, the 64-character name cap and the Trash retention now live in one client-safe core module (@millionsend/core/inbox-organize).
    • Row pills and header chips are one LabelBadge.

HTML mail

  • Rendering: the thread pane renders message.html by default in MailFrame. HTML-only mail now shows a body. The Received email page's Preview uses the same frame. The frame:
    • is sized to its content (no inner scrollbar, it follows content that grows);
    • folds a reply's trailing quoted history under "•••" (answers written inline and forwards are never folded);
    • shows the full link destination in a tooltip, with the registered domain in bold so a look-alike host can't hide it.
  • Remote images stay blocked until the reader clicks Show images, or chooses Always for {domain}. That choice is offered only when DMARC or aligned DKIM/SPF ties the message to that domain, and it reads the authenticated address, never the display name.
    • After consent, the frame's policy lists only the hosts the message's images name, never the dashboard's own.
    • Images load directly from the sender; an image proxy can come later.
  • Inline images: cid: images are inlined for the pane, as on the Received page.
  • Hardening (also applies to the existing Received preview):
    • the sandbox is allow-popups allow-popups-to-escape-sandbox allow-same-origin, with no scripts, forms or top navigation;
    • every link opens in a new tab with noopener noreferrer, so a named target can't keep an opener to the dashboard;
    • <base>, <link>, http-equiv and referrer metas, nested frames and declarative shadow roots are stripped until the markup parses the same twice.

Received email events

  • The strip uses the sent strip's nodes (now shared as EventNode, EventGap and EventStripSkeleton). Each node has a tile and an icon per step, timed gaps, the pulse on the newest step, and a Delivered pill with the inbox address as its detail.
  • Spam is amber, matching the list's badge.
  • The strip opens on its newest step when it overflows.
  • The skeleton reserves the strip and shows all four auth badges.

Focus

Text fields and editors show no focus ring; the caret marks focus. This covers inputs, textareas, the reply and template editors, the code find bar and the chip input. Buttons, links, tabs and other controls keep the keyboard-only ring.

Tests

  • apps/web: 1277 tests (was 1220): screen tests for every new row, button and state; jsdom keyboard tests for Esc, focus and the label editor; router tests on PGlite for the screening split and label colors; unit tests for the frame preparation, quote folding, link destinations and received states.
  • packages/core: 1659 tests.
  • The frame was also checked in a real browser (Chromium, Firefox and WebKit). Known gap: in Safari, an Esc pressed inside the message can't reach the page, so use the X there.

🤖 Generated with Claude Code

heliocosta-dev and others added 28 commits October 16, 2026 15:55
…t fields without a focus ring

Text fields and editors now mark focus with the caret alone; buttons, links
and other controls keep the keyboard-only ring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sent email page drew its events strip inline, with its own copy of the
event tones. The received email page needs the same nodes, so the node (tile,
pulse, pill, time, detail), the connector with its duration label and the
loading placeholder now live beside the event glyphs, and the sent page's
duplicate tone map folds into emailStatusColor.

The sent strip renders as before. A connector with no time to tell keeps a
blank label so its line stays at the tiles' height, and a wrapping detail
breaks a long word instead of clipping it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The received page wrote its own text-only strip: no tiles, no time between
steps, a long "Delivered to <address>" label that could overflow its node,
and on phones the label squeezed into the tile column while the connectors
vanished. Each step is now a sent-strip node: a tile toned and drawn by what
the step found, a short pill, the time with its UTC stamp, and a detail line
that wraps an inbox address. Each connector carries the time since the last
timed step, blank into a scan or screening, which keep no time, or into a
step listed after a later one. The newest step pulses.

Spam is amber as in the list, not red: it is filed away, not held. The
webhook step takes its tone from the delivery status the webhooks page uses.
Released and Delivered become short pills with who released it and the inbox
as details, and the screening scores reuse the review table's wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…loads

The received page's skeleton had no events strip, so the strip appeared from
nothing once the email loaded, and it drew three authentication badges side by
side where the page stacks four. It now holds the strip with the sent page's
placeholder and stacks a badge per authentication check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A long first recipient wrapped onto a second line and pushed "+N" under
the status tile, so rows ran 43 to 60px. The To cell is now one line: the
address ends in an ellipsis with the whole address in a tooltip, and "+N"
sits right after it, listing the other recipients in its tooltip. The
envelope tile names the status in the badge's own words.

The lead cell (tile, value, "+N", muted tail) is one component, so the
Inboxes and All received lists can share it. To and Status move to
35/14% so a long address and its "+N" still fit at 1440px; the loading
skeleton follows, tile included, so rows keep their 43px when data
arrives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lead cells took two lines (the address, then a muted line with the name,
expiry or forwarding) and addresses wrapped, so rows ran 32 to 66px.
Each row now leads with a muted tile (All received, catch-all, agent or
people inbox, named in its tooltip) and keeps the name, expiry and
forwarding after the address; when the column runs out that tail gives
way first, then the address ends in an ellipsis. The tiles stay muted:
Needs attention already says when a row wants a look, and it keeps to
one line too, taking width from the inbox column instead of wrapping.

All received keeps the first clause of its description, and the
catch-all row shortens its link to "turn off", with the domain in its
tooltip, so both fit beside their values at 1440px. Type and Needs
attention narrow to 90px and 22% to leave the addresses room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The list folded mail still being screened into "held" and labelled it
Quarantined, and never showed suspicious mail. receiving.list now
returns pending, quarantined and suspicious, each true when any inbox
the message reached has that verdict, from one aggregate per row (a
lateral join on the membership's primary key) in place of a subquery
per flag.

The From cell leads with a tile whose glyph and tone follow the most
urgent state (virus, quarantined, being screened, spam, suspicious,
scanner failed, else clean), and the subject keeps a badge for every
state but clean. No virus verdict means no scanner is configured, which
reads as clean; the Clean filter now leaves out mail whose scanner
failed, so it lists exactly the rows whose tile reads clean. From and
Inbox stay on one line each, the attachment count gets a paperclip,
and the code and link chips say what was found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dashboard is about to fold a reply's quoted history in received HTML,
using the same markers the agents' read already cuts: the quote classes
Gmail, Thunderbird, Yahoo and Proton write, and the ids Outlook puts where
the original starts. They now live once, as exported constants in the
import-free quotes module, which a new receiving-quotes subpath makes safe
to import from client code. The read pipeline builds its matchers from
them; the stopSpelling marker counts on any element rather than only on
<hr>, the only element Outlook puts it on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The received preview gets a DOM pass before it is framed: the message's
<base>, <link> and http-equiv or referrer metas go, since they would
override the frame's own policy or, like a preconnect, leak a read receipt;
it learns whether the message would load remote images; and a reply also
comes without its quoted history, unless the quote is all there is or it
holds a forward. Plain-text bodies fold their trailing quote with core's
splitTextQuotes, the rules the agents' read uses.

The lockdown can now let images in over https once the reader asks, and a
remembered "always for this domain" choice lives in localStorage under one
key prefix, every access guarded. Only a sender whose authentication ties
the message to its From domain may have images load unasked, so a spoofed
From never gets them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The thread pane showed only a message's text part, and nothing at all for
HTML-only mail. MailFrame now renders the HTML in the card, and the
Received email page's preview uses the same frame instead of a fixed
640px box:

- the sandbox never allows scripts, forms or top navigation;
  allow-same-origin only lets the dashboard measure the document and read
  its links;
- the frame takes its content's height, measured on load, on each image
  load or error and on width changes, with no inner scrollbar; wide mail
  scrolls sideways inside its own box;
- remote images stay blocked behind a bar offering "Show images" and, for
  an authenticated sender, "Always for" its domain;
- hovering or focusing a link shows where it goes in the app's tooltip;
- a reply's quoted history folds under a "•••" toggle, in HTML and text;
- unstyled mail gets a mail client's sans default instead of the browser's
  serif.

Held mail keeps its whole text and no HTML, as it is read to decide on a
release; draft cards are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e core module

The dashboard is about to offer the same 11 label colors core picks from,
say how many labels an inbox can hold, and say how long Trash keeps a
thread. Those values lived in modules that import node:crypto and the
database, which a client component cannot load. They now live in
inboxes/organize.ts, which imports nothing and ships as
@millionsend/core/inbox-organize; labels.ts and limits.ts read them from
there, so each value still has one copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Creating a label from the thread pane will let the person pick its color,
as the REST API already allows; without one, core still picks from its
palette. The color is checked with the shared HEX_COLOR_RE, which
labels.update now uses too instead of its own copy of the pattern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pam, Restore and the Trash note

Read rows rendered nothing in the dot column, so they looked indented next
to unread ones; they now show the hollow ring, and both marks say "Read" or
"Unread" on hover and to screen readers (an agent inbox keeps its longer
note on the dot).

"Close" was the only text button among the pane's boxed controls. It is now
an icon button with a tooltip, as Refresh is, and Esc closes the pane too,
unless the key was already handled, was typed into a field or the reply
editor, or belongs to an open menu, dialog, drawer or tooltip. Those layers
are read as the key goes down, since one that closes on it may be gone by
the time it reaches window. Focus left in the closing pane goes back to the
thread's row instead of falling to the page.

Spam gets a one-click "Not spam" and Trash a "Restore", both moving the
thread to Inbox as Move does, and Trash says how long it keeps a thread,
with the number read from core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it from the Labels menu

The dashboard could only make labels in a random color and had no way to
rename, recolor or delete one. Typing a new name in the Labels menu now
shows the palette under the create row, with a random pick preselected so
Enter alone still creates, and a dashed swatch that opens the native color
picker for any other color. A pencil on the hovered or arrowed-to row, or
→, swaps the menu to that label's edit view: name, the same swatches, and
Delete with a confirm in place. A name another label has is refused as it
is typed; the server's own refusals show inline too.

The swatches are radios, so the palette is one Tab stop walked with the
arrow keys; while the menu holds controls besides its search, Tab cycles
through them (loopTab, shared with dialogs through the FOCUSABLE list now in
lib/keyboard). GroupedMultiSelect gains these as optional hooks; the Emails
filter and the webhook event picker render pixel for pixel as before.

Thread rows and the pane header now show labels as one LabelBadge, the sans
neutral pill with the label's dot; the header's keeps its ✕, except in an
expired inbox, which is read-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Esc typed into the reply editor never closes the pane, but its toolbar
buttons sit outside the editable area, so Esc there did. Closing the pane
unmounts the composer before its debounced local draft is written, which
can drop the last words typed. Anywhere in the message editor now counts
as the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	apps/web/src/app/(dashboard)/inboxes/[id]/threads-tab.tsx
#	apps/web/src/app/(dashboard)/inboxes/received/[id]/received-email.tsx
The thread pane closes on an Esc that reaches the dashboard's window, but
the message body now renders in a frame, and keys pressed in a frame's
document never reach the parent window. After a click on the message's
text, or once a focused link's tip was dismissed, Esc did nothing.

The frame already listens to its document for the link tip, so an Esc the
tip doesn't take is re-sent from the iframe element. Page handlers see it
as an Esc on the frame, and the pane closes with focus back on the row.
An Esc typed in a field inside the message still goes nowhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The thread pane took the "Always for" domain, and the address in each
message's header, from the first <...> in the From value. A display name
may carry an address of its own, quoted, as in
"Bank <alerts@bank.example>" <promo@attacker.example>: mail that
authenticated as attacker.example read as bank.example, so a reader who
had chosen "Always for bank.example" would get the attacker's remote
images loaded unasked. The address is now the angle-addr the value ends
with, the one authentication vouched for, as senderName already reads it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng quotes

The frame parses the prepared markup again, and some trees read back
differently: MathML and SVG text turned into a referrer meta and a
preconnect link the strip had seen as text, and a declarative shadow root
or a nested srcdoc frame held a document no check reached. The strip now
runs until a parse changes nothing, drops nested frames and shadow-root
templates, and gives every link target _blank and rel noopener: a named
target kept an opener, which in Firefox let the sender's page send the
dashboard tab elsewhere.

Show images allowed any https image, and the frame shares the dashboard's
origin, so an image on the dashboard's own host went out with the reader's
session (a CSV export per image, say). The policy now lists only the hosts
the message names, never the dashboard's.

WebKit runs no listener on a document whose frame allows no scripts, the
dashboard's included, so in Safari the link tip, the re-measure and menu
dismissal never ran. The frame now follows its document with a
ResizeObserver (content that grows after load, such as an opened
<details>, is no longer clipped) and reads the hovered or focused link
from the dashboard while the pointer is over the frame or a link inside
has focus. A click into the message closes open menus and pinned
tooltips, as a click anywhere else does. Esc from inside the message
still cannot reach the dashboard in WebKit.

The link tip shows the host whole, the domain its owner registered in
bold: a look-alike host buries the real domain at its end, past what a
four-line cut showed. Show images and Always for move focus into the
message instead of dropping it with the bar.

Folding takes only the quotes after the last thing the sender wrote, so
inline answers keep their questions (Gmail's answers inside its quote
included); hidden text and zero-width characters no longer count as
written, and a message whose subject names a forward (FW:, Fwd:, ENC:,
RV:) is never folded, in HTML or text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The thread pane now renders each message's HTML, but the thread read
returned it byte for byte, with cid: references a frame can never load: a
logo in an Outlook or Apple Mail signature showed as a broken image, and
no Show images could fix it. The dashboard's read now inlines them as data
URLs, as the received email's own read does, under the same size cap; past
it the references stay. The public API keeps the HTML as received.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Needs attention cell went one line by refusing to wrap, which made
its column as wide as its longest text: at 920 to 1024px windows the
Inbox column was left with no room, so rows showed only their tiles. The
cell now ends in an ellipsis inside its own 22%, and the Inbox column
keeps 160px while the other columns give way first.

The catch-all row's "turn off" link sat at the end of its tail, the first
thing cut, and stayed in the Tab order while out of sight. LeadCell takes
an action that follows the tail whole: the tail's words give way before
the address, and the link always shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Not spam, Restore and Move closed the pane with focus inside it, and the
moved thread's row left the list with the refetch, so focus fell to the
page and the next Tab started from the sidebar. Focus now moves to the
row beside the thread, or to the folder's button when there is none. A
label's ✕ in the pane header hands focus to a neighbouring ✕, or to the
Labels menu, before its badge goes.

Esc closing the pane could throw away typing: an open draft edit lives
only in the card, so Esc leaves the pane open while one is being edited;
and the reply composer's draft, mirrored to local storage on a short
timer, is now written when the composer unmounts rather than dropped
with the timer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…readers

The custom-color swatch and the edit view's Back button carried native
titles, which never show on keyboard focus: the app's tooltip sat at
modal level, under the portaled menu. Tooltips are now the top layer, and
both controls use them.

The pencil is hidden from assistive tech, and → was the only keyboard way
into rename, recolor and delete, so a screen reader never learned the
edit view existed. Each label row now describes → and declares it as its
key shortcut, and the edit view is a group named "Edit label".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 64-character label name cap was declared three times: the labels
picker, the router's create and update inputs, and core's own default.
It is now LABEL_NAME_MAX in core's client-safe organize module, next to
the palette and the label limit, read by all three. The broadcast and
template previews take their canvas color from PREVIEW_CANVAS, as the
received-mail frame does, instead of their own copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A message held, released and delivered fills the strip with six or seven
steps, wider than the page at 1440: Delivered, the newest node with the
pulse, ended past the edge and the Webhook node was out of view, with
macOS overlay scrollbars giving no hint. When the strip overflows it now
opens scrolled to its end, as the phone layout already leads with the
latest event; a refetch leaves the reader's own scroll be.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@heliocosta-dev
heliocosta-dev merged commit e410748 into main Oct 16, 2026
5 checks passed
@heliocosta-dev
heliocosta-dev deleted the feat/inbox-ui-polish branch October 16, 2026 22:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant