222 Commits
Author SHA1 Message Date
Matthieu MALVACHE f0e8347fc6 chore(deps): patch Next.js SSRF + bump to 1.5.2
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.
v1.5.2
2026-05-14 22:28:50 +02:00
Matthieu MALVACHE 2ce06390ac fix(email): honor delete-to-trash setting for bulk delete
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.
2026-04-17 19:21:14 +02:00
Matthieu MALVACHE fd6c50015f fix(favicon): repaint base icon on zero unread so the badge clears
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.
2026-04-17 18:35:07 +02:00
Matthieu MALVACHE 885e9504fe chore: bump version to 1.5.1 2026-04-17 14:55:21 +02:00
Matthieu MALVACHE acbbcb134c fix(email): route outgoing mail through Drafts to avoid self-send dedup
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.
2026-04-17 14:48:17 +02:00
Matthieu MALVACHE 5f2792c991 fix(email): render Gmail-origin attachments (#58)
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.
2026-04-17 14:46:34 +02:00
Matthieu MALVACHE bfc7f57c19 chore(release): include i18n/ in the code files copied to public-release 2026-04-17 14:40:35 +02:00
Matthieu MALVACHE b68a3c02ed chore: bump version to 1.5.0 2026-04-17 14:37:09 +02:00
Matthieu MALVACHE 22eda063fb fix(favicon): stop tearing out Next.js's managed icon link
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.
2026-04-17 14:35:36 +02:00
Matthieu MALVACHE 8e659c1ed3 fix(print): shrink fixed-width HTML emails to fit on A4
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.
2026-04-17 14:32:36 +02:00
Matthieu MALVACHE 0a084613d1 fix(print): inline the sandboxed iframe body before print
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.
2026-04-17 14:22:17 +02:00
Matthieu MALVACHE a7a6c02b88 docs: add dev env rule against killing firefox 2026-04-17 14:20:37 +02:00
Matthieu MALVACHE 9978b1d407 fix(avatar): proxy favicon lookups so unknown domains fall back to initials
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.
2026-04-17 14:19:02 +02:00
Matthieu MALVACHE 88bebc968c fix(contacts): add missing delete_confirm_title and form.delete keys
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.
2026-04-17 14:14:03 +02:00
Matthieu MALVACHE 2a18f6a414 fix(print): wire the overlay via beforeprint so Ctrl+P also works
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.
2026-04-17 13:41:36 +02:00
Matthieu MALVACHE a053c8a38d fix(print): hide reply/archive/delete action bar and tablet back button
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.
2026-04-17 13:39:53 +02:00
Matthieu MALVACHE ce3b34f535 fix(print): clone viewer to a body-level overlay before printing
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.
2026-04-17 13:38:09 +02:00
Matthieu MALVACHE c42a51438c fix(print): force light colours so dark-mode emails print visibly
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.
2026-04-17 13:25:56 +02:00
Matthieu MALVACHE 52610ef782 fix(print): blank extra pages for short emails
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.
2026-04-17 13:10:51 +02:00
Matthieu MALVACHE 43ba0aef8a fix(print): empty page when email viewer carries an enter animation
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.
2026-04-17 13:01:03 +02:00
Matthieu MALVACHE 425fc971c4 feat(ui): optional domain favicon avatars
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.
2026-04-16 23:18:24 +02:00
Matthieu MALVACHE 08d12be925 feat(email): archive entire conversation, not just the selected message
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.
2026-04-16 23:16:09 +02:00
Matthieu MALVACHE f3bf6efedc fix(oauth): fail with a clear message when SSO is loaded over plain HTTP
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.
2026-04-16 23:12:38 +02:00
Matthieu MALVACHE 24ad26e388 fix(sieve): use full hierarchical path in filter move/copy actions
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.
2026-04-16 23:10:26 +02:00
Vsevolod Sauta 8a038595b4 feat: add russian and ukrainian locales 2026-04-16 23:07:36 +02:00
Jabi Infante 393fd12e8f feat: custom favicon with unread badge, fix stale mailbox counts
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).
2026-04-16 23:06:26 +02:00
Roy Petter Dyrdahl Torgersen 76cebb7905 fix(contacts): batch ContactCard/get to respect maxObjectsInGet
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).
2026-04-16 23:04:57 +02:00
Prastowo aGung Widodo 3c1a4496f9 fix(print): restrict print area to the email viewer
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).
2026-04-16 23:03:25 +02:00
Prastowo aGung Widodo e12bf45e84 fix(contacts): stop action buttons from wrapping on empty state
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).
2026-04-16 23:02:23 +02:00
Joel Purra 7272466ffa ci: publish major version container image tag
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.
2026-04-16 23:00:53 +02:00
Matthieu MALVACHE 09f72d3d4f fix(security): escape linkClassName in plainTextToSafeHtml
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.
2026-04-16 22:55:48 +02:00
Matthieu MALVACHE d33309a8b6 fix(security): escape quotes in plain-text linkifier, bump vulnerable deps
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.
2026-04-16 22:47:15 +02:00
Matthieu MALVACHE e0bdf99d0d chore: exclude superpowers dirs from public release, reinforce no-AI-reference policy 2026-04-16 22:46:55 +02:00
Prastowo Agung Widodo b8df1f3051 Fix: missing email_viewer.send translation 2026-03-23 22:58:06 +01:00
Matthieu MALVACHE c78b766f2a fix: add type text/plain to textBody for RFC 8621 compliance (#48)
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.
2026-03-23 22:57:39 +01:00
Matthieu MALVACHE 4bdf98525c chore: remove AI reference from spam header comment 2026-03-23 15:26:12 +01:00
Matthieu MALVACHE 3cd3e26825 chore: bump version to 1.4.0
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.
2026-03-23 15:21:20 +01:00
Matthieu MALVACHE 301386c709 chore: fix stale tests, rename spam label, update deps and fix flatted CVE
- Fix oauth-discovery tests expecting removed console.error
- Rename "AI Analysis" to "Spam Analysis" in email viewer
- Update next 16.2.1, tailwind 4.2.2, zustand 5.0.12, eslint 9.39.4,
  typescript-eslint 8.57.1, tanstack/react-virtual 3.13.23
- Fix flatted prototype pollution (GHSA-rf6f-7fwh-wjgh)
2026-03-23 15:20:53 +01:00
Matthieu MALVACHE d3ef46f760 fix: add copy-to-clipboard feedback and neutralize dark mode background tint
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.
2026-03-23 15:14:14 +01:00
Matthieu MALVACHE 3ba5cf16c3 fix(sidebar): fix context menu viewport clipping and delete dialog overflow (#44)
Render context menu and delete confirmation dialog via portal to escape
sidebar overflow-hidden. Clamp context menu position to viewport bounds.
2026-03-23 14:39:20 +01:00
Matthieu MALVACHE be6907f863 feat(sidebar): add folder drag-and-drop reparenting (#44) 2026-03-23 14:29:16 +01:00
Matthieu MALVACHE 01cb0f173f feat(sidebar): add folder management UI with context menu, inline editing, and move-to (#44) 2026-03-23 14:26:22 +01:00
Matthieu MALVACHE adbf46770d feat(drag-drop): handle mailbox reparenting on drop (#44) 2026-03-23 14:20:20 +01:00
Matthieu MALVACHE d14b8f6567 feat(i18n): add folder management translation keys for all 8 locales (#44) 2026-03-23 14:18:51 +01:00
Matthieu MALVACHE d9d6b02987 feat(drag-drop): extend context with dragType for mailbox drag support (#44) 2026-03-23 14:17:20 +01:00
Matthieu MALVACHE 0b952dcce4 feat(store): add mailbox CRUD methods with optimistic UI (#44) 2026-03-23 14:15:17 +01:00
Matthieu MALVACHE a030707cd6 feat(jmap): add Mailbox/set methods for create, update, destroy (#44) 2026-03-23 14:13:01 +01:00
Matthieu MALVACHE cb862994e3 fix: render inline CID images in email body instead of showing as attachments
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.
2026-03-23 14:11:25 +01:00
Matthieu MALVACHE 146921d5f0 fix: eliminate email list flicker during loading and after-action refreshes
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.
2026-03-23 14:11:09 +01:00
Matthieu MALVACHE c80a5a4d51 docs: add folder management implementation plan (#44)
8-task plan covering JMAP client methods, store layer, DragDropContext
extension, i18n, sidebar UI with inline editing and drag-and-drop.
2026-03-23 14:09:28 +01:00