Summary
Untrusted-tier plugins have no way to make JMAP calls as the logged-in user's session, nor to obtain the list of accounts that session can access (own + shared/delegated). This makes it impossible to build read-only plugins that operate across a user's accessible mailboxes without asking the user to type separate credentials into the plugin's settings.
Motivation
I built a sidebar-app plugin ("Global Search") that searches across every account the user can access (their own + all shared/delegated accounts) and merges the results — the fan-out the built-in search doesn't do. It works, but the plugin has to authenticate its own JMAP calls with a login + password stored in its per-user settings, because there is no session-backed path available to an untrusted plugin.
Storing a mailbox password in plugin settings is exactly the kind of credential handling the sandbox model otherwise avoids, so this is an awkward requirement for an otherwise read-only convenience plugin.
What I found in the code (v1.7.x)
The building blocks are almost all there — only the session/accounts discovery is missing:
api.http.post(path, body) already injects the logged-in user's JMAP auth server-side (doHttpPost in lib/plugin-sandbox/host-api.ts sets Authorization: client.getAuthHeader() + X-JMAP-Username), and there is a same-origin passthrough at POST /api/account/stalwart/jmap (getStalwartCredentials from the session cookie). So JMAP method calls as the session already work for a plugin.
- But a plugin needs
accountIds to make any method call, and those only come from the JMAP session resource (GET /.well-known/jmap → accounts / primaryAccounts). That resource is fetched client-side by the SPA with credentials the sandbox can't read, and the same-origin passthrough only proxies POST /jmap/ (method calls), not the session resource.
- Enumerating accounts via a JMAP method isn't an option for a normal user — on Stalwart
x:Account/query returns forbidden for non-admins.
- The
sidebar-widget slot is rendered with no extraProps (components/layout/sidebar.tsx), so the account context isn't handed to the plugin that way either.
Net: an untrusted plugin can call JMAP as the session, but can't discover which accounts to call it on without its own credentials.
Proposed solution
Expose the session's accessible accounts to plugins, session-authed, without credentials. A minimal, low-surface option that mirrors the existing JMAP passthrough:
- Add a same-origin route (e.g.
POST /api/account/stalwart/session) that resolves getStalwartCredentials(request) from the session cookie and returns the JMAP session resource (accounts, primaryAccounts, apiUrl) — the GET-.well-known/jmap analogue of the existing POST /api/account/stalwart/jmap passthrough.
- Plugins declare it under
apiPostPaths and reach it with the existing api.http.post. Combined with the JMAP passthrough, a plugin can then do fully session-authed, credential-free reads across the user's accessible accounts, gated by the existing email:read permission.
Alternatively (or additionally): pass the active/accessible account context to relevant slots (e.g. sidebar-widget) via extraProps, and/or add a first-class api.accounts.list() to the plugin API.
Scope / safety
Read-only account discovery, session-authed, still gated by the plugin's declared permissions and the user's consent — no new credential exposure (the credentials never reach plugin code, same as the existing passthrough). Happy to send a PR for the /api/account/stalwart/session route if the approach sounds right.
Summary
Untrusted-tier plugins have no way to make JMAP calls as the logged-in user's session, nor to obtain the list of accounts that session can access (own + shared/delegated). This makes it impossible to build read-only plugins that operate across a user's accessible mailboxes without asking the user to type separate credentials into the plugin's settings.
Motivation
I built a
sidebar-appplugin ("Global Search") that searches across every account the user can access (their own + all shared/delegated accounts) and merges the results — the fan-out the built-in search doesn't do. It works, but the plugin has to authenticate its own JMAP calls with a login + password stored in its per-user settings, because there is no session-backed path available to an untrusted plugin.Storing a mailbox password in plugin settings is exactly the kind of credential handling the sandbox model otherwise avoids, so this is an awkward requirement for an otherwise read-only convenience plugin.
What I found in the code (v1.7.x)
The building blocks are almost all there — only the session/accounts discovery is missing:
api.http.post(path, body)already injects the logged-in user's JMAP auth server-side (doHttpPostinlib/plugin-sandbox/host-api.tssetsAuthorization: client.getAuthHeader()+X-JMAP-Username), and there is a same-origin passthrough atPOST /api/account/stalwart/jmap(getStalwartCredentialsfrom the session cookie). So JMAP method calls as the session already work for a plugin.accountIds to make any method call, and those only come from the JMAP session resource (GET /.well-known/jmap→accounts/primaryAccounts). That resource is fetched client-side by the SPA with credentials the sandbox can't read, and the same-origin passthrough only proxiesPOST /jmap/(method calls), not the session resource.x:Account/queryreturnsforbiddenfor non-admins.sidebar-widgetslot is rendered with noextraProps(components/layout/sidebar.tsx), so the account context isn't handed to the plugin that way either.Net: an untrusted plugin can call JMAP as the session, but can't discover which accounts to call it on without its own credentials.
Proposed solution
Expose the session's accessible accounts to plugins, session-authed, without credentials. A minimal, low-surface option that mirrors the existing JMAP passthrough:
POST /api/account/stalwart/session) that resolvesgetStalwartCredentials(request)from the session cookie and returns the JMAP session resource (accounts,primaryAccounts,apiUrl) — the GET-.well-known/jmapanalogue of the existingPOST /api/account/stalwart/jmappassthrough.apiPostPathsand reach it with the existingapi.http.post. Combined with the JMAP passthrough, a plugin can then do fully session-authed, credential-free reads across the user's accessible accounts, gated by the existingemail:readpermission.Alternatively (or additionally): pass the active/accessible account context to relevant slots (e.g.
sidebar-widget) viaextraProps, and/or add a first-classapi.accounts.list()to the plugin API.Scope / safety
Read-only account discovery, session-authed, still gated by the plugin's declared permissions and the user's consent — no new credential exposure (the credentials never reach plugin code, same as the existing passthrough). Happy to send a PR for the
/api/account/stalwart/sessionroute if the approach sounds right.