SMTP becomes an internal transport plugin, locked on until there is another way back in #104
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Depends on
Reference
Postulo/postulo#104
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Observation
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 notloaded 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 backendasks which transport is selected and how it is configured, at send time.
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, underpostulo.transports, the way #99 addsimporterunderpostulo.importers. A transport declares its configuration fields through theFieldSpecmachinery that already draws connection forms, tests itself through the
test()method thatalready 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.pysays 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_HOSTand itsneighbours 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.
EmailNotifieris 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
transportkind: entry-point group, protocol, registration,docs/PLUGINS.md.PluggableBackendin core, named inMAILERS, resolving the selected transport at sendtime.
overriding its configuration.
thumb.
database; a fresh instance with no rows can still send;
forced offfrom #95 is refusedfor 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.
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_connectioncaches nothing — it constructs a fresh backend for every send. SoPluggableBackendresolves the selected transport per message, with no restart and no cache to invalidate. It also declares no options, and Django's base backend raisesInvalidMaileron any it does not declare — so puttingOPTIONSback intoMAILERS, 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, andrefuse_switching_offrefuses while removing this transport would empty it. Nothing in the rule mentions SMTP. Today the list holds two things: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.deciderather than only in the view —_governed_plugins()already excluded transports so an administrator could not reach one, butset_choicetook a plugin name and would happily have written a transport into somebody'splugins_offfrom 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.EmailBackendand, 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.mdhas the contract,wiki/Configuration.mdhas the operator's version including the refusal they will meet. 21 tests intests/test_mail_transport.py. 10 new strings, translated into all 24 catalogues and flaggeddraft.