Upgrades:
- next 16.2.4 -> 16.2.6 (CVE-2026-44578 WebSocket SSRF, GHSA-c4j6-fc7j-m34r, plus 11 other May 2026 advisories)
- react / react-dom 19.2.1 -> 19.2.6
- next-intl 4.x patched prototype-pollution advisory (GHSA-4c35-wcg5-mm9h)
Self-hosted deployments were exposed via crafted HTTP upgrade requests; patching here is required for prod.
batchDelete always issued a permanent JMAP destroy, ignoring the
deleteAction preference (default: trash). Mirror the single-delete flow:
resolve the trash mailbox for the current account and route the batch
through batchMoveEmails; fall back to destroy only when the setting is
'permanent' or no trash mailbox is available. batchMoveEmails now accepts
an optional accountId to support shared-folder bulk moves.
Removing the dynamic favicon link left Chromium painting the last cached
badged icon in the tab, so the "1" stuck around after reading the mail.
Keep the link and overwrite its href with the plain base icon when the
count drops to 0 to force a repaint.
Creating the outgoing Email directly in Sent before running
EmailSubmission/set makes Stalwart's duplicate-message check trigger
on inbound delivery when a user sends to themselves: the SMTP-
delivered copy matches the local Sent copy's Message-ID and gets
dropped, so the inbox stays empty.
Keep the email in Drafts (or the sent mailbox as a fallback if no
Drafts exists) during submission and let EmailSubmission's
onSuccessUpdateEmail atomically move it to Sent — clearing the
$draft keyword and setting $seen — only after the server has handed
the outbound copy to SMTP. SMTP delivery for email-to-self now sees
no existing Message-ID in the account and goes through.
Applies to both the draft-send and the send-from-scratch paths.
Closes#60.
Gmail stamps a Content-ID on every attachment it sends, even when the
HTML body never references the attachment inline. The viewer filtered
attachments by "!a.cid" so anything carrying a cid was assumed to be
an inline image and hidden from the attachment panel — the email
showed the paperclip indicator but no downloadable block, matching
the report's symptoms.
Compute a set of cids actually cited as cid:... inside the HTML body
and mark an attachment as inline only when its cid is in that set.
Non-cited cid-bearing attachments now render in the panel like any
other. Applied in both the single-message viewer and the threaded
conversation view.
Closes#58. Thanks @melges-morgen for the repro.
The badge hook was calling removeCurrentFavicons() — document.head
.querySelectorAll("link[rel~='icon']").forEach(l => l.remove()) —
which also removed the link that Next.js renders from app/icon.svg
and manages via its metadata reconciler. On the next render, React's
commit phase tried to remove the now-missing node and crashed with
"can't access property 'removeChild', finishedRoot.parentNode is
null".
Leave Next.js's icon alone. The hook now upserts its own link tagged
data-dynamic-favicon and cleans up only that one on unmount / count
returning to 0. Chromium picks the last matching rel="icon" link, so
the badge still appears there; other browsers fall back to the static
icon, which is an acceptable degradation.
Newsletter-style mail is usually laid out with width="600" or
style="width:640px" tables. On A4 with 12 mm margins the printable
width is closer to 180 mm, so the right edge of the content was
being clipped. Add print rules that cap every element in
#print-overlay to max-width: 100%, let tables auto-layout, and force
long URLs / single words to wrap instead of overflowing. Images,
svgs, videos and canvases also get max-width: 100% with auto height
so they scale down proportionally.
HTML emails that go through SandboxedEmailFrame render inside an
<iframe srcDoc=...>. cloneNode creates a fresh browsing context
that reloads srcDoc asynchronously — after beforeprint has already
fired and the browser has taken its DOM snapshot for printing — so
the printed page saw an empty iframe. Plain-text mail was fine
because it renders inline with dangerouslySetInnerHTML (which clones
cleanly).
When building the print overlay, walk each iframe's live
contentDocument.body and move its already-rendered children into a
plain div on the clone, copying over the computed font so it looks
the same as on screen. No innerHTML on the overlay — nodes are
cloned directly, preserving the upstream DOMPurify sanitisation.
fix(avatar): skip domain favicons for freemail providers
Showing Google's G for every gmail.com sender or Yahoo's logo for
every yahoo.com sender misrepresents the individual: the domain
belongs to the provider, not to the person. Maintain a small
freemail denylist (gmail, outlook, icloud, protonmail, proton.me,
yahoo, gmx, web.de, orange.fr, free.fr, etc.) that falls back to
the initials avatar regardless of whether the favicon service has
an icon for the domain.
DuckDuckGo responds with a 48x48 "no favicon" placeholder (HTTP 404
with an image body) for domains it doesn't have an icon for, and
both Chromium and Firefox fire the img load event for that response
and ignore the status code — so the placeholder was being rendered
as if it were the real favicon.
Route the lookup through a new /api/favicon edge route that fetches
the upstream, checks the status, and returns either the image body
on 200 or a status-only 404 on miss. Now the img element's error
event fires correctly for unknown domains and the avatar falls back
to the initials.
The route validates the domain shape and caches successful responses
for a week on the client. Domain-only lookup preserves the same
privacy posture — the email address never leaves the browser.
Only the Dutch locale carried contacts.delete_confirm_title and
contacts.form.delete; the other nine locales threw MISSING_MESSAGE
the moment a user hit the delete button from the contact detail
page. Add both keys across en, fr, de, es, it, ja, pt, ru, uk.
Also soften the domain-favicon avatar: when the favicon loads, swap
the backdrop from the initials-derived hue to white and render the
icon at 70% with object-contain + a thin ring. Transparent favicons
no longer bleed a random colour through and non-square logos keep
their aspect.
The overlay-and-clone trick was only set up from the Print button
click handler, so pressing Ctrl+P / Cmd+P or choosing Print from the
browser menu bypassed it and produced a blank page. Move the
overlay creation into a beforeprint listener attached while the
viewer is mounted, so every print trigger — shortcut, menu, or button
— goes through the same cloning path. afterprint cleans up on both
successful prints and cancellations.
The email viewer's quick-action row (Reply, ReplyAll, Forward, Archive,
Delete, Star and friends) and the tablet back button were visible in
the printed output. Apply Tailwind's print:hidden utility to both so
the printed page shows only the actual mail content — header, body,
and attachments — which is what users expect to archive on paper.
The More Actions dropdown and the Quick Reply section already had
print:hidden applied.
The previous :has()-based print rules worked in Chromium's headless
PDF export but produced a blank page in Firefox. Rather than fight
browser-specific differences in @media print with :has(), flex, and
visibility on deeply-nested ancestors, clone the email viewer DOM
into a dedicated #print-overlay appended directly to document.body
before calling window.print(), and add a html.is-printing class.
Print CSS now targets a single well-known id (#print-overlay) at the
body root: everything else is display:none'd (no :has() needed), the
overlay flows naturally on the page, and the subtree is forced to
black text on a transparent backdrop with a white root background so
dark-mode rendering prints cleanly on paper with Firefox's default
"Print background" setting off. afterprint removes the overlay and
clears the class, so both successful prints and cancellations clean
up.
Chrome defaults the "Background graphics" toggle off, so dark-mode
backgrounds don't print. The light-on-dark text of the viewer then
prints as white text on white paper — the page is blank.
Override the printed subtree with black text on a transparent
backdrop (and a single white backdrop on the root container), and
keep links in their conventional print blue. The dark mode of the
app no longer needs to leak into the printed document. Verified in a
headless print run with backgrounds off: a short message prints as
exactly one readable page.
The previous print rules kept the visibility: hidden + visibility:
visible pattern, which leaves the sidebar and email list in the layout
tree — they still contribute height. The outer flex h-screen wrapper
plus the long mailbox list made the printed document span several
pages even for a one-line email, showing the email on page one and
6–7 blank pages after.
Switch to a :has()-based display: none on every body descendant that
isn't on the ancestor chain of #email-viewer-container (and isn't the
viewer or its subtree). Those elements drop out of layout entirely,
so the document height reflects only the viewer's own content. The
ancestor chain keeps its flex styling but releases viewport-sized
height constraints. Verified in a headless print run: a short message
now renders as exactly one page.
The print rules introduced with the print-layout change left the
#email-viewer-container's utility animation (animate-in fade-in) in
place. Chrome samples keyframes when it snapshots the page for print,
so it frequently captured the opacity: 0 frame — the page came out
completely empty. Reproduced in a headless print run: the broken rule
set produces an 874-byte blank PDF; this fix produces an 18 KB PDF
with the full header, body text, and iframe content.
The replacement print block resets the viewer subtree's animation,
transition, transform, filter and opacity so no keyframe state leaks
through; switches #email-viewer-container to position: static and
resets the ancestor overflow chain via :has() so a long email prints
across multiple pages instead of being clipped to the first viewport;
and pulls @page margin up to 12mm so the content has breathable
page margins instead of colliding with Chrome's default headers.
When a contact has no photo, fall back to the sender domain's favicon
instead of coloured initials. The lookup is opt-in (settings → email
behaviour) and off by default. The favicon is fetched from DuckDuckGo's
public icon service using only the domain name — the email address
never leaves the browser, and nothing is hashed or correlated back to
the user. Initials stay rendered behind the image so the avatar still
reads right if the favicon 404s or the user is offline.
Adds a settings toggle + translations across all 10 locales.
Closes#22.
Archive now follows the Gmail / Apple Mail convention: when the current
message belongs to a thread, all of its siblings move to Archive in a
single Email/set. Keeps the inbox free of half-archived conversations.
Introduces client.moveThreadToMailbox(threadId, dest) which resolves the
thread's emailIds via Thread/get then batches the mailboxIds update
into one JMAP request, and a matching store method that reconciles
local counters for the source and destination mailboxes without a full
refetch. Single-message mail (no threadId) falls back to the existing
per-message path.
Closes#49.
OAuth2/OIDC with PKCE needs crypto.subtle.digest(), which browsers
only expose in secure contexts (HTTPS or localhost). Loading the
webmail over http://host without a TLS proxy in front surfaced as
"TypeError: can't access property 'digest', crypto.subtle is
undefined" when the user clicked "Sign in with SSO" — unhelpful.
Guard handleOAuthLogin with window.isSecureContext + a crypto.subtle
check and surface a translated message ("SSO requires a secure
connection…") in an amber warning banner instead. Key added to all 10
locales.
Closes#23.
Selecting a nested folder as a filter destination stored only the leaf
name, producing a Sieve fileinto action the server could not resolve
(e.g. "Foo" instead of "Inbox/Projects/Foo"). Add a getMailboxFullPath
helper that walks parentId and joins with "/", and route the filter
modal's move/copy dropdown through it so the emitted script references
the full path.
The helper guards against unknown ids, orphaned parents, and
self-referential cycles.
Closes#62.
Replace the default Next.js favicon.ico with an SVG mail icon from
Lucide and add useFaviconBadge, a canvas-based hook that draws a red
unread-count badge on the tab favicon. Shows "+" when count > 9. On
Safari 15 and below (no Canvas roundRect), falls back to fillRect.
Also fixes a real JMAP push bug in the email store: Mailbox state
changes on push weren't always paired with Email changes, so unread
counts in the sidebar could go stale when a new message arrived. Now
Email changes refresh mailbox counts too, and the Mailbox branch
de-dups when both fire in the same push.
Credits #63 (@jabiinfante).
Contact loading silently returned empty when the address book held
more entries than the server's maxObjectsInGet (Stalwart defaults to
500). Replace the single back-reference get with a two-step flow:
query IDs first, then pack a batched get into one JMAP request. All
batches ride a single HTTP roundtrip via multiple method calls.
Address books at or under the server cap still get a single method
call, so no behavior change for small lists.
Adds getMaxObjectsInGet() capability helper that mirrors the existing
getMaxCallsInRequest() / getMaxSizeUpload() shape. 83 new test cases
cover single-batch, exact-boundary, over-cap, and direct-ids paths.
Closes#45. Credits #46 (@capitanroy).
The print output was capturing the entire window, so portrait pages
only showed the sidebar and message list. Scope the print rendering
with a @media print block that hides everything, then re-reveals the
#email-viewer-container subtree and positions it full-page. Add
print:hidden to the More Actions dropdown and the Quick Reply section
so they don't appear in the printed output.
Credits #55 (@prastowoagungwidodo).
Apply whitespace-nowrap + overflow-hidden + text-ellipsis to the "New
Contact" / "Import vCard" buttons on the empty state, and add flex-wrap
+ min-w-0 on the parent so longer future translations wrap to a new
row instead of clipping behind the ellipsis. Icons get shrink-0 so
they stay rendered.
Credits #56 (@prastowoagungwidodo).
Adds a `{{major}}` tag so users can pin to `jmap-webmail:1` and receive
non-breaking minor/patch updates without manually bumping the tag each
release.
Closes#57. Thanks @joelpurra.
Defense-in-depth. All current callers pass hardcoded tailwind class
strings, so this is not exploitable today, but a future caller that
forwarded a user-controlled value would get HTML injection through the
class attribute. Run the value through escapeHtml() and add a test
covering the attribute-escape case.
Plain-text email bodies were escaped for <, >, & but not " or ', and the
URL linkifier regex captured every non-whitespace character up to the
next <. A URL containing a double or single quote broke out of the
href attribute in the rendered anchor, allowing arbitrary event handlers
to be injected into otherwise plain-text mail. Reported by @rathlinus.
Extract a shared plainTextToSafeHtml helper in lib/email-sanitization.ts
that escapes all five HTML-significant characters in the correct order
before linkification, and route both email-viewer and thread view
through it. Add tests that parse the output and assert no onmouseover
attribute lands on the anchor element.
Also bump dependencies flagged by npm audit: next 16.2.4 (DoS in Server
Components), next-intl 4.9.1 (open redirect), dompurify 3.4.0
(FORBID_TAGS bypass); picomatch/vite/brace-expansion resolve
transitively. npm audit is clean.
Bump version to 1.4.1.
Apache JAMES requires the type field on textBody parts per strict
RFC 8621, while Stalwart is lenient. This fixes compose failures
on non-Stalwart JMAP backends.
New features: folder management (#44), mail multi-selection (#43).
Bug fixes: health endpoint (#41), identity deletion (#42), inline CID
images, email list flicker, dependency updates.
Thanks to @wrenix, @ClemaX, @freddij, @dlecourtaltimafr, and @capitanroy
for reporting issues and contributing to this release.
Copy source button now shows a checkmark and "Copied" text for 2s
after clicking. Dark mode email background blend target changed to
perfectly neutral gray (R=G=B) to eliminate perceived color cast on
certain displays.
Emails with embedded images (cid: references) displayed them as
downloadable attachments because the browser cannot resolve cid: URLs
and the attachments were not filtered. Added fetchBlobAsObjectUrl to
the JMAP client, pre-fetch inline images as object URLs, replace cid:
references in sanitized HTML, and filter CID attachments from the
download list in both the email viewer and thread conversation view.
Shared isLoading flag caused the list to dim on unrelated operations
(send, batch actions, mailbox refresh). After-action fetches also
triggered a full loading overlay unnecessarily. Now operations that
don't fetch emails no longer set isLoading, after-action refreshes
use silent refreshCurrentMailbox, and the loading overlay only appears
after a 300ms delay to skip fast fetches.