Lists installed plugins, shows local vs latest versions, and highlights Update available. Works with Larry, Elastic, and custom skins.
plugin_managernow keeps its own canonical version inplugin_manager::PLUGIN_VERSIONinsideplugin_manager.php.plugin_manager::info()exposes the plugin metadata array used for self-identification.- Development builds should use a
+devsuffix such as1.0.0+dev. - Release builds should use a clean tagged version such as
1.0.0.
For a release bump:
- Update
plugin_manager::PLUGIN_VERSIONinplugin_manager.phpor runsh scripts/bump-version.sh 1.0.0. - Update
CHANGELOG.md. - Create the matching release tag after verification.
- Discovers installed plugins
- Shows local version (from composer.json, explicit version constants/tags, or best-effort)
- Checks online (Packagist / GitHub releases, falls back to tags)
- Bold “Update available”
- Post-update advisory scan for upgrade/migration signals such as
migrations/,sql/,UPGRADE.md, and composer metadata hints - Optional allowlisted Plugin Manager-owned post-update hook files, disabled by default and scoped per plugin
- Manual admin-only
Run maintenanceaction for plugins with an eligible allowlisted hook - Per-plugin maintenance status badges and a recent maintenance activity panel
- One-click (check now) per row to bypass cache
- Diagnostics panel for connectivity
sources.map.phpto resolve outliers or mark plugins as bundled- Scroll-friendly UI for large lists
-
In your Roundcube root (the folder with
composer.json), run:composer require texxasrulez/plugin_manager
This uses
roundcube/plugin-installerto place the plugin inplugins/plugin_manager. -
Enable the plugin in Roundcube config (e.g.
config/config.inc.php):$config['plugins'][] = 'plugin_manager';
-
Clear caches.
- Copy the
plugin_manager/folder intoroundcube/plugins/. - Enable in config:
$config['plugins'][] = 'plugin_manager';
- Clear caches.
-
To raise GitHub API limits, set in
plugins/plugin_manager/config.inc.php(or main config):$config['pm_github_token'] = 'ghp_xxxxx';
-
To default remote checks on/off:
$config['pm_remote_checks'] = true; // or false
On startup, Plugin Manager bootstraps data/installed_versions.json and data/version.json if they are missing.
For most modern plugins, the local installed version can be detected immediately from local plugin files such as composer.json or explicit PHP version constants.
Some older or legacy plugins do not publish a reliable local version inside the installed files. For those plugins, Plugin Manager may show a best-effort local value until the plugin is updated through the Plugin Manager UI or the upstream plugin adopts explicit local version metadata.
After a successful plugin update, Plugin Manager now performs a read-only inspection of the updated plugin directory. It looks for migration or upgrade signals such as:
sql/,db/,database/,migrations/,updates/,upgrade/UPGRADE,UPGRADING,INSTALL,CHANGELOG,README,UPGRADE.md,INSTALL.mdcomposer.jsonsignals such as install/update-relatedscripts,extra, orsuggestmetadata
This inspection is advisory only. It does not run SQL, shell commands, composer scripts, or arbitrary plugin code.
If signals are found, Plugin Manager shows an advisory flash message after the normal update success message.
Plugin Manager can also run a strictly allowlisted internal PHP hook file after a successful install or update.
This is deny-by-default: nothing runs unless you explicitly configure it in pm_post_update_hooks.
The executable maintenance code now lives inside plugin_manager/webhooks/, not inside the target plugin directory.
That means:
- third-party plugins do not need to ship their own maintenance hook files
- administrators do not need to patch vendor plugins just to add maintenance support
- Plugin Manager remains the only codebase that owns runnable maintenance hook files
- target plugin directories are still inspected for advisory signals and used as data inputs, but not as hook sources
Important safety rules:
- only hooks listed by the administrator in config are considered
- only
type = 'internal'is supported - the configured hook path must resolve under
plugin_manager/webhooks/<plugin>/ - plugin metadata does not authorize hook execution
- manual maintenance appears only for plugins with a valid allowlisted hook that explicitly supports manual execution
- hook preflight validation is reused for status badges, automatic runs, and manual runs
- no shell commands, SQL execution, or composer script execution are performed by Plugin Manager
The hook file lives under a dedicated directory inside Plugin Manager:
plugin_manager/
└── webhooks/
└── archive/
└── post-update.php
The config path is relative to the per-plugin webhook folder, not to the Roundcube root and not to the target plugin directory. For the example above:
'path' => 'post-update.php',Conceptually:
- create
plugin_manager/webhooks/<plugin>/ - put a hook file in that folder
- allowlist that file for the matching plugin in
pm_post_update_hooks
Example config shape:
$config['pm_post_update_hooks'] = [
'archive' => [
'enabled' => true,
'type' => 'internal',
'path' => 'post-update.php',
'label' => 'Run archive maintenance',
'timeout' => 10,
'args' => [],
'run_on' => ['update', 'manual'],
'allow_manual_run' => true,
'require_confirmation' => true,
],
];In that example:
- the plugin key is
archive - the executable code is
plugin_manager/webhooks/archive/post-update.php - Plugin Manager will only consider that hook file for the
archiveplugin - no file inside
plugins/archive/is used as executable hook code - the plugin directory is still passed in the context array so the hook can inspect or update plugin-owned files when appropriate
- Decide which plugin needs maintenance support.
- Create a directory under
plugin_manager/webhooks/that matches the plugin directory name. - Put a dedicated hook file in that folder, such as
post-update.php. - Keep that hook file narrow and plugin-specific.
- Add an allowlist entry under the plugin’s exact directory name in
pm_post_update_hooks. - Set
pathto the file path relative toplugin_manager/webhooks/<plugin>/. - Decide which events may run it with
run_on. - Set
require_confirmationif you want automatic update-time execution suppressed until an admin explicitly runs it. - If you want manual execution available at all, include
manualinrun_onand leaveallow_manual_runenabled. - Set
show_manual_linktotrueonly if you want the row-level UI action visible even when the hook can already auto-run.
Plugin Manager now ships a growing set of example webhook files under webhooks/ for popular plugins.
These examples are meant to save admins time and provide a safe starting point for common plugin maintenance reviews.
Example:
$config['pm_post_update_hooks'] = [
'archive' => [
'enabled' => true,
'type' => 'internal',
'path' => 'post-update.php',
'label' => 'Run archive maintenance',
'run_on' => ['update', 'manual'],
'allow_manual_run' => true,
'show_manual_link' => false,
'require_confirmation' => true,
],
];In that example:
- the plugin directory name is
archive - Plugin Manager will only consider hooks for
archiveif that exact key exists - the executable maintenance code is
plugin_manager/webhooks/archive/post-update.php - the hook will not auto-run on install because
installis not listed inrun_on - the hook will not auto-run on update because
require_confirmationistrue - the admin can still use the row-level
Run maintenanceaction because confirmation is required
Field notes:
enabled: master switch for this plugin hooktype: currently onlyinternalpath: relative path underplugin_manager/webhooks/<plugin>/timeout: parsed and logged, but not enforced in the current phaserun_on: supported values areinstall,update, andmanualallow_manual_run: if omitted it is treated as enabled, but the hook must still allow themanualeventshow_manual_link: iftrue, show the row-level manual action even when the hook could auto-run; default behavior keeps it hidden unless confirmation is requiredrequire_confirmation: iftrue, the hook is not auto-run after update; the manual action satisfies that confirmation requirement
For every automatic or manual maintenance attempt, Plugin Manager performs a preflight check. The hook is refused unless all of the following are true:
- the current user is allowed to perform update/maintenance actions
- the request token is valid
- the plugin name is syntactically valid
- the plugin has an exact allowlist entry in
pm_post_update_hooks - the hook config is enabled
typeis exactlyinternal- the current event is allowed by
run_on - manual runs are allowed when the event is
manual - the configured
pathis syntactically safe - the resolved hook file exists under
plugin_manager/webhooks/<plugin>/ - the hook file is readable, is a regular file, is not a symlink, and remains inside that webhook directory
If any of those checks fail:
- the hook is not run
- the plugin may show a
Blockedmaintenance badge - the admin gets a warning when a run was attempted
- the refusal is recorded in the maintenance audit trail
- update ZIP archives are also sanity-checked before extraction so obviously unsafe archive paths are rejected early
An internal hook file should be a narrow PHP file inside plugin_manager/webhooks/<plugin>/.
It should return a callable that accepts a plain context array and returns a normalized result array.
Keep the file explicit and easy to review.
Recommended practices for hook files:
- keep each hook file dedicated to one plugin or one narrow maintenance task
- use the provided
$contextarray rather than inferring event state indirectly - return concise admin-facing messages
- treat missing prerequisites as a clean failure and report them in
messageordetails - keep plugin-specific filesystem writes scoped to the selected plugin directory unless there is a strong reason not to
Avoid in hook files:
- printing output directly
- calling
exitordie - returning scalars or non-array data
- assuming Plugin Manager will execute shell commands for you
- assuming Plugin Manager will run Composer for you
- assuming Plugin Manager will execute SQL files for you automatically
Each plugin row can show a compact maintenance badge:
Available: a valid allowlisted manual maintenance hook is ready to runBlocked: a hook is configured, but preflight validation found a problemRan recently: a maintenance run for that plugin was recently recorded in the audit log
Preflight validation checks:
- exact plugin allowlist match
- hook config exists and is enabled
- supported hook type
run_onallows the current eventallow_manual_runallows manual execution- hook path is syntactically valid
- hook file is present under
plugin_manager/webhooks/<plugin>/ - hook file remains inside that directory after resolution
- hook file is readable, is a regular file, and is not a symlink
Blocked hooks stay blocked until the underlying configuration or file problem is fixed.
If require_confirmation is true, Plugin Manager will show a message that the hook is available but was not run automatically.
If a plugin has an eligible allowlisted hook with run_on containing manual, Plugin Manager can show a row-level Run maintenance action for administrators.
By default, the row link is shown only when manual confirmation is required.
If you want the manual link visible for an otherwise auto-running hook, set show_manual_link = true in that hook config.
This action:
- is admin-only
- requires the normal request token
- asks for confirmation before running
- uses the same allowlisted internal hook-file validation and execution path as automatic post-update hooks
- returns to the main Plugin Manager page with normal flash messages
It is not a generic script runner. Plugin Manager still does not execute shell commands, composer scripts, or arbitrary plugin-discovered code.
Plugin Manager keeps a lightweight bounded audit trail of recent maintenance activity in its local data directory. Entries include:
- timestamp
- plugin
- event (
install,update, ormanual) - result (
success,warning,failure,skipped, orrefused) - short message
The Plugin Manager page shows a small read-only Recent maintenance activity panel for administrators.
It appears below the plugin list and can be hidden entirely with the Hide recent maintenance activity checkbox.
That visibility preference is stored in the browser so admins can keep it collapsed if they prefer.
By default, the audit trail is enabled and retains up to 100 recent entries.
An internal maintenance hook file is a PHP file owned by plugin_manager.
Plugin Manager loads it from plugin_manager/webhooks/<plugin>/..., expects the file to return a callable, and then calls that callable with the maintenance context.
Example file:
<?php
return function (array $context): array {
return [
'ok' => true,
'message' => 'Archive maintenance completed.',
'details' => [
'Plugin: ' . ($context['plugin'] ?? 'unknown'),
'Event: ' . ($context['event'] ?? 'unknown'),
],
];
};Example location:
plugin_manager/webhooks/archive/post-update.php
Plugin Manager resolves the configured path under that plugin-specific webhook directory and executes the returned callable directly. It does not load executable hook files from the target plugin directory. Plugin Manager also refuses webhook paths that escape the plugin-specific webhook directory, point at symlinks, or do not resolve to a readable regular file.
Example context passed to the hook:
[
'plugin' => 'example_plugin',
'plugin_path' => '/full/path/to/roundcube/plugins/example_plugin',
'event' => 'manual',
'previous_version' => null,
'current_version' => '1.2.4',
'request_time' => 1710000000,
]Expected return shape:
[
'ok' => true,
'message' => 'Did maintenance work.',
'details' => ['Additional note'],
]Result field meaning:
ok: required boolean success flagmessage: short summary shown to the administratordetails: optional list of additional short detail strings
If ok is false, Plugin Manager treats the run as a failure and shows a warning.
If the hook throws an exception or returns malformed data, Plugin Manager catches that and reports a clean failure instead of breaking the page.
<?php
return function (array $context): array {
$plugin = $context['plugin'] ?? 'unknown';
$event = $context['event'] ?? 'unknown';
$pluginPath = $context['plugin_path'] ?? '';
$currentVersion = $context['current_version'] ?? null;
$previousVersion = $context['previous_version'] ?? null;
if ($pluginPath === '' || !is_dir($pluginPath)) {
return [
'ok' => false,
'message' => 'Plugin path is not available for maintenance.',
'details' => [],
];
}
$details = [
'Plugin: ' . $plugin,
'Event: ' . $event,
];
if ($previousVersion !== null && $currentVersion !== null) {
$details[] = 'Version change: ' . $previousVersion . ' -> ' . $currentVersion;
}
$markerFile = $pluginPath . '/data/maintenance.last-run';
$markerDir = dirname($markerFile);
if (!is_dir($markerDir) && !@mkdir($markerDir, 0775, true) && !is_dir($markerDir)) {
return [
'ok' => false,
'message' => 'Could not create plugin maintenance directory.',
'details' => $details,
];
}
$written = @file_put_contents($markerFile, date('c') . "\n");
if ($written === false) {
return [
'ok' => false,
'message' => 'Could not write plugin maintenance marker.',
'details' => $details,
];
}
$details[] = 'Wrote maintenance marker: data/maintenance.last-run';
return [
'ok' => true,
'message' => 'Example plugin maintenance completed.',
'details' => $details,
];
};That example still operates on the target plugin directory through $context['plugin_path'], but the executable code itself lives in plugin_manager.
That is the key safety and maintenance difference in this model.
Use run_on to limit when Plugin Manager may invoke the hook:
['update']: automatic after updates only['update', 'manual']: automatic after updates and also available from the row action['manual']: never auto-run, only available when the admin clicksRun maintenance['install', 'update', 'manual']: eligible for all supported events
If you are unsure, start with:
'run_on' => ['manual'],
'require_confirmation' => true,That gives you the safest rollout: the hook is configured and visible, but it only runs when an administrator explicitly chooses to run it.
Allowlisting a hook does not change these limits:
- Plugin Manager does not execute shell commands for hooks
- Plugin Manager does not run Composer scripts
- Plugin Manager does not auto-run SQL files
- Plugin Manager does not trust plugin metadata as permission to execute code
- Plugin Manager does not auto-discover executable hooks from plugin files
- Plugin Manager does not execute arbitrary plugin-owned PHP files as maintenance hooks
Administrators should review any allowlisted internal hook file carefully before enabling it. The allowlist is an explicit trust decision by the administrator.
If the hook throws, returns malformed data, or otherwise fails validation, Plugin Manager refuses it or reports the failure cleanly and continues the normal page flow.
Administrators should only allowlist hook files they trust and have reviewed. Allowlisting a hook file is an explicit decision to permit that plugin_manager maintenance routine to run for that specific plugin.
Plugin config editing in the manager is also limited to resolved live plugin directories. Backup directories and other non-live plugin paths are not valid config-edit targets.
If you want help creating a webhook for another plugin, contact me and I can help put one together.
See INSTALL.md and USER_GUIDE.md for details.
You can suppress specific plugins from appearing in the Plugin Manager by adding their directory names to
pm_hidden_plugins in config.inc.php.
// Hide the built-in example plugins
$config['pm_hidden_plugins'] = array('zipdownload', 'managesieve');This only affects display in the manager; it does not enable/disable the plugin itself.
