Part of VulOS — the open, self-hostable web OS & app suite. Runs standalone, or combined under one login by Vulos Workspace.
Shared React webmail UI for the Vulos suite. It is a thin, reusable component
library that renders a full three-pane webmail experience and talks to
lilmail's /v1 JSON API (see lilmail/docs/API.md). The only coupling to a
backend is that HTTP contract — there is no direct dependency on lilmail or any
other Vulos repo.
Both vulos-mail/webmail and the Vulos Workspace shell mount this package so
there is exactly one mail UI to maintain.
VulOS is an open, self-hostable web OS + app suite. Each product is self-hostable on its own and can be combined under one login by Vulos Workspace:
- Vulos Mail — mail + calendar + contacts (engine: lilmail; UI:
@vulos/mail-ui; server: vulos-mail) - Vulos Talk — team chat + channels/Spaces + huddles
- Vulos Meet — video meetings (LiveKit SFU)
- Vulos Office — documents: docs, sheets, slides, PDF
- Vulos Relay — sovereign connectivity fabric (
@vulos/relay-client) - Vulos Workspace — the open suite shell (one login, app switcher, admin)
- Vulos OS — the web-native desktop
@vulos/mail-ui is the React UI of the Vulos Mail product — the <MailApp/>,
<Calendar/>, and <Contacts/> surface. It renders against the /v1 HTTP
contract only, so it runs standalone (against lilmail or any /v1 server) and
is combined by Vulos Workspace. Products never import one another's code.
@vulos/mail-ui renders a full, polished webmail with Gmail's information
density and interaction model, drawn in the OSS-native near-black / mono-led
Vulos theme:
- Comprehensive left sidebar with a distinctive Vulos Mail brand mark, a prominent Compose, a primary mailbox group (Inbox / Starred / Sent / Drafts) with icons + unread counts, a collapsible More disclosure for the less-used mailboxes (Archive / Spam / Trash), capability-gated Categories (Social / Promotions / Updates) and user Labels (coloured dots, nesting), and an optional storage meter. Everything the backend doesn't expose is hidden — no dead UI. Collapses to an icon-only rail.
- Right calendar side panel (Gmail-style) — a mini month + an
upcoming agenda, expandable in place to the full month grid and
toggleable (state persisted), reusing the
/v1calendar data path. Hidden on mid-size/mobile viewports (reachable via the rail toggle / mobile drawer). - Layout controls — a reading-pane position toggle (right / bottom /
off), an inbox type (default / unread-first / starred-first, sorted
client-side), a preview-text toggle, and a comfortable / compact density —
all persisted to
localStorage. - Theme — Auto (follows the OS light/dark setting live), Dark or Light; applied via tokenised CSS custom properties, AA-contrast in both modes.
- Conversation threading — messages are grouped client-side by
References/In-Reply-To/Message-ID; thread rows show a count and the reading pane renders a collapsible conversation (latest expanded). - Message list with colour-hashed avatars, snippet preview, relative dates, attachment indicator, star, unread accent rail, per-row hover quick-actions (archive / delete / mark read), multi-select with a bulk action bar, and "select all".
- Docked compose (bottom-right) with minimise / full-screen, To/Cc/Bcc
contact autocomplete, a rich-text toolbar (bold / italic / lists / link →
HTML, plain-text fallback), debounced draft auto-save, and
reply / reply-all / forward prefill.
- Inline images — paste or drag-drop a raster image (PNG/JPEG/GIF/WebP,
≤2 MB) into the body; it embeds as a proper
cid:inline MIME part when the backend supports it, else as a base64data:URI. Both paths refuse SVG and enforce the same size cap. - Attachments — real drag-drop / picker upload staged via
POST /v1/attachments(with progress). The attach control is disabled with a tooltip on servers without the upload route (capability probe). - Send-later — a schedule dropdown next to Send (presets + custom picker)
that POSTs an RFC3339
sendAt; confirms "Scheduled for …" and lists pending sends in the Scheduled view. Shown only when the backend supports it. - Undo send — Gmail-style delayed send behind an "Undo" toast (window set in Settings) that reopens the draft with nothing having hit the network.
- Inline images — paste or drag-drop a raster image (PNG/JPEG/GIF/WebP,
≤2 MB) into the body; it embeds as a proper
- Conversation threading — messages grouped client-side by
References/In-Reply-To/Message-ID; thread rows show a count and the reading pane renders a collapsible conversation (latest expanded). - Triage — snooze (hide until later, with presets), report spam, archive
(IMAP
MOVE), delete (to Trash), star (\Flagged), read/unread (\Seen), and apply / create / delete user labels — all optimistic (UI updates immediately, reverts on error) and undoable where destructive, and each capability-probed so absent routes hide their affordance. - Filters (mail rules) — a rules manager (
<FiltersPanel/>) over the/v1/rulescontract: rules in evaluation order (first match wins), create / edit / delete / reorder, a "Run now on Inbox" pass, and "create filter from sender". Shown only when the backend supports rules. - Calendar RSVP — accept / tentative / decline a received meeting invitation from the reading pane (sends a METHOD:REPLY to the organizer, reflects the event in your calendar), plus create / edit / delete of events.
- Keyboard shortcuts (
j k o u e # ! r a f c s x z / ?, plusg-then-key mailbox chords) with a?help overlay (toggleable in Settings) and a fuzzy ⌘K command palette — every palette entry is a real wired action. - Search with a results view, an active-query chip, and structured
from: to: subject: has: is:operators applied client-side. - Calendar + Contacts as exported components, surfaced as the persistent
calendar side panel and an overlay contacts panel; plus a deep, sectioned
Settings panel (General / Appearance / Notifications / Calendar / Filters /
Labels / Storage / Composing / Keyboard shortcuts) — every control genuinely
applied or reflecting real data, with capability-gated sections (Filters,
Labels, Storage) and no dishonest fields. Server-backed surfaces the
/v1contract doesn't expose (identities, vacation auto-reply) are intentionally omitted rather than shown as dead controls; a host can inject its own (account, change password, mail-client setup…) at the top of Settings viasettingsExtra. - Motion — side panels glide in, the compose dock rises, conversations and
expanded messages fade up; all suppressed under
prefers-reduced-motion.
Fully responsive: three-pane on desktop, side panels collapse ≤1024px, a single-pane list ↔ message flow with a drawer rail and full-screen compose ≤768px (no horizontal scroll at 360px, ≥44px tap targets).
Generated by the Playwright screenshotter against the seeded mock /v1 demo
(no backend). Regenerate with npm run screenshots.
| Inbox (full layout) | Conversation | Compose |
|---|---|---|
![]() |
![]() |
![]() |
| Sidebar (More / Categories / Labels) | Calendar panel (mini) | Calendar panel (expanded) |
|---|---|---|
![]() |
![]() |
![]() |
| Search | Contacts | Settings |
|---|---|---|
![]() |
![]() |
![]() |
| Account | Overlay panel + conversation |
|---|---|
![]() |
![]() |
| Mobile | Mobile drawer |
|---|---|
![]() |
![]() |
import { MailApp } from '@vulos/mail-ui'
import '@vulos/mail-ui/style.css' // tokens + component styles
export default function App() {
return <MailApp baseUrl="/v1" onSend={async (draft) => { /* host send */ }} />
}MailApp (and the api client) default to the same-origin /v1. Point it
elsewhere with baseUrl, or pass a pre-built client:
import { MailApp, createMailClient } from '@vulos/mail-ui'
const client = createMailClient({ baseUrl: 'https://mail.example.com/v1' })
<MailApp client={client} onAuthError={() => location.assign('/login')} />All requests are sent with credentials: 'include' so the lilmail session
cookie rides along. A 401 surfaces as an ApiError with .status === 401
(and fires onAuthError).
MailApp keeps the /v1 mail contract pure, so anything server-specific
(account, change password, mail-client setup, sign out) is supplied by the host
through settingsExtra and rendered at the top of the Settings panel. The
bundled vulos-mail/webmail passes its <AccountSettings/> (backed by
/api/webmail/account), surfaced as styled sections using the shared tokens:
<MailApp baseUrl="/v1" settingsExtra={<AccountSettings onLogout={signOut} />} />| Export | What |
|---|---|
MailApp |
Full app (comprehensive sidebar | list | reading pane | calendar panel | rail), responsive, wired to /v1. Sends via POST /v1/messages by default; override with onSend. |
Calendar |
Month / week / day / agenda views + event create/edit/delete over /v1/calendar/events (requires lilmail [caldav]). |
CalendarPanel |
The persistent right-hand mini-month + agenda side panel (expandable to the full Calendar). |
EventEditor |
Modal event editor (all-day, recurrence → RRULE, location, notes). |
Contacts |
Searchable contact list over GET /v1/contacts / /v1/contacts/cards (requires lilmail [carddav]). |
ContactEditor |
Modal contact editor (name, org, title, emails/phones, notes). |
FiltersPanel |
Mail-rules ("Filters") manager over /v1/rules. |
ScheduledView |
The Scheduled (send-later) mailbox view over /v1/scheduled. |
CommandPalette |
The ⌘K fuzzy command palette. |
Logo |
The Vulos Mail brand mark. Renders the real product image (src, default /vulos-mail.png — host-served) with an optional wordmark; falls back to a token-driven SVG mark if the image fails to load. |
FolderList, MessageList, MessageView, Compose, Settings, Icon |
Individual components. |
groupThreads, useSettings, DEFAULT_SETTINGS |
Threading grouping + persisted-settings helpers. |
createMailClient, ApiError, FLAG_SEEN, FLAG_FLAGGED |
/v1 API client (mail + calendar + contacts + rules + scheduled + move/snooze/labels/attachments). |
sanitizeEmailHtml, sanitizeEmailBody, stripHtml |
DOMPurify-based HTML sanitisers for email bodies (sanitizeEmailBody also reports whether remote content was present). |
isSenderAllowed, allowSender, disallowSender, loadAllowlist, normalizeSender |
Per-sender remote-image allowlist helpers. |
@vulos/mail-ui/api |
The api client module on its own. |
@vulos/mail-ui/style.css |
Stylesheet (OSS-native design tokens + components). |
import { Calendar, Contacts } from '@vulos/mail-ui'
<Calendar baseUrl="/v1" onAuthError={() => location.assign('/login')} />
<Contacts baseUrl="/v1" onSelect={(c) => startCompose(c.email)} />OSS-native visual identity (per vulos-cloud/DESIGN_OSS_NATIVE.md): near-black
canvas, mono-led type, themeable --accent, reserved --brand. All colours come
from CSS custom properties in src/tokens.css — no hardcoded hex in components.
Light theme via [data-theme="light"] on a host element. Email HTML is always
sanitised before render.
Email HTML is hostile by default, so every body is run through DOMPurify
(sanitizeEmailBody, in src/components/sanitize.js) before it is inserted into
the DOM — there is no path that renders raw message HTML.
- Strict allow-list — script / iframe / object / embed / form / input /
button / style / link / meta / base tags are forbidden, as are inline event
handler attributes (
onerror,onload,onclick, …). Data attributes are dropped. - Anchors hardened — every link is forced to
target="_blank"withrel="noopener noreferrer". - Remote content blocked by default — external
<img>/ media (src,srcset,poster,background) and CSSurl(...)background images are neutralised so opening a message can't phone home or load a tracking pixel. The reader opts in per-message via "Load remote images", or per-sender via "Always show from " (a client-side localStorage allowlist — seeimageAllowlist.js; unknown senders always stay blocked). This only flipssanitizeEmailBody'sallowRemoteflag; it never globally disables blocking. - Fail-closed
data:gate — only rasterdata:images (png / jpeg / gif / webp / bmp / ico / apng / avif) are kept on content-loading attributes; any script-bearing or XMLdata:URI (image/svg+xml,application/xml, …) is stripped regardless of the remote-image toggle, so an allow-listed sender can never smuggle in SVG/XML that a blocked one couldn't. The compose side likewise refuses to embed SVG inline. - Attachment downloads are blob saves, never
innerHTML— no XSS surface.
npm run dev # standalone demo (in-memory mock client)
npm run build # demo SPA → dist/
npm run build:lib # redistributable library → dist-lib/ (+ mail-ui.css)
npm test # vitest — unit + MSW integration (no browser needed)
npm run test:e2e # Playwright — browser-level E2E against vite previewTwo complementary layers, both mocking the lilmail /v1 API so no backend is
needed:
Runs under vitest/jsdom. Beyond the component unit tests, the
src/test/integration/ suite mounts the real <MailApp/> wired to the
real createMailClient({ baseUrl: '/v1' }) and answers every request with an
MSW node server (src/test/msw/). The store is stateful,
so a flow is asserted end-to-end — e.g. archive → the message actually moves to
the Archive folder; send → the captured POST /v1/messages body is inspected
(including the reply In-Reply-To/References threading guard). The MSW server
starts globally in src/test/setup.js but only intercepts /v1 (everything else
is bypassed), so the pre-existing hand-stubbed-client tests are untouched. Flip
optional capabilities off via configureStore({ caps: { rules: false, … } }) to
exercise graceful degradation.
Runs the built demo under vite preview in real Chromium via
@playwright/test. Specs in e2e/ mock /v1 with
page.route (e2e/fixtures/mockV1.js) and drive a thin harness page
(e2e/app/) that mounts <MailApp baseUrl="/v1"> so the browser's network layer
is what gets intercepted. First run installs the browser:
npm run test:e2e:install # playwright install --with-deps chromium (once)
npm run test:e2eCI runs both layers as separate jobs (see .github/workflows/ci.yml).
<Compose/> sends through POST /v1/messages by default (via client.sendMessage).
A host may override onSend to route outbound mail through its own transport.
The bundled vulos-mail/webmail uses the default — its /v1 is reverse-proxied
to a lilmail engine that submits over SMTP.
- Categories & Labels map onto IMAP folders. Category-named folders (Social
/ Promotions / Updates / Forums) surface as the Categories group; any other
non-special folder becomes a coloured Label (nesting via
/). Absent such folders, the sections simply don't render. - Storage meter reads an optional
GET /v1/quota({ used, limit }bytes). Servers without it 404 and the meter is hidden (in the sidebar + Settings). - Calendar side panel uses the existing
GET /v1/calendar/eventspath; with no calendar capability the panel and its rail toggle don't render. - Archive / move uses
POST /v1/messages/:uid/move(lilmail's IMAPMOVE/COPY+expunge). If the server has no Archive folder or the endpoint is absent (older lilmail), the archive affordances are hidden automatically. - Delete moves to Trash via
DELETE /v1/messages/:uid(lilmail soft-deletes to Trash by default;?hard=trueexpunges). - Snooze (
POST /v1/messages/:uid/snooze), report spam (POST /v1/messages/:uid/spam), labels (apply as IMAP keyword flags; create / delete folders viaPOST/DELETE /v1/folders), filters / mail rules (/v1/rules), send-later / Scheduled (/v1/scheduled, gated on the server having a scheduler), and calendar RSVP (POST /v1/calendar/rsvp) are all capability-probed: on a404/405/501the corresponding UI (button, panel, view, command-palette entry) is hidden rather than shown broken — no dead controls. - Attachment upload stages files via
POST /v1/attachments(with progress), referenced by id when sending; download of received attachments usesGET /v1/messages/:uid/attachments/:partId(blob save). Both are capability-probed — where the route is absent the attach control is disabled with a tooltip and received attachments render as non-interactive chips; we never fake sending. - Inline images ship as
cid:multipart/related parts when the backend supports it, degrading to base64data:URIs otherwise (see the compose notes above) — so a pasted screenshot always sends, regardless of server age.












