Commit Graph
66 Commits
Author SHA1 Message Date
Linus Rath be3debdcb4 fix: never send a message when a plugin that checks it before sending fails or does not answer 2026-10-14 02:11:59 +02:00
Linus Rath 592db209db fix: sign plugin bundles when they are installed and require the signature for privileged plugins 2026-10-14 02:05:47 +02:00
Linus Rath e720be7e17 feat: let users install their own plugins, with admin review of the actual code
The admin page offered "User Plugin Uploads" and "Require Admin
Approval", but nothing in Settings used them: there was no upload, no
uninstall and no way to ask for approval, and the admin had no list of
requests. The approval code that existed pinned a hash of the code only
and showed the admin a manifest the browser sent, never the code.

Now:
- Settings > Plugins has an install button (when uploads are allowed),
  a remove button for the user's own plugins, and lists in plain
  language what each plugin may do, high-risk items first
- an install sends the ZIP to the server, which reads the manifest and
  code itself and pins the request to a hash of both
- the admin Plugins tab lists requests with the code and approves or
  denies them; a user may have 10 open requests
- a user plugin is checked again on every start, so a revoked approval
  or turning user plugins off takes effect
- the consent dialog and the plugin settings are translated, use the
  theme colours, and show the sites a plugin may send data to
2026-10-14 02:03:26 +02:00
Linus Rath c45533646e fix: give slot plugins the message, event or contact only with the matching read permission 2026-10-14 01:31:30 +02:00
Linus Rath 9bff740a89 fix: keep plugin network access to the origins in its manifest 2026-10-14 01:27:02 +02:00
Linus Rath 4d733aa598 chore: remove unused exports and the dead plugin error boundary 2026-10-13 13:36:51 +02:00
Linus Rath 22ffc36ca9 feat: let plugins run a message-list search with a JMAP filter #1024 2026-10-11 19:39:58 +02:00
Linus Rath 2522dbd804 fix: keep the plugin denylist and unit tests from breaking Lite and Docker builds 2026-09-26 21:38:57 +02:00
Linus Rath 0040e5daca fix: keep plugin storage per account and delete it on sign-out
`api.storage` kept one `plugin:<id>:` namespace for everyone signed in
to the browser and left it behind after signing out, so a plugin read
what it had stored for the previous account, such as quick-notes'
notes on someone else's mail.

Keys now carry the account (`plugin:<id>:@<account>:<key>`). Signing an
account out or removing it deletes its keys, and a full sign-out
deletes all plugin storage. Keys written before this change are handed
to the first account that uses the plugin. Uninstalling a plugin still
removes everything under its prefix.
2026-09-26 16:47:29 +02:00
Linus Rath 6d2255ad0d fix: keep plugin http.post away from credentialed and admin routes
`http.post` attaches the user's Authorization header to any same-origin
path the plugin's own manifest lists in `apiPostPaths`. A plugin with
only `http:post` could list the JMAP passthrough and get full JMAP
access, or reach `/api/admin/*`, `/api/settings` and the other
credentialed routes. The host now refuses those paths whatever the
manifest says, checked on the decoded path the router sees. Plugins
keep reaching their own sidecar routes.
2026-09-26 15:01:42 +02:00
Linus Rath f98779b0d0 fix: require manifest permissions for plugin hooks and slots
Only the host API methods checked a plugin's permissions. Every hook
and slot the sandbox reported was wired as-is, so a plugin declaring no
permissions could rewrite recipients and body of every outgoing mail
through `onTransformOutgoingEmail`, replace displayed bodies through
`onRenderEmailBody`, or read all fetched mail through
`onEmailsFetched`. Admin-installed plugins are managed, so users never
saw a consent prompt for any of it.

Each hook and slot now maps to a permission in
`lib/plugin-sandbox/permissions.ts`, checked the same way as API calls
(declared, and managed or granted). Hooks that change what is sent need
`email:send`, display takeovers need `email:render-takeover`, and a hook
or slot missing from the table is refused. Logout and account switches
stay implicit so plugins can always drop per-account state.
2026-09-26 14:26:33 +02:00
Linus Rath d5c0996519 fix: bind the plugin sandbox to its framing window (GHSA-96cx-gx36-3g79) 2026-09-17 20:31:39 +02:00
Linus Rath b168e45e22 Merge pull request #975 from bartfaizoli76/feature/plugin-dialog-open
feat: api.ui.openDialog + plugin-dialog slot for large, clickable custom UI
2026-09-07 17:42:42 +02:00
Linus Rath 93b8d99ff9 fix(): refill missing managed bundles from the server #636 2026-09-07 16:52:20 +02:00
bartfaizoli76 2adc281a9d feat: add ui.openDialog + 'plugin-dialog' slot for large, clickable custom UI
Every existing plugin slot renders wherever it's placed in the page (a
toolbar row, an attachment's hover-action bar, etc.) and is constrained
by that spot's own CSS - fine for small controls, but there's no way
for a plugin to show a large custom UI (a graphical file browser, a
multi-step wizard) since even a position:fixed overlay rendered from
inside such a slot is clipped to the surrounding row's own layout, not
the real page (confirmed live building the webdav-storage plugin: a
folder browser rendered but was squeezed into an unusable sliver).

Adds:
- host-dialog.ts: new 'custom' DialogKind + awaitCustomDialog(), reusing
  the existing confirm/alert/prompt queue plumbing.
- host-api.ts: 'ui.openDialog' API method.
- runtime.tsx: sandbox-side api.ui.openDialog(opts) proxy.
- plugin-types.ts: new 'plugin-dialog' SlotName - a plugin implements
  this slot with its actual custom UI.
- plugin-dialog-host.tsx: renders the requested slot via the existing
  PluginIframeSlot INSIDE the dialog's real app-root fixed overlay
  (position: fixed; inset: 0, above everything) instead of the
  confirm/prompt form, for kind: 'custom'. Resolves via a generic
  onResult callback in extraProps (marshalled the same way
  composer-attachment-source's onAttach already is - no new plumbing
  needed there).

A plugin calls api.ui.openDialog({ slot: 'plugin-dialog', extraProps,
title, width }) and awaits the Promise, which resolves to whatever its
own slot component passes to props.onResult(), or null if the user
closes the dialog without doing so.
2026-09-05 18:57:49 +02:00
Michael Richter 84617d6186 feat: OAuth handler for plugins; extended contacts and addressbook API 2026-08-31 09:55:06 +02:00
Roman Varlakov 7928f72ee2 feat: real byte progress for attachment uploads, stock and plugin-offloaded
The composer chip only knew a boolean: `uploading` flipped on, an
indeterminate bar animated at 40% width, and nothing anywhere carried a
percentage, so a large attachment on a slow link looked frozen for the
whole transfer. The JMAP client has been able to report real progress
since #333 gave uploadBlob an XHR path with onProgress; the composer
just never passed it.

Stock path: pass onProgress and the existing AbortController's signal to
client.uploadBlob. The chip's bar becomes determinate as soon as bytes
are reported (falling back to the indeterminate animation otherwise),
and cancel now aborts the transfer itself instead of being checked only
once the upload has finished.

Plugin offload path (#751): a plugin that offloads an attachment moves
the bytes itself via api.http.post, which the composer cannot observe.
It also cannot be handed a callback: the sandbox bridge marshals
functions host-to-plugin only, and the File a plugin holds is a
structured clone, so object identity cannot correlate its request back
to an attachment. The staged file id can: the composer generated it and
handed it to onBeforeBlobUpload. A plugin echoes it back as the new
optional `progressFileId` on the post options; the host sends the Blob
via XMLHttpRequest (fetch exposes no upload progress events) and reports
bytes into a host-side registry keyed by that id, which the composer
subscribes to before running the hook. Progress never crosses into the
sandbox, and reports for an id the composer is not tracking are dropped,
so the plugin API grows exactly one optional field. Calls without
progressFileId keep the fetch path untouched.

Closes #754
2026-08-23 19:44:56 +06:00
Paulhenry Saux d82a8e5ca5 feat(plugins): new plugin API method: getPublicKeyFromWKD 2026-08-22 20:55:45 +02:00
Paulhenry Saux e2f24e017f fix(plugins) : divide PRF workflow (getOrCreate) into two for better compatibility 2026-08-17 21:43:58 +02:00
Paulhenry Saux a5a12e11bc feat: allow privileged plugin to fetch only a range of blob 2026-08-15 18:28:05 +02:00
Paulhenry Saux 1e62029b6c feat(plugin): add isActive property to AccountResponse in plugin API 2026-08-14 13:26:08 +02:00
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
Ctaoist 01ef9240d4 refactor: generalize JMAP keyword capability 2026-08-12 22:47:00 +08: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
Ctaoist f2c379b929 feat: expose label settings to extensions 2026-08-12 15:38:05 +08:00
Paulhenry Saux 80c9d5b377 feat: add jmap.uploadBlob to plugins api 2026-08-09 16:54:26 +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
Paulhenry Saux 1979c27d9e fix: use correct method 'crypto.getPublicKeys' in crypto plugin API 2026-08-06 07:36:18 +02:00
Paulhenry Saux d573cda920 feat: add new crypto API plugin methods 2026-08-05 21:55:50 +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
Paulhenry Saux 66ff523553 feat(plugins): add 2 new methods : user.getAccounts and user.getIdentities 2026-07-29 13:14:09 +02:00
Paulhenry Saux 7969fd09eb fix(plugins): prevent a privileged plugin to get PRF secret of another privileged plugin 2026-07-29 11:30:12 +02:00
Paulhenry Saux 2245ad2024 fix(plugins): use correct method name for message errors in host-api.ts 2026-07-22 20:28:32 +02:00
Paulhenry Saux 7bf62e4bdc feat: add contact methods in plugin API 2026-07-22 19:34:45 +02:00
Linus Rath 5105e000f5 feat: add message-list category tabs 2026-07-22 17:22:20 +02:00
Paulhenry Saux 07c473e057 feat: add new plugin ui.rerenderFetchedEmails method 2026-07-22 13:39:21 +02:00
Linus Rath a909593dda fix: detect typing inside the QuotedHtml shadow island via composedPath #654 2026-07-21 23:26:51 +02:00
Paulhenry Saux b4739c111f feat: add new plugin API to submit without moving to box mail and import to box 2026-07-21 20:55:52 +02:00
Paulhenry Saux 20d02214df fix: add new plugin api methods introduced by #586 to protocol plugin sandbox 2026-07-13 21:21:07 +02:00
Paulhenry Saux b1f6758f98 fix: add ui:download-file permission to consent screen. 2026-07-13 21:18:36 +02:00
Paulhenry Saux a679d82cc3 feat: add download file method for files generated by plugin 2026-07-13 21:18:36 +02:00
Paulhenry Saux a08a9e9ed3 feat(plugins) : add new api api method : webauthn.getOrCreate 2026-07-13 21:17:45 +02:00
Linus Rath d3f77ef9cf feat: add plugin ui.rerenderEmail API and restyle read-receipt banner 2026-07-09 16:04:33 +02:00
Linus Rath 5ccba83129 Merge branch 'main' of https://github.com/bulwarkmail/webmail
# Conflicts:
#	lib/plugin-sandbox/host-api.ts
2026-07-09 14:57:36 +02:00