Commit Graph
538 Commits
Author SHA1 Message Date
Ctaoist b09d5c857d fix: make label reordering atomic 2026-08-13 21:17:05 +08:00
Ctaoist 6e792f8834 feat: expose label reordering to extensions 2026-08-13 10:59:45 +08: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
Linus Rath 1cfbc48a9d Merge pull request #784 from zisisvakras/fix/relative-namespace-i18n
fix: scope separate translation hooks instead of relative namespace p…
2026-08-12 18:08:38 +02:00
Linus Rath 8ca553f92a Merge pull request #789 from ctaoist/mail-gateway
feat: expose label settings to extensions
2026-08-12 18:08:12 +02:00
dealerweb b11c3e8932 fix: apply the data: URI allowlist to srcset candidates too
DOMPurify URI-tests srcset as a single string, so an allowed first
candidate waved every later one through - an SVG data: URI could ride
in behind a raster candidate on <img srcset> and <picture><source>.
Check every data: occurrence against the raster allowlist and drop the
whole attribute on one disallowed hit, including the external blocker's
data-blocked-srcset stash, which shares the hook pass with no ordering
guarantee. The token is captured through the metadata-ending comma so
the plain (non-base64) raster form keeps the same verdict as in src.
2026-08-12 17:47: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
Ctaoist 01ef9240d4 refactor: generalize JMAP keyword capability 2026-08-12 22:47:00 +08:00
zisisvakras 44360024bc fix: check the specific capability a request declares, not a broader one
contactUsing(), calendarUsing(), and fileUsing() checked the base
"principals" capability but then declared the separate
"principals:owner" sub-capability (RFC 9670) in the request's "using"
array. RFC 8620 section 3.3 requires a server to reject the whole
request if it declares a capability the server does not advertise, so
a server that supports base principals without the owner
sub-capability rejects every one of these requests with
unknownCapability.

Fixed to check the specific sub-capability being declared instead of
assuming it from the broader one. The calendar agenda route had the
same mismatch and is fixed the same way. Note that RFC 9670 section
1.5.2 places principals:owner in accountCapabilities rather than the
session capabilities, so on compliant servers the sub-capability is
simply never declared, which is the intent. Nothing is lost by
omitting it.

The file share test asserted the old behavior by expecting the owner
capability to be declared against a server that advertises only base
principals. It now asserts the capability is omitted in that case and
declared only when the server advertises it. The same pair of
assertions is added for the contacts, calendars, and agenda route
request builders, so each site this commit touches is covered against
regression.
2026-08-12 17:34:20 +03:00
Ctaoist cf89eaad27 feat: expose gateway keywords to extensions 2026-08-12 21:40:02 +08:00
Ctaoist ddfd9e43e6 feat: expose JMAP keyword helpers to extensions 2026-08-12 20:43:55 +08:00
zisisvakras 37f830daa7 fix: resolve relative session URLs without corrupting URI templates
RFC 8620 does not require Session object URLs to be absolute, but
rewriteSessionUrl() called new URL(url) with no base, which throws on a
relative apiUrl or downloadUrl. The throw was silently caught and the
raw relative string returned, so requests resolved against the app's
own origin instead of the JMAP server.

Resolving with the URL parser and reading back href or pathname is not
an option: downloadUrl, uploadUrl and eventSourceUrl are RFC 6570
level 1 URI Templates (RFC 8620 section 2) whose literal {accountId},
{blobId}, {name} and {type} placeholders would be percent-encoded to
%7B/%7D, silently defeating the string replace() expansion at every
call site. Instead, anchor the path, query and fragment of the original
string at the server origin, which also covers network-path references
(//host/path) and does not fold a path prefix on the configured server
URL into relative resolution.

An empty URL is returned untouched: rewriting it into the bare server
origin would make it truthy and defeat the "not available" guards on
apiUrl and downloadUrl downstream.
2026-08-12 15:10:37 +03:00
Ctaoist f2c379b929 feat: expose label settings to extensions 2026-08-12 15:38:05 +08: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 da3d6c36b8 Merge pull request #768 from paulhenry46/uploadBlob-plugin-api
feat(plugins): add jmap.uploadBlob to plugins api
2026-08-11 09:53:29 +02: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
Paulhenry Saux 80c9d5b377 feat: add jmap.uploadBlob to plugins api 2026-08-09 16:54:26 +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 905e6ed96f fix: split "Name <addr>" recipients into the JMAP name and email fields #672 2026-08-06 19:19:18 +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 d55aba3890 fix: honor configured PWA theme color in desktop title bar #671 2026-08-06 17:40:41 +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 b3f5f51efa fix: stop minting duplicate "Trusted Senders" address books #730 2026-08-06 15:07:54 +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 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 94c7a454cc feat(plugins): move upfiles.get behind email:blob-read, off the privileged tier
upfiles.get is a read, but sat behind the email:blob-write permission and
the privileged tier, which requires crypto:full plus a signed managed
bundle. That put a simple attachment-offload plugin in the same trust
bucket as crypto plugins that read stored message bytes.

The method only returns a file the user just attached in this session,
looked up by the unguessable UUID the composer itself passes to the
onBeforeBlobUpload hook. It cannot reach server-side message blobs; those
stay behind jmap.fetchBlob on the privileged tier. So gate it behind
email:blob-read (consent copy already covers attachment bytes) and drop
the tier requirement.

Note: the privileged set also lists 'upfiles.set', but the dispatched
method is named 'upfiles.save', so that entry never matched; left as-is
here, flagged in the PR.
2026-08-06 17:25:52 +06:00
Roman Varlakov 600ab02034 feat(plugins): binary Blob/File bodies for api.http.post
api.http.post(path, body, options) now accepts a Blob/File body and sends
it as-is to the same-origin route instead of JSON.stringify-ing it into
'{}'. The plugin may only supply Content-Type and X-Plugin-* metadata
headers; anything else is refused, and the host attaches its credential
headers after the plugin's, so they can never be overridden. Non-Blob
bodies keep the stock JSON behaviour. The sandbox facade previously
dropped the third argument, so the options never reached the host.
2026-08-06 17:25:42 +06: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 5ed25dda98 Merge pull request #743 from paulhenry46/plugin-api-crypto
feat: new crypto methods for plugin API
2026-08-06 12:25:01 +02:00
Paulhenry Saux 1979c27d9e fix: use correct method 'crypto.getPublicKeys' in crypto plugin API 2026-08-06 07:36:18 +02:00
Linus Rath d68ebb55c0 Merge pull request #746 from bulwarkmail/copilot/fix-comments-in-review-thread
Restrict shared-account capability fallback to explicit non-personal accounts
2026-08-06 00:54:23 +02:00
Linus Rath 4b2970ba80 Merge pull request #706 from polastre/check-account-capabilities
fix(jmap): hide Contacts and Calendars when account lacks capability
2026-08-06 00:54:13 +02:00
copilot-swe-agent[bot]andrathlinus 6f9fc53502 fix: require explicit shared account fallback for contacts/calendars
Co-authored-by: rathlinus <139418639+rathlinus@users.noreply.github.com>
2026-08-05 22:41:26 +00:00
Paulhenry Saux d573cda920 feat: add new crypto API plugin methods 2026-08-05 21:55:50 +02:00
Linus Rath e27f17b796 Merge pull request #736 from dealerweb/fix/calendar-parse-debug-log
fix: route the calendar parse dump through the debug logger
2026-08-05 17:20:32 +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 151af5ced0 Merge branch 'main' of https://github.com/bulwarkmail/webmail 2026-08-05 17:19:40 +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
dealerweb 264a8493fb fix: route the calendar parse dump through the debug logger
Opening a message with a calendar invitation printed the whole parsed
event to the console on every render - title, description, attendees and
location included - through a leftover `[PARSE DEBUG]` console.log.

The surrounding file already logs via debug.log('calendar', ...) in 22
places, which honours the debugMode setting and its category filters, so
this one line was the outlier. It also ran JSON.stringify with
indentation unconditionally, even though nothing was listening.

Passing the object to debug.log instead leaves the formatting to the
logger, so nothing is serialised unless the calendar debug category is
actually switched on.
2026-08-05 14:40:21 +02:00
Eduard Polonskyi e036e10004 fix: sequence PWA and push onboarding 2026-08-03 15:31:17 +02:00
Linus Rath e94a1429d5 Merge pull request #724 from paulhenry46/userlogout-api-hook
feat: implement existing hooks onBeforeLogout and onAfterLogout + new plugin API method
2026-08-01 23:09:39 +02:00
Paulhenry Saux d15c58f8da feat: implement logout hook and add a new plugin api method to perform logout 2026-08-01 21:46:33 +02:00
Paulhenry Saux 6698ec8456 Merge branch 'bulwarkmail:main' into user-api-plugin 2026-08-01 20:24:08 +02:00