Skip to content

vul-os/mail-ui

Repository files navigation

@vulos/mail-ui

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.

Part of VulOS

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.

A Gmail-class webmail, in the Vulos aesthetic

@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 /v1 calendar 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 base64 data: 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.
  • 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/rules contract: 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 / ?, plus g-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 /v1 contract 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 via settingsExtra.
  • 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).

Screenshots

Generated by the Playwright screenshotter against the seeded mock /v1 demo (no backend). Regenerate with npm run screenshots.

Inbox (full layout) Conversation Compose
Inbox Conversation Compose
Sidebar (More / Categories / Labels) Calendar panel (mini) Calendar panel (expanded)
Sidebar Calendar Calendar expanded
Search Contacts Settings
Search Contacts Settings
Account Overlay panel + conversation
Account Overlay panel + conversation
Mobile Mobile drawer
Mobile Mobile drawer

Install

// package.json
"dependencies": {
  "@vulos/mail-ui": "file:../mail-ui"
}

Use

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 */ }} />
}

Configuring the API base URL

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).

Injecting an account / settings surface

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} />} />

Exports

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)} />

Design

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.

Security posture

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" with rel="noopener noreferrer".
  • Remote content blocked by default — external <img> / media (src, srcset, poster, background) and CSS url(...) 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 — see imageAllowlist.js; unknown senders always stay blocked). This only flips sanitizeEmailBody's allowRemote flag; it never globally disables blocking.
  • Fail-closed data: gate — only raster data: images (png / jpeg / gif / webp / bmp / ico / apng / avif) are kept on content-loading attributes; any script-bearing or XML data: 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.

Scripts

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 preview

Testing

Two complementary layers, both mocking the lilmail /v1 API so no backend is needed:

1. Unit + MSW integration (npm test)

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.

2. Browser E2E (npm run test:e2e)

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:e2e

CI runs both layers as separate jobs (see .github/workflows/ci.yml).

Status / graceful degradation

<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/events path; with no calendar capability the panel and its rail toggle don't render.
  • Archive / move uses POST /v1/messages/:uid/move (lilmail's IMAP MOVE/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=true expunges).
  • Snooze (POST /v1/messages/:uid/snooze), report spam (POST /v1/messages/:uid/spam), labels (apply as IMAP keyword flags; create / delete folders via POST/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 a 404 / 405 / 501 the 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 uses GET /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 base64 data: URIs otherwise (see the compose notes above) — so a pasted screenshot always sends, regardless of server age.

About

Shared React webmail UI for the Vulos suite — a reusable three-pane, Gmail-class mail component library that talks to lilmail's /v1 JSON API. Mounted by both Vulos webmail and Workspace; container-query responsive.

Topics

Resources

License

Stars

2 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors