The shell now takes a message's signed attributes where it took a digest,
so a page can't get a login or TLS digest signed. A quiet option lets a
plugin answer from what's allowed, for anything the user didn't ask for.
An admin's install no longer covers the certificates on someone's own
computer: a managed plugin gets `keystore` when that person says yes, also
when an update adds it, and it's forgotten once everyone has signed out.
Changed test assertions: the fixture plugin needs a grant now, as managed
alone is no longer enough, and the bridge gets an options argument.
A plugin's render can be a key operation, and mark-read replacing the
message object ran it twice: a second PIN dialog, and two cancels lock the
key. The effects now depend on what the hook sees and on the explicit
re-render nonce, not on the object. The list row and the full message still
differ, as a row has no headers for a plugin to recognise its mail by.
onRenderEmailBody can return `blockExternalContent: true` (flag
`api.host.features.blockExternalContent`) for a body it can't vouch for.
External content then stays off whatever the settings and trusted senders
allow, in the reader and in conversations, with no way to load it. The S/MIME
plugin uses it against EFAIL.
EmailReadView.attachments gain cid and alt for images the HTML shows
inline. plugin-projection.test.ts now expects the cid on its inline
logo, since the view carries it.
The assistant's new host calls come in four areas (context, actions,
Files, the panel in every view), built side by side. Each area gets its
own files under lib/plugin-sandbox/assistant for its permissions, method
table, dispatcher, host feature flags and sandbox methods; the shared
files only read the merged result. No area adds anything yet. The host
features test now compares against the core flags plus the areas' flags.
onCalendarAlert now fires for a task's display alert too, as
{ id, taskId, taskTitle, triggerTime }, and closing that reminder's toast
fires onCalendarAlertAcknowledge with { alertId, taskId }. A task's
reminder reaches only plugins with tasks:read, an event's only plugins
with calendar:read; either permission registers the hooks, and the host
hands each call only to a plugin holding the one it needs.
onBeforeCalendarAlert, a new intercept, runs before Bulwark shows a
reminder of an event or a task, with the same payload. A plugin that
shows reminders itself returns false: Bulwark then plays no sound, sets
no title alert and shows no toast for that reminder, while
onCalendarAlert still fires. Like the reminder it runs in every open
tab, and each tab follows its own answer. It fails open within five
seconds, so a hung plugin delays a reminder but never drops it. Without
a handler the reminder shows at once, as before.
- The message for send checks is built after waiting for required
checks that are still starting: a send right after sign-in gave them
no external recipients and no file types.
- A plugin's forward is held too while a required check is down, also
with no plugin listening.
- A plugin the admin forces on that hasn't reached this browser yet (a
new device, a failed plugin sync) counts as starting.
- A check that stopped working says to reload or tell the admin.
- The files that were checked are the ones sent, and recipients a
plugin adds after the check are checked again.
- A file Bulwark couldn't read is marked unreadable; a text file
starting with "MZ" is text, Java and Android apps are told from ZIPs.
Changed assertions: runPluginSendCheck takes a builder and reports
guard-loading or guard-failed instead of guard-down; the composer test
of an auto-disabled guard expects the stopped_working text; the sniff
tests give programs a zero byte, as real ones have, and expect
unreadable on a file that can't be read.
api.tasks.create and update take links by id (an http(s) URL, a mailto:
address or a path of this Bulwark, stored as its full URL; at most ten)
and relatedTo (by a mail's Message-ID or another object's UID, with its
relation types; at most ten). They merge on update as alerts do: an
entry given is set, one given null is removed, and entries the plugin did
not name, such as an attachment's link another app wrote, stay as the
server holds them.
TaskView returns both as any app wrote them. Lengths, ids and URL
schemes are checked as the rest of the calendar API checks them. Task
reads now ask the server for links too.
api.tasks.create and update take alerts by id: a trigger from the task's
due (or start) as an ISO 8601 duration, or at an instant in UTC; display
or email; at most five. An update merges by id, each alert written by its
JMAP patch path: an alert given is set, one given null is removed, and
alerts the plugin did not name, such as one another app wrote, stay as
the server holds them; null for the whole map removes every alert.
Whether the map is patched or written whole is decided on the task as the
server holds it, not an older copy. An alert kept with the same trigger
and action stays acknowledged, so a snooze is an update of its trigger,
which then fires again. While a task uses its calendar's default alerts,
a change makes them the task's own under new ids, as the task form does,
an alert under a default's id is refused rather than dropped, and
removing every alert turns the defaults off too.
TaskView returns the alerts, each with when it was acknowledged, and
useDefaultAlerts. api.tasks.acknowledgeAlert dismisses one alert (or
undoes that with null) by writing only its acknowledged. A plugin's
writes to one task go one after another, since Stalwart refuses a set
while another is under way on the same object. The task store applies
patch paths to the task the caller read, so a task the list does not
hold is shown and reported whole.
An entry from onProvideSearchResults may wrap what matched in <mark>, as
SearchSnippet/get does, and the block above the message list highlights
it with the list's own SearchSnippetText. The host keeps the marks,
escapes everything else and cuts the snippet by its text, closing a mark
it cuts, so the snippet is still never rendered as HTML.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `directory:read` for api.user.findPeople, with at least 3 characters,
at most 20 ids, 20 results and 10 lookups a minute, in the directory
of the account the call names; one load in flight per client.
- Plugins hear `group` only for accounts the server confirmed are group
principals; the policy's fallback stays for the admin's scopes.
- The email.* keyword methods refuse $draft, $junk, $notjunk and
$phishing.
- A plugin keeps at most 4 KB in a mailbox and reads it 30 times a
minute; the email:write consent text says others in a team mailbox
can read it.
- email.updateKeywords also changes the rows of expanded conversations.
What the team inbox plugin needs, kept general for other plugins:
- `api.ui.setListFilters`: keyword filters drawn as chips in the list
header of the folders they apply to (by account kind and folder
role). A chip the user turns on is ANDed into the folder's query and
comes off with one click; a keyword change a filter tests loads the
list again (ui:message-list).
- Actions get `accounts`, `hasKeyword` and `notKeyword`, so a button
shows only on messages of group accounts, or of a keyword state; the
click of message-row-actions and reader-actions carries the message's
`accountId`.
- `api.email.updateKeywords`: several keywords in one change, through
the list for a listed message (email:write).
- `api.user.getSelf`, `getMailAccounts` and `findPeople`: the user's own
account id (Stalwart's principal id), which accounts are group
accounts, and people from the directory (account:read).
- `api.accountData`: a plugin's own entry in a mail account's shared
Inbox metadata on Stalwart 1.0 (email:read / email:write).
- Row badges take an `icon`.
An entry of onProvideSearchResults may carry email: { id, accountId? }.
A click on it opens the message in the reader through the login that
reaches the account, as a list row does, and marks the entry while it
is open; any other click stays onAction.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The hook was called on every search, but nothing showed its entries,
the list waited for it, and an entry's onClick could not cross from the
plugin's sandbox. Its entries are now drawn as a block above the
search's message list, a group per plugin and source.
Each plugin is asked on its own (HookBus.collect) beside the search, so
the list no longer waits, and the answer to a search that was replaced
is dropped. The answer is checked: at most 20 entries, a key and a
title, plain text cut to its limits. A click goes to the plugin that
gave the entry as onAction with surface "search-results", the entry's
key and the query; ExternalSearchResult loses onClick.
Closes#1336
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Plugins that talk to Nextcloud, Paperless or an AI provider used to need
the password or key in their own settings, readable by the plugin and
stored in the browser. A plugin can now declare credentials in its
manifest and send requests "with credential X" through the server:
- `credentials` in the manifest: admin scope (secret and server from the
admin config) or user scope (the user connects an account; the server
address is fixed or chosen by the user)
- `/api/plugin-secrets`: the user's connected accounts, encrypted per
mail account under `PLUGIN_SECRETS_DIR`; listing never returns secrets
- `/api/plugin-proxy`: adds the secret and forwards only to the
credential's own origin, through the SSRF guard unless the admin set
the server up or listed it in `PLUGIN_PROXY_PRIVATE_ORIGINS`; no
redirects or WebDAV moves to other servers, streamed bodies capped at
512 MB up and 2 GB down, 300 requests a minute per plugin, 8 at once
per user, and one audit line per request without paths or bodies
- a capture mode that stores a secret from a sign-in response (Nextcloud
Login Flow v2) and removes it from what the plugin gets back