Plugins from the command line: disable and remove skip the lock that keeps the mail transport on #595

Closed
opened 2026-10-01 10:48:09 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading the plugin install, disable and remove paths against the page that drives them.

Checked again on 2026-10-01 against main at 0a5cad587: still present. The code is now at core/server_views.py:1469 (PluginActionView._switch) and core/server_views.py:1495-1497 (PluginActionView._remove), plugins/installing.py:834 (remove, which asks data.refuse_removing(name) at :850) and plugins/installing.py:932 (set_disabled); plugins/management/commands/plugins.py:99-119 is where it was.

The rule from #104 is that the package carrying the selected mail transport cannot be switched off or removed while mail is the last way back into accounts. That rule is checked only in the view behind Server settings → Plugins. installing.set_disabled() and installing.remove() do not ask it, and manage.py plugins disable and manage.py plugins remove call those two directly. Writing a plugin says the command line "is the same code" as the page; for this lock it is not.

  • Where: core/server_views.py:1468 (PluginActionView._switch) and core/server_views.py:1494-1496 (PluginActionView._remove) call transport.refuse_removing_distribution(name); plugins/installing.py:1008 (remove) asks only data.refuse_removing(name), and plugins/installing.py:1106 (set_disabled) asks nothing; plugins/management/commands/plugins.py:99-119 (_remove, _switch) call installing directly.
  • What happens: an instance whose provider blocks outbound SMTP installs a third-party HTTP mail transport and selects it. Later an operator runs manage.py plugins disable <that package> (or remove). The command succeeds. In every process the transport stops loading, transport.selected() falls back to the built-in SMTP transport, which cannot deliver on that host, and verification mail, password resets and sign-in links stop going out. The same request on the page is refused with "mail is the only way back into an account here".
  • Who can trigger it: an operator with a shell on the instance; nobody else. This is a safety interlock against a mistake, not a privilege boundary.
  • Why it matters: the lock exists so that nobody strands the accounts by accident, and the route that bypasses it is the one used in scripts and by people following the wiki. The lock already opens by itself when mail is not delivering (refuse_switching_off returns nothing when site.mail_delivers() is false), so the command line gains nothing as an escape hatch by skipping it.
  • Fix: move the transport refusal into installing.remove() and into installing.set_disabled() when disabled is true, next to the data.refuse_removing check that remove() already calls "the last door", raising InstallError with the refusal. PluginActionView._switch and _remove can then drop their own copies (the data refusal in _remove is already a duplicate of the one in remove()).

Done when

  • A test in tests/test_mail_transport.py, with transport._distribution_of patched as the existing page tests do, runs call_command("plugins", "disable", "postulo-smtp") and call_command("plugins", "remove", "postulo-smtp") and expects CommandError carrying "only way back into an account", with the record unchanged.
  • The two existing page tests (test_switching_off_the_package_that_carries_the_mail_is_refused, test_removing_the_package_that_carries_the_mail_is_refused) still pass once the view no longer checks for itself.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading the plugin install, disable and remove paths against the page that drives them. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The code is now at `core/server_views.py:1469` (`PluginActionView._switch`) and `core/server_views.py:1495-1497` (`PluginActionView._remove`), `plugins/installing.py:834` (`remove`, which asks `data.refuse_removing(name)` at :850) and `plugins/installing.py:932` (`set_disabled`); `plugins/management/commands/plugins.py:99-119` is where it was. The rule from #104 is that the package carrying the selected mail transport cannot be switched off or removed while mail is the last way back into accounts. That rule is checked only in the view behind *Server settings → Plugins*. `installing.set_disabled()` and `installing.remove()` do not ask it, and `manage.py plugins disable` and `manage.py plugins remove` call those two directly. *Writing a plugin* says the command line "is the same code" as the page; for this lock it is not. - **Where:** `core/server_views.py:1468` (`PluginActionView._switch`) and `core/server_views.py:1494-1496` (`PluginActionView._remove`) call `transport.refuse_removing_distribution(name)`; `plugins/installing.py:1008` (`remove`) asks only `data.refuse_removing(name)`, and `plugins/installing.py:1106` (`set_disabled`) asks nothing; `plugins/management/commands/plugins.py:99-119` (`_remove`, `_switch`) call `installing` directly. - **What happens:** an instance whose provider blocks outbound SMTP installs a third-party HTTP mail transport and selects it. Later an operator runs `manage.py plugins disable <that package>` (or `remove`). The command succeeds. In every process the transport stops loading, `transport.selected()` falls back to the built-in SMTP transport, which cannot deliver on that host, and verification mail, password resets and sign-in links stop going out. The same request on the page is refused with "mail is the only way back into an account here". - **Who can trigger it:** an operator with a shell on the instance; nobody else. This is a safety interlock against a mistake, not a privilege boundary. - **Why it matters:** the lock exists so that nobody strands the accounts by accident, and the route that bypasses it is the one used in scripts and by people following the wiki. The lock already opens by itself when mail is not delivering (`refuse_switching_off` returns nothing when `site.mail_delivers()` is false), so the command line gains nothing as an escape hatch by skipping it. - **Fix:** move the transport refusal into `installing.remove()` and into `installing.set_disabled()` when `disabled` is true, next to the `data.refuse_removing` check that `remove()` already calls "the last door", raising `InstallError` with the refusal. `PluginActionView._switch` and `_remove` can then drop their own copies (the data refusal in `_remove` is already a duplicate of the one in `remove()`). ## Done when - A test in `tests/test_mail_transport.py`, with `transport._distribution_of` patched as the existing page tests do, runs `call_command("plugins", "disable", "postulo-smtp")` and `call_command("plugins", "remove", "postulo-smtp")` and expects `CommandError` carrying "only way back into an account", with the record unchanged. - The two existing page tests (`test_switching_off_the_package_that_carries_the_mail_is_refused`, `test_removing_the_package_that_carries_the_mail_is_refused`) still pass once the view no longer checks for itself.
tiagoagueda added this to the 0.6.0 milestone 2026-10-01 10:48:09 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#595
No description provided.