Waxwing is a modern, minimalist webmail client that runs entirely in the browser and talks
directly to a JMAP mail server such as Stalwart — over HTTPS, with live
updates on an SSE stream. It ships as plain static files: Stalwart can host it itself (via its
Applications feature), or you serve it from any web server or CDN. Installable as a
Progressive Web App with real push notifications, offline reading, and an offline outbox.
The waxwing is named for the sealing-wax-red tips of its wing feathers — the bird that
carries a wax seal. Silky plumage, no excess, letters delivered sealed: that's the brand.
Why
Classic webmail (Roundcube & friends) needs its own server-side stack because IMAP was
never meant for browsers. JMAP is: JSON over HTTPS, parsed and indexed by the mail
server. That makes the extra webmail server obsolete — Waxwing is the client that follows
through on that idea.
Install it
Stalwart can host Waxwing itself and keep it updated. One command, and it is done:
curl -u 'admin:PASSWORD' -X POST https://mail.example.com/jmap/ \
-H 'Content-Type: application/json' \
-d '{
"using": ["urn:ietf:params:jmap:core"],
"methodCalls": [["x:Application/set", {
"create": {
"waxwing": {
"enable": true,
"description": "Waxwing webmail",
"resourceUrl": "https://github.com/Heiko-W/waxwing/releases/latest/download/waxwing-stalwart.zip",
"urlPrefix": { "/webmail": true },
"autoUpdateInterval": 604800000
}
}
}, "c1"]]
}'
Restart Stalwart and open https://mail.example.com/webmail/. That is the whole installation:
no web server to run, no container, no database.
That is the Stalwart 1.0 spelling. Stalwart 0.16 calls the two fields enabled and
autoUpdateFrequency — docs/deployment.md lists everything
that differs.
autoUpdateInterval makes Stalwart re-fetch the release weekly, so new versions arrive on
their own. Say plainly what that means: it is a standing decision to run whatever this project
publishes next, in your users' browsers, against their mailboxes. If you would rather not,
point resourceUrl at a versioned asset and drop the field — then upgrading is deliberate, and
you can check what you are upgrading to:
sha256sum -c SHA256SUMS --ignore-missing # arrived intact
gh attestation verify waxwing-stalwart.zip --repo Heiko-W/waxwing \
--source-ref refs/tags/v0.26.1 # built here, from THAT TAG
--ignore-missing because SHA256SUMS lists all three artefacts and you downloaded one;
without it the command fails on the two you did not fetch. --source-ref because without it
the check also passes for a build from any branch of this repository — including the rehearsal
builds the release workflow makes on purpose. Name the tag you are installing.
The deployment guide has both forms, and
SECURITY.md §4 says what each check does and does not cover.
Not using Stalwart, or want it behind your own nginx? Two more paths — and the honest
trade-off of the cross-origin one — are in the deployment guide.
What it does
- Mail — conversations, a virtualized list that stays smooth at 100 000 messages, full-text
search, labels, and triage that works entirely from the keyboard
- Compose — rich text (Fastmail's Squire), draft autosave, attachments, undo send, and
identities you can actually manage: add, edit or delete a send address and its signature
- Contacts — JMAP for Contacts (RFC 9610) address books, groups, composer autocomplete
- Filters — server-side rules over JMAP for Sieve (RFC 9661), so they keep sorting your mail
while the app is closed. A script you already had is preserved untouched, never rewritten
- Live — push over EventSource (SSE), system notifications via Web Push
- Offline — opened without a network, the installed app opens your mailbox, not a sign-in form:
a local replica and an outbox that survive a reload, a reconnect and a cold start
- Private — remote content blocked by default, message bodies rendered in a script-free
sandboxed frame, zero telemetry
- Yours — minimalist design, dark/light, white-label through
config.json + theme.css
with no rebuild
- In your language — 14 of them: Čeština, Deutsch, English, Español, Français, Italiano,
Nederlands, Polski, Português, Türkçe, Русский, Українська, 中文, 日本語. Only the first two
have been read by a native speaker — see below
Status
v0.26.1 — feature-complete, and deliberately not 1.0 yet.
Every planned work package is done and the release gate is signed off: 6 053 unit tests, 20
integration tests against a live Stalwart, and 259 end-to-end tests (two more skipped) across the
seven Playwright suites the gate runs, on Stalwart 1.0; the CI runs the same suites against
0.16.27 as well. Performance and accessibility are measured rather than asserted — the numbers are
in the implementation plan.
v0.26.1 is two small fixes from daily use. Triage stacks Undo toasts, which by design
(ADR-021) never expire; from two of them
on there is now a Dismiss all button that closes the pile without running any Undo. And the
unread count on the installed app's icon has always followed the notifications switch in
Settings → Notifications, which nothing there said — a sentence under the switch says so now,
shown only where it is true (the browser has the Badging API and the permission is not blocked).
Whether macOS actually paints the badge has not been checked on a real Mac.
v0.26.0 runs on Stalwart 1.0 and on 0.16.27, and the same suites are green on both. Stalwart
1.0.0 shipped on 26 October 2026 and its release notes name almost nothing a client sees. Run
against the same fixture, eight things broke or changed, two of them badly: the release bundle
could not be installed at all (1.0 refuses a bundle without stalwart-app.json, and reports success
while it does), and signing in with OAuth stopped working (1.0 answers client_id=waxwing with
"Unknown client id"). The Files screen, the calendar month and shared address books and calendars
broke too, and the people search in the share dialogs finds nobody until an administrator allows
directory queries. All of it is fixed and tested; ADR-048
has the table. If you run Stalwart, upgrade Waxwing before you migrate it to 1.0 — a migrated
server that still holds an older bundle has no webmail until the next fetch. The CI now runs the
whole gate against both servers. The release also takes DOMPurify to 3.4.17, which closes a
critical and a high advisory in the library that cleans every HTML mail (both concern options
Waxwing does not set — SAFE_FOR_TEMPLATES and IN_PLACE — which is why it was not urgent, not why it
was skipped), and 13 more dependency alerts. Five development-only alerts on Vite stay open on
purpose: the fix is in Vite 8.1.6, whose bundler splits the same source into different chunks and
takes the initial JavaScript from 295.46 to 310.19 kB, over the 300 kB budget. They concern Vite's
dev server, not what ships.
New with 1.0, in Settings → Account & security: the apps signed in to your account — what each
may reach, when it was last used, from where — and a Sign out for each. Signing out of Waxwing
now also revokes the token on a 1.0 server, which Stalwart 0.16 could not do (checked against a
real server: the sign-in's grant is gone from the account afterwards). Searching for a word inside
a PDF attachment works on 1.0 without any change here — the server indexes attachment contents
now (its notes list Office, OpenDocument, EPUB and RTF too; the PDF is what was measured), and
Waxwing's search asks it.
v0.25.1 is compatible with Stalwart up to v0.16.25. The end-to-end suites now run against
Stalwart v0.16.25 (they ran against v0.16.18). The first run found one break that no Stalwart
changelog mentions: from v0.16.20 the account language is spelled de-DE, not de_DE, and the
server refuses the old spelling — so "Language of server messages" could not be saved on any newer
server. Waxwing now reads either spelling and writes the one the server uses, so v0.16.18 through
v0.16.25 all work: the whole suite is green on v0.25.1 / v0.16.25, and the language setting is
also checked against v0.16.18 and v0.16.19 (the old spelling) and probed on v0.16.20–v0.16.22. The
release also takes DOMPurify to 3.4.16 (both of its advisories concern the IN_PLACE mode, which
Waxwing does not use) and closes the nine other open dependency alerts, all of them build- or
test-time.
v0.25.0 draws every account's calendars in one grid. A shared or group calendar used to be
reachable one account at a time — an entry in the rail, and the whole screen switched to it. That is
the wrong shape for a calendar: the question a shared calendar exists to answer is "when are we all
free", and a screen showing one account at a time cannot answer it. Now every calendar-serving
account is in the same month, week and agenda, each calendar with its own section in the rail, its
own tick and its own colour
(ADR-047).
Two consequences worth naming. The tick is this device's decision now, not the account's — it
used to write the server's isVisible, which is impossible on a calendar shared with you read-only,
so half the rows had a tick box that could not be ticked. And events wear their calendar's
colour, including calendars the server left without one: Stalwart's default calendar has no
colour, so "leave colourless calendars neutral" would have drawn both accounts in the same chip —
the one thing the merged grid exists to prevent.
v0.24.1 closed every open dependency advisory, including one that had been held open. Seven
alerts, six of them high, all in build- and test-time packages — the shipped bundle is byte-for-byte
the same size. The esbuild one had been documented as a deliberate exception since 2026-08-18
because the fix steps outside tsup's declared range; tsup has not moved, but vite — the
toolchain that builds the bundle — accepts the newer major and develops against it, and
build:libs on it emits byte-identical output. Measured, not assumed. Nothing was dismissed.
v0.24.0 opened what other people have shared with you — and fixed the reason it could not work.
A shared calendar or address book has appeared in the session since S-1 and been unreachable in the
client ever since. It now has a place: the contacts rail groups books by account, the calendar rail
gains an account entry, and a delegated book opens in ITS account rather than in the same-id book in
your own — JMAP ids are per-account and short, so that collision is real
(ADR-018). The rails came from a contributed
pull request (#68).
They drew nothing, and the reasons were underneath them. The client reads its contacts and calendar
out of the replica, only a sync engine writes there, and only a MAIL account got an engine — so an
account that shares an address book and no mailbox had no rows to draw, indefinitely, while
AddressBook/get was returning the book to the same session. Chasing that turned up an older defect
that is not about sharing at all: Identity/get is refused on every delegated account, and that
refusal was killing the whole sync pass. Every shared mailbox has silently not synced its
contacts, calendar and files since the engine fleet existed — its mail worked, which is why nobody
noticed (ADR-046).
v0.23.0 was the September code review, worked to the end — and the two Musts it left open.
112 findings from a review in eight dimensions, each one adversarially re-checked before it was
believed, each fix pinned by a regression test that goes red when the fix is removed. The full
list is in docs/reviews/2026-09-01-code-review.md.
Five of its own recommendations were disproved while implementing them and solved another way;
the reasoning is at each finding.
Two of the findings were Musts the plan had parked, and both are now built:
- Opened offline, the app opens your mailbox. An installed PWA opened without a network used
to boot its shell out of the precache and then land on the SIGN-IN FORM reading "Could not
reach the server" — with a fully populated replica behind it and no way to reach it, because
the JMAP session document was held in memory and fetched from the network at every start. It
now lives beside the credentials in the encrypted store, and the e2e tripwire that had pinned
the defect since M3.5 is the cold-start test it always asked for
(ADR-041).
- "Select all" reaches the folder, not just the window. In a folder of 300 it selected 50.
The bar now offers a second step that pages the rest out of
Email/query, and the selection is
a snapshot rather than a subscription — so "300 selected" means the same thing when you press
Delete as it did when you selected
(ADR-042). Undo carries the full set.
The defects worth naming are the ones nobody had filed: a draft lost its Bcc recipients on
open-and-close, blank lines were dropped from sent mail in both composer modes, a signature
change was silently discarded in plain-text mode, a delivered message could be reported as "send
interrupted", and two buttons looked usable while refusing every click — aria-disabled with no
visual half, one of them since M1.4. That last one was found by looking at the built app on a
phone and a tablet with the network actually off; 5 900 green tests could not see it.
Importing 500 contacts takes 450 ms instead of 15.4 s.
One more finding came out of the release gate itself, and it is the kind worth publishing:
the focus sweep had never walked a whole tab order. It started each walk with
document.body.focus(), which in Chromium moves nothing — so it began wherever the last click
had left focus and covered 11 stops of 24 on the list, 5 of 28 on the reading pane, 5 of 37 in
the composer. The WCAG 2.4.7 and 1.4.11 verdicts this project has been reporting were measured
over a fraction of each screen. The walk is anchored now, asserts where it started, and is green
across every stop (ADR-045).
v0.22.0 was a security and implementation review, worked to the end. Forty findings from a
review in seven dimensions — each one adversarially re-checked before it was believed, and each fix
pinned by a regression test that goes red when the fix is removed. The full list, with what was
found and what was decided, is in
docs/reviews/2026-08-29-security-code-review.md.
The two that mattered most were both about a promise the code did not keep:
- A CSS comment walked through the inline-style allowlist. The browser's tokenizer strips
comments before strings or parens exist, so a
" inside one opened a string state the splitter
had and the browser did not — after which no ; was a declaration boundary and the whole
attribute fused into one allowlisted color, carrying a full-screen position:fixed overlay
with it. In the composer's quote path that lands in the app's own DOM.
- Re-authenticating destroyed public-computer mode. The choice rode in memory, re-auth is a
full-page redirect, and one click on "sign in again" at a shared terminal wrote a 30-day refresh
token back to disk under a checkbox promising the opposite.
Two findings are deliberately only half-fixed, and say so where it matters:
ADR-037 records that the
account switcher ships without the per-account store ADR-004 designed — the credential path is
closed, the menu now says "Sign in as …" rather than pretending the switch is free — and the
cache reaper prunes calendar occurrences but not contact cards, because a card is the address book
rather than a cached view of it.
v0.21.0 was twelve more languages — cs es fr it ja nl pl pt ru tr uk zh, fourteen in all.
A Stalwart operator asked for Russian, and asked in the same sentence whether they should fork the
project to get it (#50). docs/translating.md had
ruled machine translation out on grounds that are correct — a reader who confuses Discard with
Archive loses a message — and drawn the wrong conclusion from them. The pipeline that sentence
pointed at had produced no language in the whole time it existed, so the choice was never between a
machine translation and a reviewed one. It was between a machine translation and English.
ADR-036 is the argument;
translating.md, Contributing and the answer on
#50 all name which twelve bundles nobody has read.
What the gate takes off the table is every failure a reviewer cannot see anyway: key parity, the
plural forms each language actually selects (Russian, Ukrainian, Polish and Czech have four where
English has two, and a missing _few falls back to English mid-sentence for the counts 2–4), an
invented {{placeholder}}, a dropped {{product}}, a hardcoded brand name, a file that is more
than half English verbatim. What is left is wording — and wording is a one-line pull request.
Two real defects surfaced only because the bundle stopped being two Latin languages, both
reported independently by two of the translators:
- The attachment-mention heuristic was off, not weak. It matched on
\b, which is defined
against ASCII \w, so a keyword beginning with в, 附 or 添 cannot sit on such a boundary at
any position — including the start of the message. "You mentioned an attachment but attached
none" was silently dead in Russian, Ukrainian, Japanese and Chinese, for 19 of 19 keywords. Han
and kana get no boundary at all now, because a script without word gaps has nothing for one to be.
toLowerCase() folds Turkish İ to i plus a combining dot, so a mail opening "İlişikte…"
matched nothing. The fold takes a language now.
Both mutation-proven: three of the new tests go red against the old implementation.
The cost is stated rather than hidden: the service-worker precache grows from 478 KB to 769 KB
gzipped, of which 334 KB is the fourteen locale chunks. No budget is broken — initial JS is
283.94 KB gz of 300, and locale bundles have always been lazy.
v0.20.0 is the §13 finding list, worked to the end — ten Bxx findings fixed, three closed as
accepted trade-offs, one left open on the owner's decision.
Three of them were not tidying:
- A folder could be emptied and never recover (B17). Filed UNPROVEN for a month, reproduced
against the live fixture: Stalwart honours
upToId in deciding which changes to report and
numbers the additions against the whole result set, so an "unread first" window of 50 over 120
messages answers removed: 50, added: 50 with every addition at index 70–119. The client dropped
all fifty and wrote the window back empty, with a carried-over total and a live query state —
and only a voided window is ever re-queried. The folder would have shown nothing, online,
indefinitely. An addition that cannot be placed now means "this delta cannot be applied", which is
what cannotCalculateChanges already means.
- Every mailbox consumer had its own subscription (B10, ADR-035).
Twenty-nine of them, resolving on their own ticks. Demonstrated rather than argued: with the
reveal layer already re-rendered without Archive,
e still dispatched a move into the mailbox
that had just been deleted. One shared subscription now — which also removed an accident another
component was quietly living on, and is the more interesting half of that story.
- The message body frame is a tab stop and nothing said so (B6). Found by a new browser-side
focus sweep on its first run. Across the iframe boundary there is nothing to hang a CSS rule on:
with focus in the frame the
<iframe> matches neither :focus, :focus-visible nor
:focus-within and fires no focus event, while the framed document reports hasFocus().
Four security questions, answered by experiment rather than by argument (B25). The app's CSP
does bind inside the mail frame — the intersection of the two policies applies, which
csp.shipped.test.ts has reasoned from for two milestones without anything ever measuring it. Five
namespace-confusion payloads pasted through the real paste path leave nothing behind. And a JMAP
navigation cannot reach the service worker under a /mail/ mount, because scope decides that, not
the denylist.
WebKit is in the gate now (B11). It used to be four smoke tests run by hand; it runs the whole
read suite — the sanitizer, the reading pane, the split pane, the message list — on the one engine
whose disagreements have actually cost this project defects. One test is skipped there with the
reason at the skip (ADR-029).
Three findings are closed as accepted rather than fixed, each with the condition under which it
would be re-opened: an inexact window retraction whose exact form needs a versioned undo payload
(B14), a void gate that is deliberately not filter-aware because over-voiding costs a round trip and
under-voiding costs a wrong list (B15), and a count patch that is not re-applied for an intent the
server may already have processed, because a double-count never self-corrects (B18).
One stays open: content in a push notification while the app is closed (B28) would move the
access token, the SecretStore and the OAuth refresh into the service worker. Nothing of that is
there today, and that is what NFR-SEC-02 promises. Post-V1, on the owner's decision.
v0.19.0 is Waxwing read against Apple's Human Interface Guidelines — 39 findings for the
desktop, the tablet and the phone, all of them worked off
(the survey).
Full conformance was never the goal, and the audit says so on its first page: this is a web app
that also runs on Windows, Linux and Android, and part of the HIG presupposes a system menu bar,
window traffic lights and real vibrancy materials. What transfers is the other part — and it turned
out to be the larger one. Every finding carries a verdict of adopt, adapt or deliberately
differ; the third category came out empty, which is itself a result.
Five causes produced twelve of the findings:
color-scheme was declared nowhere. The tokens could paint the app dark; the parts the
browser draws stayed light — scrollbars, the open list of every native <select> (a system
popover on an iPad), date fields, the selection highlight.
- The blanket reduced-motion reset froze every progress indicator.
animation-iteration-count: 1 for everything meant the spinner turned once in a hundredth of a
millisecond and then stood still: the app told its reader "stalled" with the very mark that means
"working". WCAG 2.3.3 asks for reduced motion, not for none.
visualViewport appeared zero times. Send, Discard and every dialog footer sat under the
on-screen keyboard — the primary action of this app's central task, invisible for as long as
someone was typing.
- One of four safe-area insets was read, under
viewport-fit=cover and display: standalone.
The phone header — which carries the entire mail chrome on that viewport — padded 8 px against
the Dynamic Island.
- Form dialogs threw typed input away on Escape and on a press beside them. On a 390 px phone
that backdrop is not a thin margin; it is where a thumb lands reaching for the keyboard.
Plus context menus on four surfaces, ⌘Z for undo, grouped menus with separators, a folder rail that
can be put away and stays away, a split whose width is a share of the room rather than a per-tier
constant, an action bar docked in the thumb zone on a phone, and keyboard semantics for address
fields.
Two findings are decided rather than fixed, each with an ADR: the PWA launch screen cannot
follow the system appearance because the manifest holds one background_color and no media-query
mechanism (ADR-033), and
upload progress is not available behind the fetch seam
(ADR-034).
What the checks are for. Every one of those defects is invisible on a developer's machine, so
the round leaves five new static guards behind — color-scheme per theme block, env(safe-area-*)
only in tokens.css (the inset is physical, so a hand-written rule pads the wrong edge under
RTL), an explicit variant on every <Button>, the first test layout.ts has ever had, and the
contrast matrix over four palettes instead of two.
And what only looking found. A visual sweep at 390 / 834 / 1280 px against the live fixture
caught a defect six passing assertions could not: the header reported "Updated in 9 seconds" — a
time in the future, for a minute after every sync. The clock was read when the shell mounted and
kept in state; every test asked about "3 minutes ago", and the sign only flips in the first moment.
v0.18.0 is the folder rail, read from two screenshots of the running deployment. With three
accounts in it the rail was one long column of near-identical folder names, and the top-level
navigation spent 96 px repeating what its icons already said:
- An account folds away, and stays folded. The header row is the control; the state is stored
per device, so it survives a reload. A folded account keeps its Inbox unread count on its header —
folding must not hide the one thing about an account that is time-sensitive.
- A bracket down each section's leading edge, accented for the account whose mail is on screen.
Continuous on purpose: a header answers "whose folders are these" only until it scrolls away.
- The scrollbar was drawn in pieces, and only on macOS. The rail has exactly one scroll
container, so the reported gaps could not be two scrollbars — they line up with the account
headers, which are opaque and carry a
z-index, and a stacking context is what gets painted over
an overlay scrollbar. It cannot happen where the bar has its own gutter, which is why no suite
here could have seen it: they all run Chromium on Linux. The app now measures the platform's real
scrollbar at boot and reserves a lane only where one is needed.
- The navigation gave back 43 px — icons at 53 px on a desktop and 61 px on a touch tablet,
against 96 px before, with the touch target intact. The label is kept for screen readers and shown
as a tooltip, and the phone's bottom bar still prints it.
The flake that came with it was two test defects, and neither was in the app. One assertion
counted a list before it had arrived and then waited five seconds for a correct icon to disappear;
another aimed a click by hovering, in a rail that moves when a share notice arrives. Sixteen
consecutive runs of the suite passed afterwards, and CI recorded no retries for the first time since
v0.17.0. A third instance of the same class turned up in the test written to prove the first fix —
recorded, with the rule it produced: if a value crosses a store, a database or the network, wait for
it.
v0.17.0 is what the traces found. Six end-to-end suites had been failing intermittently for
months and it was being read as flakiness. It was not. Recording what actually went over the wire —
rather than reasoning about the code — turned the same symptom into five separate defects, four of
them in the app:
- A folder showed only the last 30 days of itself. One setting governed two different horizons:
how much mail the device keeps offline, and how much a folder shows. Opening a folder of older
mail said "No messages from the last 30 days" beside a sidebar listing it as a normal folder. The
cache window now bounds only what is kept
(ADR-030).
- New mail could arrive with no notification, and never get one. Two independent causes with one
symptom. A sync pass that failed after committing mail discarded the ids it had already
found — and the retry asks the server what changed since a state that already includes them, so
the banner was gone rather than late. Underneath it, the "is this new?" threshold was a
millisecond client stamp compared against a server timestamp that has one-second resolution, which
blanked out a whole second after every sign-in and every leadership hand-over.
- A calendar you had just shared said nobody had access. The share dialog rendered a snapshot of
the calendar taken when the row was clicked, so the grant that landed afterwards never reached it.
Grant, close, reopen — and be told "Only you.", for a calendar the server had already shared.
- Two things in the folder rail could not be clicked. The unread badge sat on top of the folder
menu and swallowed the click; and a row scrolled into view — which is what happens every time
keyboard focus moves down the tree — landed under the opaque sticky account header.
The harness was hiding all of it. Stalwart rate-limits per account, and the suites drive one
account through a hundred sign-ins in five minutes where a real client's entire sign-in is fourteen
requests — so the app kept meeting a budget the previous hundred tests had spent
(ADR-031). Opening a window is
also one request rather than two now, using the back-references JMAP has for exactly that
(ADR-032).
v0.15.0 closed the JMAP gap. A survey of what Stalwart offers against what this client used
produced 67 findings; 58 are implemented here. Calendars can be created and shared, series and
reminders edited, invitations sent and answered, .ics files imported; folders reorder, hide and
take a role; mail search leaves the trash out and understands OR, NOT and size; push
notifications carry sender and subject; files move, multi-select and search; calendars and files
read from the replica when there is no network; and app passwords and the account password can be
managed without leaving the client.
The survey was run against two live servers rather than against documentation, and that is what
it was worth. The pinned test fixture had drifted a version behind production — several
findings were untestable until it moved. Four plans written from drafts were contradicted by
the server, one of which (inCalendars for inCalendar) would have emptied the month view the
first time anyone hid a calendar. The end-to-end suites caught four bugs this release itself
introduced, with the unit tests green for all four. And the visual pass over 72 surfaces at
three widths stopped seven more, including a menu behind its dialog that made renaming a calendar
unreachable on a phone. The findings, the measurements and what was deliberately not built are
in the survey.
v0.14.0 was the second UI review, fixed. Sixty findings from a follow-up pass over the
running client at three viewport tiers, in both themes and across all six accent palettes —
including two settings that turned out to apply only half-way (the dark palette had four
surface tokens on one value, and five of six accents left the selection tint blue), and a
regression the previous round's own work had introduced. The list, its evidence and the three
findings withdrawn on reading the code are in
the review.
v0.13.0 closed the gap to Bulwark, the other serverless JMAP client, measured feature
by feature in the comparison. Sieve filter rules, a
calendar, file storage with sharing, multiple accounts, scheduled send, saved searches,
templates, snooze, winmail.dat unpacking and read receipts all arrived in that release. Every
JMAP shape in it was measured against a live server rather than transcribed from a draft, which
is how four of them turned out to differ from what the specification says.
One gap is left open on purpose: PGP and S/MIME signatures are recognised but not verified.
§5.1 of the comparison says which half is buildable and which is blocked, and why a tick this
client has not earned would be worse than no tick at all.
What 1.0 is waiting on is use. A mail client earns that number by being lived in for a
while, against more than one server and more than one mailbox. That has not happened yet.
Known gaps, stated plainly:
- No screen reader has been used on it by a person. The accessibility work is automated and
thorough; nobody has listened to it. See
docs/accessibility.md.
- Stalwart is the only server it has been tested against — currently v1.0.0 and v0.16.27, with
the same suites green on both. JMAP is a standard and the client reads the session capabilities
rather than assuming, but "should work" is not "does work". Stalwart changes wire formats where its
release notes do not say so: the language spelling inside 0.16, and eight things between 0.16.27
and 1.0 (ADR-048) — two of which stopped
sign-in and the Files screen outright. Check a new Stalwart version against the one tested here
before updating a server you depend on, and upgrade Waxwing before you migrate to 1.0.
- Cached mail is not encrypted at rest — a browser has nowhere to put a key. On someone
else's machine, tick "Public or shared computer" at sign-in: the cache is then removed on
sign-out, on closing the tab, and at the next start if the browser crashed first. The window
between a crash and that next start is the part no browser API can close, and the sign-in
screen says so.
- The phishing link check is friction, not a boundary. No warning means "nothing found",
not "checked and safe".
SECURITY.md says why in detail.
Documentation
Running it
Building on it
Contributing
Contributions are welcome, and the project is unusually explicit about what it expects — see
CONTRIBUTING.md. The short version: pnpm gate has to be green, and a
test has to fail when your fix is removed. That second one is the house rule; the guide
illustrates it with three real cases where a green test was measuring nothing.
There is no curated starter list — pick something that bothers you. Bug reports are genuinely
useful on their own, especially with a .eml attached for anything about how a message renders,
which is the only way to reproduce those exactly.
The translations need you
Waxwing speaks fourteen languages. Two of them — English and German — were written by a person.
The other twelve were machine-generated, checked mechanically (every key present, the plural
forms each language actually selects, no invented {{placeholder}}, no hardcoded brand name) and
read by nobody who speaks them. ADR-036
is the argument for shipping them anyway; the short version is that the alternative on offer was
English for everyone.
So if you read one of the twelve, the most valuable thing you can do here is tell us where it
sounds wrong. Fixing a string is one line in one JSON file — apps/web/src/i18n/locales/<lang>/common.json
— and node scripts/check-locales.mjs <lang> tells you in a second whether the file is still
valid. translating.md has the rest. An issue that just says "this button
says the wrong thing" is welcome too.
Licence
The app is AGPL-3.0-only: run it, modify it, host it for others — and if you modify it and
let people use it over a network, they get your changes too.
Two packages are MIT so that clients which are not themselves AGPL can use them:
@waxwing/jmap (a typed JMAP client, no runtime dependencies) and
@waxwing/jscontact (JSContact ⇆ vCard). Neither is on npm yet. The cut is
enforced by the dependency direction: neither MIT package imports AGPL code.
Everything else — apps/web, packages/mail-html, e2e, scripts, docs — is AGPL-3.0-only.
This explanation used to sit at the top of LICENSE, which cost the project its licence
badge: GitHub reads that file to identify the licence, ten lines of preamble made it
NOASSERTION, and a license:agpl-3.0 search did not return this repository at all. LICENSE is
now the verbatim AGPL-3.0 text — diff against gnu.org's copy is empty — and the scope note lives
here, where a reader was going to look anyway.
Development
Prerequisites: Node.js 24 — the version in .nvmrc, and not just a
recommendation — and pnpm ≥ 10 (corepack enable picks up the version pinned in
package.json). nvm use is enough.
This line used to say "≥ 22", and so did engines — which is how a newcomer ended up on Node 26:
install succeeded, the dev server ran, and then pnpm verify failed 54 tests with nothing in
eighteen thousand lines of output mentioning the Node version. Node ≥ 25 defines a global
localStorage that shadows jsdom's. Both ends are closed now: engines.node is ">=24 <25" and
engineStrict: true in pnpm-workspace.yaml makes pnpm install refuse the wrong major in a
second, and pnpm verify refuses to start on it as well.
pnpm install
Common scripts, run from the repo root:
| Command | What it does |
|---|
pnpm typecheck | TypeScript strict type-check across all workspace packages |
pnpm lint | Biome lint + format + import-sort check |
pnpm lint:fix | Biome auto-fix (lint + format + import sort) |
pnpm format | Biome format-write |
pnpm test | Unit/component tests (Vitest) |
pnpm build | Build all packages |
pnpm size | Build apps/web and check it against the size-limit budget (≤ 300 KB gz initial JS) |
pnpm verify | Run before committing — the fast gate, in this order: check:node → build:libs → typecheck → lint → test → size → check:dist → check:site → check:actions → check:nul (no Docker/browser) |
pnpm verify:e2e | The E2E gate (needs Docker): install chromium, bring the Stalwart fixture up + smoke, run Playwright, always tear down |
pnpm verify:all | pnpm verify then pnpm verify:e2e |
pnpm gate | The local pipeline — preflight (pins the Node major) → verify → the @waxwing/jmap integration suites against a live fixture → the E2E suites, with a per-stage summary |
pnpm gate:fast | The hermetic half of the pipeline; what .githooks/pre-push runs |
pnpm e2e:server | Start a local Stalwart JMAP server with test accounts (Docker) |
pnpm e2e:server:down | Stop it and wipe its ephemeral data |
pnpm demo | Dev-only raw end-to-end demo: Stalwart fixture + seeded mail + a throwaway login/read UI at http://localhost:5173 (Docker) |
pnpm demo --lan | Same, served on your LAN IP so another machine can open it (Basic sign-in only — see below) |
Before committing, run pnpm verify (and pnpm verify:e2e when you have Docker). These
scripts are the pre-merge gate: typecheck, Biome, tests, build, the size-limit budget, the
Stalwart fixture smoke and the Playwright suites. CI runs the very
same scripts — it was written that way on purpose (ADR-003),
so the local and hosted gates cannot drift apart.
The local pipeline (pnpm gate)
pnpm gate sequences those scripts and adds the three things a gate you run by hand cannot give you:
-
A Node preflight. The manifests and pnpm install reject the wrong major now, but a checkout
installed on 24 can still be RUN on another one, and nothing re-installs in between — on Node ≥ 25
a global localStorage shadows jsdom's and ~22 tests fail for reasons unrelated to the code. The
pipeline refuses to run rather than hand you results you would have to distrust.
-
The @waxwing/jmap integration suites, actually run (defect B22). They describe.skipIf
themselves away when the fixture is unreachable, so a skip was indistinguishable from a pass; the
stage brings a fixture up and then asserts nothing was skipped.
-
Automation. Enable the versioned hook once per clone:
git config core.hooksPath .githooks # pre-push runs `pnpm gate:fast`
Push anyway with git push --no-verify when you mean to.
.github/workflows/ci.yml runs the same stages by calling the same
scripts. Running it locally with act
was evaluated and rejected: act mounts the host Docker socket instead of nesting a daemon, so the
fixture's compose bind mounts resolve to non-existent host paths and are silently replaced with empty
directories — Stalwart then boots with no config and no diagnostic. Details in ADR-019.
Need a real mail server to develop against? pnpm e2e:server brings up a pinned, local,
plain-HTTP Stalwart instance with ready-made test accounts in one
command. See e2e/stalwart/README.md for accounts, ports, and
the dev-only TLS choice. Requires Docker.
Raw end-to-end demo (pnpm demo)
pnpm demo (work package SP.4) is a dev-only, throwaway UI that talks straight to the
JMAP fixture — login (OAuth + Basic), a mailbox list with counts, a paged message list, a
raw message view (text + naive HTML in a sandboxed iframe) and an Email/parse button for a
message/rfc822 attachment. It is not in the production bundle (it is gated on
import.meta.env.DEV && VITE_WAXWING_DEMO === '1', so every vite build dead-code-eliminates
it). One command brings up the Stalwart fixture, advertises the browser's origin, seeds
alice's inbox with demo mail, and starts a same-origin Vite proxy + dev server; Ctrl-C tears
everything back down.
pnpm demo # open http://localhost:5173 — Basic AND OAuth both work (secure context)
pnpm demo --lan # open http://<your-lan-ip>:5173 from another machine on your LAN
Sign in with the fixture account the banner prints ([email protected] /
waxwing-e2e-Pw1!; the form is pre-filled). LAN caveat: a plain-http LAN IP is an
insecure context, so crypto.subtle is unavailable — OAuth and "stay signed in" are
disabled there and only Basic sign-in works (the demo says so and disables OAuth). Serve
the demo over HTTPS at the LAN origin (or use localhost) if you need OAuth.
The matching Playwright check is pnpm e2e:demo (run it in a second terminal while
pnpm demo is up; it skips cleanly if the demo isn't running).
This is a pnpm workspace:
apps/web — the Waxwing SPA (AGPL-3.0)
packages/jmap — @waxwing/jmap, typed JMAP client (MIT)
packages/jscontact — @waxwing/jscontact, JSContact ↔ vCard 4 conversion (MIT)
packages/mail-html — @waxwing/mail-html, HTML-mail sanitizer + sandboxed renderer (AGPL-3.0)
e2e — Playwright suites + the Stalwart Docker fixture
docs — specification, tech stack, implementation plan, and ADRs