The demo user becomes Nora Lindqvist, engineering lead at Meridian Labs,
in the week of a product launch. The fixtures tell one story across mail,
contacts, calendar, tasks and files, and exercise the features the
screenshots and the public demo should show:
- a second, mail-only login (her personal domain) and the shared Support
mailbox delegated to the work account, so the account switcher, the
shared section and the merged inbox have real content
- raw headers the way Stalwart writes them: the sender check fails on
the phishing mail, newsletters carry a one-click unsubscribe, one mail
asks for a read receipt, and spam scores show in the details
- an iMIP invitation with a served .ics, an event waiting for the answer
on the calendar, and RSVP patches applied in the demo client
- @mentions as mailto links in a received mail, a verification code
twelve minutes old, nested folders, named tags on the mail, contact
groups, a scheduled message and tasks switched on
- every attachment and file backed by real bytes under public/demo,
generated by `scripts/demo-assets/generate.mjs`; contact photos are
bundled portraits instead of requests to randomuser.me
- folder counts computed from the mail instead of hand-kept numbers
Demo login seeds the tags, templates and merged-inbox settings once per
device and switches the cross-account gate on for the session; "Reset"
restores them. `bulwark-demo-simulate=off` in localStorage stops the
simulated incoming mail for screenshot runs.
The quiet window ignored which account rang, so a second, different
message in the same account within 30 seconds arrived without sound, and
every single-login user lost the alert too. The window now records the
account of the last audible alert and silences only other accounts,
which is the burst across logins the change is meant for.
Each account has its own notification, so one message reaching several
of the user's accounts rang once per account. Only the first alert in a
30 s window makes a sound now; the others still appear or update,
silently. The window runs from the last audible alert, so a steady
trickle still rings every so often.
Clicking a new-mail notification opened `/mail/message/<id>` in
whichever account was active. With several accounts signed in, that
looked the id up in the wrong account: nothing opened, or a different
message did and was marked read.
The preview API now returns the cookie slot of the login that owns the
notified account, the service worker adds it to the permalink (or links
that account's Inbox for a group notification), and the app switches to
the login on that slot before opening the message.
Clicking a push notification on Android closed the notification and did
nothing: the app never came to the front.
handleNotificationClick navigates an existing client and then focuses it:
if ("navigate" in client && targetUrl) {
await client.navigate(targetUrl);
}
return client.focus();
A notification click grants the service worker a transient user
activation, and both focus() and openWindow() refuse to run without one.
navigate() spends that activation, and it also replaces the client's
document, which invalidates the WindowClient handle the next line uses.
So navigating first breaks the click two ways over.
Instrumented on a device, against a backgrounded installed PWA:
client 1: navigate ok -> focus() threw NotFoundError (stale handle)
client 2: navigate ok -> focus() threw InvalidAccessError (activation spent)
openWindow() -> threw InvalidAccessError (same)
Every rejection was swallowed by the surrounding catch, so the toast
closed and nothing opened, with nothing in any log to say why.
Focusing first uses the activation while it is still live. navigate()
needs none of its own once the client is focused, and it is wrapped so a
failed navigation still leaves the app raised rather than falling through
to opening a second window.
Running in production since 2026-08-24.
Each email push showed a notification with a unique tag
("bulwark-mail:<id>"), and Android renders one notification per unique
tag — so a burst of mail stacked one toast per message (50 arrivals =
50 toasts).
Use a shared per-account tag ("bulwark-mail:<accountId>") so each push
replaces the account's single notification. The newest message stays the
headline and a Gmail-style "+N more messages" line, counted from the
account's unread total, carries the rest. A single unread still deep-links
to that message on click; a group opens the inbox.
The service worker hard-coded the notification icon and badge to the bundled /icon-192x192.png, so push notifications always showed the default Bulwark logo even when an admin had configured a custom PWA/favicon icon (which the manifest already honors via /api/pwa-icon).
Point both notifications at /api/pwa-icon/192, and make that endpoint fall back to the bundled default icon instead of returning 404 when no custom icon is set - so it always returns an app icon and the service worker (which can't run the custom-vs-default check itself) has a single stable URL.
* Added account selection for protocol links when multiple connected accounts are available, including mailto: links
* Added support for handling mailto: links in an already-open PWA/session instead of always opening a new tab
* Added webcal: protocol handling for calendar links
* Added account selection for webcal: links when multiple calendar-capable accounts are connected
* Added an import-or-subscribe choice for detected webcal calendars
* Added protocol handler settings for registering mail and calendar handlers and choosing the open mode
* Added service worker/session coordination for passing protocol requests between browser/PWA contexts
* Added tests and translations for the new protocol handler flows
Delete static public/manifest.json (hardcoded "Bulwark Webmail") and mark
app/manifest.ts as force-dynamic so Next.js evaluates APP_NAME at request
time instead of build time, fixing the native browser install prompt.
Closes#207
- Display Bulwark logo on the login page, with theme-aware switching
between light and dark variants based on the resolved theme
- Add LOGIN_LOGO_LIGHT_URL and LOGIN_LOGO_DARK_URL env variables to
allow custom logo overrides (defaults to bundled Bulwark branding)
- Expose logo config through /api/config endpoint and useConfig hook
- Add logo URL prompts to the setup.sh installer branding step
- Rename branding assets to use underscores instead of spaces
A modern, privacy-focused webmail client built with Next.js and the JMAP protocol.
Designed for Stalwart Mail Server.
Features:
- Full email operations (compose, reply, forward, threading)
- Real-time push notifications
- Dark/light theme support
- Mobile responsive design
- Keyboard shortcuts
- Drag-and-drop organization
- i18n (English/French)
- Security-first (external content blocked, HTML sanitization)