Skip to content

[Feature]: Session-authed JMAP for plugins (accessible-accounts discovery without credentials) #583

Description

@truesysadmin

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/jmapaccounts / 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions