SMTP becomes an internal transport plugin, locked on until there is another way back in #104

Closed
opened 2026-09-07 16:26:26 +00:00 by tiagoagueda · 1 comment
Owner

Observation

make smtp internal plugin but stays enable until this issues is resolved

The decision on #100, staged. SMTP becomes an internal plugin now; it cannot be switched off
until #103 gives an instance another way back into an account.

The ordering problem, which shapes the whole design

A plugin cannot supply MAILERS. Django reads that at settings import; entry points are not
loaded until the app registry is ready, which is later. So "SMTP is a plugin" cannot mean
"the plugin defines the mail settings".

What it can mean, and what works: core names one backend in MAILERS, and that backend
asks which transport is selected and how it is configured, at send time.

MAILERS = {"default": {"BACKEND": "postulo.notifications.transport.PluggableBackend"}}

That is the same mechanism #84 needs anyway -- configuration read from the database rather
than frozen at import -- so the two share one piece of work rather than growing two.

A new plugin kind

transport, under postulo.transports, the way #99 adds importer under
postulo.importers. A transport declares its configuration fields through the FieldSpec
machinery that already draws connection forms, tests itself through the test() method that
already exists, and delivers a message.

This is the argument for doing it at all. A self-hoster whose provider blocks outbound
25, 465 and 587 -- which is most residential connections and several VPS providers -- has no
route today except finding a relay that speaks SMTP. With a transport kind they can install
one that speaks an HTTP API instead, and Postulo ships none of them and names no vendor.
That is the plugin system doing exactly what registry.py says it is for.

The lock, and why it must not be a special case

The interlock decided on #100 is: SMTP may not be switched off while it is the last way
anybody could get back into their account.
Today, on most instances, it is -- and #103 has
not been built, so the answer is always no.

Implement that as the rule, evaluated, not as if plugin == "smtp": refuse. Two reasons.
A hardcoded exception is one nobody deletes, so the day #103 lands the lock stays on by
inertia. And the rule is the more useful thing regardless: an instance that later runs a
second transport should be able to switch this one off, and a hardcoded name would refuse
that too.

The refusal names what it is protecting, the way Server settings -> People already refuses
to remove the last administrator.

Three things this must not disturb

The environment stays a complete route. A fresh instance has an empty database and has to
send a verification email before the first account exists. So POSTULO_EMAIL_HOST and its
neighbours keep working and keep winning, exactly as #84 specifies. A transport plugin
configured only through the interface would make first boot impossible.

Per-person policy does not apply to a transport. #95 gives an administrator four states
over a plugin per person -- available, unavailable, forced on, forced off. None of them means
anything for mail delivery, which is instance infrastructure rather than a capability a
person holds. The transport kind is exempt from that policy, and forced off must be refused
by the interlock like any other attempt to switch it off.

The email notifier is a different plugin and stays one. EmailNotifier is a notifier:
it decides that something is worth telling somebody and writes the message. The transport is
what delivers it. One sits on the other, they are different kinds, and merging them would
make notification settings and delivery settings the same form -- which they are not.

Scope

  • The transport kind: entry-point group, protocol, registration, docs/PLUGINS.md.
  • PluggableBackend in core, named in MAILERS, resolving the selected transport at send
    time.
  • An internal SMTP transport carrying the identity fields from #97, with the environment
    overriding its configuration.
  • The interlock from #100, evaluated per account, refusing by name rather than by rule of
    thumb.
  • Tests: the lock refuses while email is anybody's only route; the environment beats the
    database; a fresh instance with no rows can still send; forced off from #95 is refused
    for a transport.

Classification

Enhancement, security. Depends on #97 for the identity fields. Shares its mechanism with #84
and should probably be built with it. #103 is what eventually lets the lock open; nothing
here waits on it.

## Observation > make smtp internal plugin but stays enable until this issues is resolved The decision on #100, staged. SMTP becomes an internal plugin now; it cannot be switched off until #103 gives an instance another way back into an account. ## The ordering problem, which shapes the whole design A plugin cannot supply `MAILERS`. Django reads that at settings import; entry points are not loaded until the app registry is ready, which is later. So "SMTP is a plugin" cannot mean "the plugin defines the mail settings". What it can mean, and what works: **core names one backend in `MAILERS`, and that backend asks which transport is selected and how it is configured, at send time.** ```python MAILERS = {"default": {"BACKEND": "postulo.notifications.transport.PluggableBackend"}} ``` That is the same mechanism #84 needs anyway -- configuration read from the database rather than frozen at import -- so the two share one piece of work rather than growing two. ## A new plugin kind `transport`, under `postulo.transports`, the way #99 adds `importer` under `postulo.importers`. A transport declares its configuration fields through the `FieldSpec` machinery that already draws connection forms, tests itself through the `test()` method that already exists, and delivers a message. **This is the argument for doing it at all.** A self-hoster whose provider blocks outbound 25, 465 and 587 -- which is most residential connections and several VPS providers -- has no route today except finding a relay that speaks SMTP. With a transport kind they can install one that speaks an HTTP API instead, and Postulo ships none of them and names no vendor. That is the plugin system doing exactly what `registry.py` says it is for. ## The lock, and why it must not be a special case The interlock decided on #100 is: **SMTP may not be switched off while it is the last way anybody could get back into their account.** Today, on most instances, it is -- and #103 has not been built, so the answer is always no. Implement that as the rule, evaluated, not as `if plugin == "smtp": refuse`. Two reasons. A hardcoded exception is one nobody deletes, so the day #103 lands the lock stays on by inertia. And the rule is the more useful thing regardless: an instance that later runs a second transport should be able to switch *this* one off, and a hardcoded name would refuse that too. The refusal names what it is protecting, the way *Server settings -> People* already refuses to remove the last administrator. ## Three things this must not disturb **The environment stays a complete route.** A fresh instance has an empty database and has to send a verification email before the first account exists. So `POSTULO_EMAIL_HOST` and its neighbours keep working and keep winning, exactly as #84 specifies. A transport plugin configured only through the interface would make first boot impossible. **Per-person policy does not apply to a transport.** #95 gives an administrator four states over a plugin per person -- available, unavailable, forced on, forced off. None of them means anything for mail delivery, which is instance infrastructure rather than a capability a person holds. The transport kind is exempt from that policy, and *forced off* must be refused by the interlock like any other attempt to switch it off. **The email notifier is a different plugin and stays one.** `EmailNotifier` is a *notifier*: it decides that something is worth telling somebody and writes the message. The transport is what delivers it. One sits on the other, they are different kinds, and merging them would make notification settings and delivery settings the same form -- which they are not. ## Scope - The `transport` kind: entry-point group, protocol, registration, `docs/PLUGINS.md`. - `PluggableBackend` in core, named in `MAILERS`, resolving the selected transport at send time. - An internal SMTP transport carrying the identity fields from #97, with the environment overriding its configuration. - The interlock from #100, evaluated per account, refusing by name rather than by rule of thumb. - Tests: the lock refuses while email is anybody's only route; the environment beats the database; a fresh instance with no rows can still send; `forced off` from #95 is refused for a transport. ## Classification Enhancement, security. Depends on #97 for the identity fields. Shares its mechanism with #84 and should probably be built with it. #103 is what eventually lets the lock open; nothing here waits on it.
tiagoagueda added this to the 0.3.0 milestone 2026-09-07 16:26:26 +00:00
Author
Owner

Done in 6942059. The whole Scope list is in, and the design in the issue held up: the ordering problem is exactly what shapes it, and sharing the mechanism with #84 meant this was one piece of work rather than two.

The ordering problem, confirmed rather than assumed. A useful detail of Django 6.1 that makes the design cleaner than it had to be: MailersHandler.create_connection caches nothing — it constructs a fresh backend for every send. So PluggableBackend resolves the selected transport per message, with no restart and no cache to invalidate. It also declares no options, and Django's base backend raises InvalidMailer on any it does not declare — so putting OPTIONS back into MAILERS, which is exactly the frozen-at-import mistake this exists to avoid, fails loudly at the first send instead of silently doing nothing. There is a test for that refusal.

The lock, evaluated. recovery_routes() lists what exists, and refuse_switching_off refuses while removing this transport would empty it. Nothing in the rule mentions SMTP. Today the list holds two things:

  • email, via the selected transport;
  • a passkey, which signs somebody in without the password they have forgotten.

A two-factor recovery code is deliberately not on the list: it is a second factor, so it helps somebody who still knows their password and does nothing for somebody who does not. That distinction is what makes the count meaningful rather than decorative.

So the lock is not always-no, which is better than the issue expected. It counts active accounts with no passkey; on a typical instance that is all of them and the refusal stands, and there is a test showing it opens by itself once every account holds one — with nothing deleted and no name checked. #103 adds a third entry to that list and the same thing happens.

Enforcement is at three points, not one, because the page is not the boundary: switching off the distribution that provides the transport, removing it, and the per-person policy. That last one needed a guard in policy.decide rather than only in the view — _governed_plugins() already excluded transports so an administrator could not reach one, but set_choice took a plugin name and would happily have written a transport into somebody's plugins_off from a hand-written POST. Both have tests.

One thing I built that the Scope list did not name, and I would rather say so than leave it implied: a transport selector on the Email page, plus storage and a drawn form for a non-SMTP transport's own config_fields(). Without those, "resolving the selected transport" has nothing to select and a third-party transport cannot be configured — which would make the kind unusable and the argument for it hypothetical. SMTP keeps its named columns; everything else gets the generic pair, encrypted the same way a connection's secrets are. The selector only appears when more than one transport is installed, so nothing changes on an instance today.

Two corrections found by looking at the page rather than by the tests. The card said locmem.EmailBackend and, directly beneath, "Carried by the SMTP transport" — two sentences contradicting each other, because in development nothing pluggable is carrying anything; the transport line and the lock notice now appear only when the pluggable backend is actually in force, and there is a test for each case. And the refusal read "1 of them have no passkey"; rephrased to "%(count)d of them would have no way in", which is correct English at any number and needs no plural form in 24 catalogues.

docs/PLUGINS.md has the contract, wiki/Configuration.md has the operator's version including the refusal they will meet. 21 tests in tests/test_mail_transport.py. 10 new strings, translated into all 24 catalogues and flagged draft.

Done in 6942059. The whole Scope list is in, and the design in the issue held up: the ordering problem is exactly what shapes it, and sharing the mechanism with #84 meant this was one piece of work rather than two. **The ordering problem, confirmed rather than assumed.** A useful detail of Django 6.1 that makes the design cleaner than it had to be: `MailersHandler.create_connection` **caches nothing** — it constructs a fresh backend for every send. So `PluggableBackend` resolves the selected transport per message, with no restart and no cache to invalidate. It also declares no options, and Django's base backend *raises* `InvalidMailer` on any it does not declare — so putting `OPTIONS` back into `MAILERS`, which is exactly the frozen-at-import mistake this exists to avoid, fails loudly at the first send instead of silently doing nothing. There is a test for that refusal. **The lock, evaluated.** `recovery_routes()` lists what exists, and `refuse_switching_off` refuses while removing this transport would empty it. Nothing in the rule mentions SMTP. Today the list holds two things: - **email**, via the selected transport; - **a passkey**, which signs somebody in without the password they have forgotten. A two-factor **recovery code is deliberately not on the list**: it is a *second* factor, so it helps somebody who still knows their password and does nothing for somebody who does not. That distinction is what makes the count meaningful rather than decorative. So the lock is not always-no, which is better than the issue expected. It counts active accounts with no passkey; on a typical instance that is all of them and the refusal stands, and there is a test showing it **opens by itself** once every account holds one — with nothing deleted and no name checked. #103 adds a third entry to that list and the same thing happens. **Enforcement is at three points, not one**, because the page is not the boundary: switching off the distribution that provides the transport, removing it, and the per-person policy. That last one needed a guard in `policy.decide` rather than only in the view — `_governed_plugins()` already excluded transports so an administrator could not reach one, but `set_choice` took a plugin *name* and would happily have written a transport into somebody's `plugins_off` from a hand-written POST. Both have tests. **One thing I built that the Scope list did not name**, and I would rather say so than leave it implied: a transport **selector** on the Email page, plus storage and a drawn form for a non-SMTP transport's own `config_fields()`. Without those, "resolving the *selected* transport" has nothing to select and a third-party transport cannot be configured — which would make the kind unusable and the argument for it hypothetical. SMTP keeps its named columns; everything else gets the generic pair, encrypted the same way a connection's secrets are. The selector only appears when more than one transport is installed, so nothing changes on an instance today. **Two corrections found by looking at the page** rather than by the tests. The card said `locmem.EmailBackend` and, directly beneath, "Carried by the SMTP transport" — two sentences contradicting each other, because in development nothing pluggable is carrying anything; the transport line and the lock notice now appear only when the pluggable backend is actually in force, and there is a test for each case. And the refusal read "1 of them have no passkey"; rephrased to "%(count)d of them would have no way in", which is correct English at any number and needs no plural form in 24 catalogues. `docs/PLUGINS.md` has the contract, `wiki/Configuration.md` has the operator's version including the refusal they will meet. 21 tests in `tests/test_mail_transport.py`. 10 new strings, translated into all 24 catalogues and flagged `draft`.
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.

Reference
Postulo/postulo#104
No description provided.