Commit Graph
730 Commits
Author SHA1 Message Date
Linus Rath 6dc344f547 Merge pull request #811 from hildebrandttk/fix/files-stale-data-on-account-switch
fix(files): reset the account-scoped Files drive on every account switch
2026-08-13 23:59:12 +02:00
Linus Rath 0f3cf0e7ea Merge pull request #807 from hildebrandttk/feat/event-editor-end-follows-start
feat(calendar): move the end with the start, keeping the event's length
2026-08-13 23:57:37 +02:00
Stefan Hildebrandt 5c06ae8bc2 fix(files): reset the account-scoped Files drive on every account switch
The Files store was the only account-scoped store never wired into the
account lifecycle: account-state-manager clears email/contacts/calendar/
filter/identity/vacation on a switch, but not files. Because the Files
store is a global that outlives the FilesApp component, the previous
account's client + resources lingered in memory and resurfaced whenever
the drive was reopened after a switch - most visibly when the switch
happened on the mail view (FilesApp unmounted), the common normal-shell
flow that the FilesApp re-init effect alone does not cover.

Clear the Files store centrally in clearAllStores() alongside the other
account-scoped stores, so the reset happens on every switch path.

Tests:
- lib/__tests__/account-state-manager.test.ts: assert clearAllStores()
  resets the Files drive (client, resources, pathStack, selection,
  supportsFiles) with no cross-account leak.
- integration/tests/13-files-account-switch.spec.ts (dockerized Stalwart,
  JMAP FileNode): seeds a distinct top-level folder in alice's and bob's
  drives and asserts the on-screen drive swaps on a switch with no stale
  folder - both switching off the Files view (mail -> switch -> open
  Files) and on it (rail account switcher). Verified: passes with the fix,
  fails (path 1) without it. New FileNode seeding helpers in helpers/jmap.ts;
  openFiles/openMailView/driveEntry nav helpers in helpers/app.ts.
2026-08-13 23:01:41 +02:00
Stefan Hildebrandt fa76bb895d feat(calendar): move the end with the start, keeping the event's length
Editing an event's start now shifts the end by the same amount, preserving the
current duration (end - start). Moving the start forward never leaves the end
before it, and moving it either way never silently changes the length. Guards
against an already-negative-duration state.

Extracted a pure `shiftedEnd(oldStart, oldEnd, newStart)` for the start
date/time onChange handlers. +7 unit tests. tsc + eslint clean.
2026-08-13 21:24:09 +02:00
Linus Rath 70383dea18 Merge pull request #796 from lucas-gaitzsch/fix/scheduled-send-polish
fix: polish scheduled send follow-ups
2026-08-12 18:12:28 +02:00
Linus Rath c6f3fffab5 Merge pull request #717 from dealerweb/fix/data-uri-allowlist-media-tags
fix: apply the data: URI allowlist to media tags too
2026-08-12 18:09:21 +02:00
Lucas Gaitzsch 17f18e378e fix: polish scheduled send follow-ups 2026-08-12 17:46:51 +02:00
zisisvakras 07ce2d0139 fix: scope separate translation hooks instead of relative namespace paths
next-intl has no "../" relative-namespace escape. A key like
t('../context_menu.not_spam') is not resolved relative to anything -
it is concatenated literally into the lookup key and always misses,
throwing MISSING_MESSAGE. email-list.tsx, about-data-settings.tsx,
and account-settings.tsx all used this pattern to reach keys outside
their own translation namespace.

Fixed by calling useTranslations() again for each additional
namespace needed, which is the supported way to reach sibling
namespaces, instead of trying to walk out of the current one.

The translations test already verifies that every key referenced in
source resolves in the en locale, but its extractor skipped any key
starting with a dot, which is exactly the shape of the broken calls,
so this class of bug never failed CI. Since no legitimate key can
start with a dot, the exclusion is removed. With it gone the test
reports all thirteen call sites fixed here when run against the old
code, and passes with the fix, so a reintroduced relative path fails
CI with the offending file listed.
2026-08-12 17:53:35 +03:00
Linus Rath 5e73633a80 Merge pull request #777 from hildebrandttk/fix/consistent-cross-account-id-namespacing
Fix/consistent cross account id namespacing
2026-08-11 17:37:55 +02:00
Linus Rath 799c7d6e5c Merge pull request #775 from gbatkhuyag/feat/mn-locale-support
feat(i18n): add Mongolian locale support
2026-08-11 17:34:46 +02:00
Linus Rath ca64e94dbd Merge pull request #770 from hildebrandttk/fix/calendar-alias-organizer-editable
fix(calendar): rights-first event editability, incl. alias organizers
2026-08-11 09:54:55 +02:00
Linus Rath 9972e5c630 Merge pull request #767 from paulhenry46/reply-forward-plugin-hooks
feat(plugins): add onBeforeComposeOpenToReply / Forward
2026-08-11 09:53:02 +02:00
Stefan Hildebrandt ed49e63f1f test(integration): multi-account address-book aggregation across a switch
Companion to the calendar spec for the cross-account id-namespacing fix. Logs
in two accounts, enables the Pro shell, and asserts both accounts' address books
stay aggregated (same set, no loss/duplication/corruption) after switching the
active account. Contacts keep no persisted id-selection, so this verifies the
aggregated list stays correct/usable rather than a selection reset.

Adds a data-testid on address-book rows (address-book-item + data-book-name/
-account) and openContacts / addressBookRows helpers.
2026-08-10 22:45:35 +02:00
Stefan Hildebrandt ce3c5db315 test(integration): multi-account calendar switch preserves selection
Docker/Playwright regression for the cross-account id-namespacing fix: log in
two accounts, enable the Pro shell, deselect a calendar, switch the active
account, and assert the deselection survives (it reset before the fix because
the previously-active account's ids flipped raw<->namespaced on the switch).

Adds the minimal test hooks: data-testid on calendar sidebar rows
(calendar-item + data-calendar-name/-visible), the settings tab buttons
(settings-tab-<id>), and a testId passthrough on ToggleSwitch used for the Pro
interface toggle. New app helpers: openCalendar (in-app nav, since a hard goto
to a deep link bounces to login), calendarRows, enableProInterface (toggles via
the settings UI - the app doesn't survive a hard reload and seeding proInterface
before login destabilises the add-account popover).

Verified green in the dockerized Stalwart harness.
2026-08-10 22:45:28 +02:00
Stefan Hildebrandt 583e20d9dc fix(calendar,contacts): namespace all accounts consistently on switch
Calendar/contact aggregation left the ACTIVE account's ids raw while
namespacing background accounts (<localAccountId>::id). So an entity's id
depended on which account was active; switching flipped the id form and
invalidated persisted references (selectedCalendarIds), broke isSubscription
matching, and destabilised selection - the account-switch bug users saw in
Calendar and Address Book (mail was fine: it keys per account + stable
virtual ids).

Invariant now: an entity is namespaced iff it carries a localAccountId;
originalId always holds the raw JMAP id. Consumers/mutations already resolve
the raw id via `originalId || stripLocalAccountPrefix`, so they are unchanged.

- prefix* functions (calendar + contacts) always namespace + set originalId;
  drop the isActiveAccount branch and the now-dead activeLocalAccountId params
  (callers in the hooks + calendar-app/contacts-app updated).
- mapServerEventToStoreEvent namespaces the event id with the matched calendar's
  localAccountId, so createEvent/updateEvent results are consistent.
- reconcileSelectedIds(): remap selectedCalendarIds by raw identity on every
  fetch, so a raw->namespaced transition (switch, just-created calendar, or the
  one-time migration from old persisted raw ids) keeps the selection instead of
  resetting to "all".
- isSubscriptionCalendar(): namespace-robust and collision-safe - resolve the
  calendar and match by raw id scoped to the owning JMAP accountId (Stalwart's
  raw ids like "b"/"c" collide across accounts).

+9 unit tests (selection stability across switch/create, subscription
robustness, no cross-account false positives). tsc + eslint clean; 368 store
tests green.
2026-08-10 22:45:21 +02:00
GitHub Copilot 8c19859f43 feat(i18n): add Mongolian locale support 2026-08-10 21:11:21 +08:00
Stefan Hildebrandt 06784581aa fix(calendar): exclude subscription/read-only calendars from event creation (#762)
The rights-first resolver already made EXISTING events in subscription /
read-only calendars read-only, but the create path still offered them:
event-modal's calendar dropdown listed every calendar. iCal subscriptions are
real Stalwart calendars (client-side feed written into a created calendar) with
writable myRights, so they appeared as selectable create destinations.

- canCreateEventsIn(calendar, isSubscriptionCalendar): writable AND not a
  subscription. Exported from lib/calendar-editability.
- event-modal: the calendar picker now lists only creatable calendars (plus the
  event's own calendar when editing), and new events never default into a
  subscription / read-only calendar.

+4 unit tests. tsc + eslint clean; calendar-editability suite 30 green.
2026-08-10 12:05:44 +02:00
dealerweb 3e58eb636c fix: apply the data: URI allowlist to media tags too
ALLOWED_URI_REGEXP excludes image/svg+xml on purpose - DOMPurify cannot
inspect the bytes inside a data: URI, so an SVG payload can carry
<script>/<foreignObject> the surrounding sanitizer never sees. That
exclusion never took effect on <img>, <video>, <audio>, <source> and
<track>: DOMPurify's attribute check short-circuits on DATA_URI_TAGS
before ALLOWED_URI_REGEXP is consulted, and that set can only be extended
via ADD_DATA_URI_TAGS, never trimmed. Any data: URI reached those tags,
including image/svg+xml and text/html; <a href> behaved as documented.

Not a live script vector - browsers render SVG-in-<img> in secure static
mode - but the sanitizer promised something it did not enforce, and the
print window renders sanitized bodies without the iframe sandbox and CSP
that otherwise back it up.

restrictDataUriResourcesOnNode re-applies the raster allowlist on those
tags, wired in through the existing afterSanitizeAttributes hooks. The
blocked-external-image placeholder becomes a 1x1 GIF: it is set in the
same hook pass, so an SVG placeholder would depend on hook order.

The three viewer call sites that hand-rolled
DOMPurify.sanitize(html, EMAIL_IFRAME_SANITIZE_CONFIG) now use
sanitizeEmailHtmlForIframe, which is the same thing and picks up the
guard automatically.
2026-08-10 11:50:18 +02:00
Stefan Hildebrandt 3898d0c1cb fix(calendar): rights-first event editability, incl. alias organizers
Event editability was inferred from identity (isOrigin + organizer match)
and never from the calendar's write rights. That opened events in read-only
shared / iCal-subscription calendars for editing, and once the address list
was widened with account aliases any alias-organized event became editable -
even in calendars the user cannot write.

- lib/calendar-editability.ts: getEventEditability() -> editable | rsvp-only
  | read-only, gating on the calendar's myRights (mayWriteAll / mayWriteOwn /
  mayRSVP) first, then organizer/participant identity. Subscriptions are
  read-only. Aliases now only refine the write-own / RSVP branches, so they
  can no longer over-open events (Stalwart: organizer identity does not
  confer calendar write; the ACL does).
- collectUserCalendarAddresses(): merge identities + account aliases so an
  alias organizer is recognised as the user's own; calendar-app fetches the
  principal aliases and passes isSubscriptionCalendar through.
- event-detail-popover / event-modal: derive Edit/Delete/RSVP/read-only from
  the resolver (three render states); read-only is new - no edit, no RSVP.

Tests: calendar-editability (26 cases over rights x role x subscription x
origin, incl. the alias-in-read-only regression) + calendar-participants.
tsc + eslint clean.
2026-08-10 09:09:55 +02:00
Stefan Hildebrandt 88a809bd69 test(integration): verify unread/starred cross views keep the acted-on row
Adds 11-cross-view-retain.spec.ts: with the Unread cross view open, reading a
mail keeps it in the list (status + unread counter update only, no removal);
with the Starred cross view open, un-starring keeps it. Both force the
focus/push refresh that previously dropped the row and assert it survives.

Supporting test hooks:
- thread-list-item: expose data-starred on the row.
- email-context-menu: testId ctx-star / ctx-unstar on the star item.
- integration helpers: expectEmailStarred (app.ts), setFlagged (jmap.ts).
- integration policy.json: enable crossStarredViewEnabled.

Verified: 08-shared-moves-style run of 11-cross-view-retain → 2/2 passed
against the dockerized stack.
2026-08-09 22:17:58 +02:00
Paulhenry Saux 604316c663 feat(plugins): onBeforeComposeOpenToReply, etc to allow plugins to edit emails before they are used to populate fields of composer 2026-08-09 14:20:26 +02:00
Linus Rath 7cb534348d Merge pull request #685 from paulhenry46/contact-cryptokeys-slot
feat: add contact-cryptokeys plugin UI slot
2026-08-07 01:43:16 +02:00
Linus Rath 706c6c23fd fix: send via the identity's own account client so DKIM matches the From domain #461 2026-08-06 18:55:08 +02:00
Linus Rath 69c8c041f9 feat: detect and import unrecognized JMAP keywords as tags #658 2026-08-06 18:44:14 +02:00
Linus Rath 1b10e027bd fix: keep inline images when replying to octet-stream cid parts #543 2026-08-06 18:41:01 +02:00
Linus Rath cfe2af4d8a fix: stop re-adding the organizer to the attendee list on every save #731 2026-08-06 18:29:34 +02:00
Linus Rath 11b6bdd8a6 fix: sync PWA title bar colour with the active theme #671 2026-08-06 18:23:21 +02:00
Linus Rath 23c4ab87da feat: confirm sending without a subject instead of blocking it #684 2026-08-06 17:31:50 +02:00
Linus Rath e2da05c130 feat: deep links for mail, calendar, contacts, files, settings and improve screen reader support
#733 #722 #721  #720  #719
2026-08-06 16:47:20 +02:00
Linus Rath 6f293bbf04 Merge pull request #729 from MrDeik/agent/push-notification-onboarding
feat: add background notification onboarding
2026-08-06 14:47:31 +02:00
Linus Rath e9c4929913 fix: address review feedback on push onboarding
- track session-only dismissal per account instead of a single account id
- label the close button with a dedicated dismiss aria string in all locales
- read PWA install prompt visibility from a shared flag and subscribe in a
  layout effect so an early visibility event cannot be missed
- restore real timers after the push onboarding tests
2026-08-06 14:46:32 +02:00
Linus Rath f056db5a05 fix(plugins): make attachment offload workable and keep the write gate closed
Review follow-up to #751.

The offload path could not actually run: onBeforeBlobUpload goes through
HookBus.transform, which capped every handler at the 5s default. A handler
that pushes the file to an external store blows past that, and on timeout
the error is swallowed, the original file id is kept, and the composer
attaches the big file anyway - silently - while three such timeouts in a
minute auto-disable the plugin. Buses can now carry their own per-handler
budget and this one gets 120s. The same ceiling existed inside the sandbox,
where http.post/http.fetch were bounded by the 30s default API timeout;
those two are transfers rather than round-trips and now get 120s as well.

PRIVILEGED_ONLY_METHODS listed 'upfiles.set', which matches no dispatched
method - the dispatcher calls it 'upfiles.save'. So the write was never
tier-gated despite the comment saying it was, and an untrusted plugin with
email:blob-write could replace the bytes of a file the user was about to
send. Fixed the name. Relaxing the read while that stayed open was the
wrong order.

Moving upfiles.get to email:blob-read is kept, but it is a wider grant than
the PR described: onBeforeBlobUpload hands the file id to every registered
handler, so the id is not a secret and any untrusted plugin holding
email:blob-read can read every file the user attaches. The consent copy now
says that; email:blob-write's copy was describing the read and now describes
the replace it actually gates.

Also in the composer offload path:
- sanitize the handler's html before it reaches the document and the sent
  message, and insert it through the editor at the caret rather than
  concatenating it after the signature and quoted block
- honour an abort that lands while the handler is offloading
- delete the right staged file when the handler re-saved it under a new id
  via upfiles.save, which otherwise stranded a copy in IndexedDB forever
2026-08-06 14:08:07 +02:00
Roman Varlakov 656a9c9c3b feat(plugins): let onBeforeBlobUpload offload an attachment
A handler can now return an ExternalAttachmentResult ({externalAttachment:
true, html, text}) instead of a file id. The composer then deletes the
staged file, removes the attachment and appends the replacement content
to the body, so a plugin can push a large file to an external store and
leave a download link behind. Returning a string (or nothing) keeps the
stock upload path byte-for-byte.

Also adds plainTextMode to the upload callback deps, which the new branch
reads to pick between the html and text replacement.
2026-08-06 17:25:27 +06:00
Linus Rath 57d802db85 Merge pull request #704 from guisea/fix/sent-attachment-list-hidden-when-unwrapped
fix: keep sent message/rfc822 attachment visible after inline unwrapping
2026-08-06 00:30:23 +02:00
Paulhenry Saux 3b519c3b3b feat: manage public keys and at rest encryption 2026-08-05 21:46:42 +02:00
Linus Rath da17464e05 Merge pull request #737 from dealerweb/fix/internal-mailto-links
fix: open address links in the built-in composer
2026-08-05 17:20:18 +02:00
Linus Rath 0fe484efda fix: time out stalled JMAP requests so sends can't hang forever #702 2026-08-05 17:19:10 +02:00
dealerweb 04996a3af1 fix: open address links in the built-in composer
Clicking an address in the app's own chrome - the recipient card, the
contact sidebar, the contact details - handed a mailto: URL to the OS
mail handler. In a webmail client that goes nowhere: the browser asks
which application should open the link, and the message is composed
somewhere else, if at all. Only users who registered Bulwark itself as
their system mailto handler got a usable result, and even then via a
detour through the OS into a new tab.

A new MailtoLink component sends the click to the built-in composer
instead. It stays a real anchor with an href, so "Copy email address",
middle click and assistive technology keep working; only the plain
left click is redirected, and only when a listener takes it - otherwise
the browser's own behaviour still applies. Modified clicks are left
alone.

The request reaches the mail page through a window event, the way
`bulwark:rate-limit-blocked` already does, rather than threading a
callback through three component levels and five call sites. It goes to
openMailtoDraft rather than the protocol path: an in-app click carries
its own context - the account being read - so the account picker that an
external mailto: needs would only get in the way here.

Message content is deliberately untouched. Plain text bodies render into
the main document rather than the sandboxed frame and their sanitizer
permits mailto:, so a document-wide interceptor would have swept up
untrusted links too; a component cannot be reached from message HTML at
all.
2026-08-05 14:42:24 +02:00
Eduard Polonskyi e036e10004 fix: sequence PWA and push onboarding 2026-08-03 15:31:17 +02:00
Eduard Polonskyi 573fab8b83 feat: add background notification onboarding 2026-08-03 03:23:08 +02:00
Mathy Vanvoorden 1652a0ec62 fix: make bulwark respect server limits
If you have a large number of tags, getTagCounts would not be able to get the
unread counts because it did not respect maxCallsInRequest, even though the
value was actually read out, it was just ignored. There are also other places
where the limits were not respected.

Batching is now generalized in a helper that also takes maxObjectsInSet, which
also was ignored, into account and is applied to all functions.

In addition, the dev mock now also advertises and enforces the limits, so these
issues can get picked up during development.

Possible closes #699
Possibly closes #399
2026-07-30 22:11:40 +02:00
Linus Rath e659fe3d38 feat: allow contact cards for organizations #701 2026-07-30 19:44:36 +02:00
Linus Rath 5a2bc6b671 Merge branch 'main' of https://github.com/bulwarkmail/webmail
# Conflicts:
#	app/api/dev-jmap/[...path]/route.ts
2026-07-30 19:17:43 +02:00
Linus Rath 59bc7fd64c fix: reply to own thread message addresses original recipients #703 2026-07-30 19:02:39 +02:00
Linus Rath 1cdbf75270 fix: use full tag path in drag-drop toasts, fresh email in context menu markAsRead
Nested tag toasts from drag-and-drop only showed the leaf name for
non-root tags, contradicting the comment above it and making two
same-named leaves under different parents (e.g. Personal/Receipts vs
Work/Receipts) indistinguishable in the toast.

The context menu's markAsRead handler was the one action left reading
the stale contextMenu.data instead of the live-refreshed
contextMenuEmail introduced alongside it, so it could act on outdated
email state while every sibling handler was already updated.
2026-07-30 18:39:49 +02:00
Mathy Vanvoorden f1e1ed1df7 fix: make the tint of selected rows work the same way in dark and light mode 2026-07-29 17:18:42 +02:00
Mathy Vanvoorden d9d9f91a86 feat: Make it easier to handle multiple tags
- Tags can now be removed straight from the email header
- Tagging control now allows the user to (de)select multiple tags in one go
2026-07-29 17:18:42 +02:00
Mathy Vanvoorden 108406a885 feat: improve visualization of tags
Previously tags where very much focused on color coding email and less about
adding additional information. They were also visualized in different ways in
different locations.

This commit gets rid of all "Color-coding" references, aligns visualization of
the tags across the whole project and tries to improve user experience of using
tags in general.

A search box is shown in the tagging control so the user can quickly search for
a tag if they have a huge (more than 10) amount of tags.
2026-07-29 17:18:42 +02:00
Mathy Vanvoorden 013ef7d557 fix: remove unused code
This code is nowhere used, so to prevent extra work during an upcoming refactor
of tags, it is removed and some related tests are now actually made useful
2026-07-29 17:18:42 +02:00
Mathy Vanvoorden 56d11b5759 fix: remove the reset-to-defaults button from tag settings
If you carefully crafted your tags and then click this button by accident, all
your hard work is gone. A confirmation message would be the other solution but
since I have difficulty to grasp when you would need such a button, I propose
to just remove it.
2026-07-29 17:18:42 +02:00