Decide where pluggability stops: SMTP and contact details are not shaped like plugins #100

Closed
opened 2026-09-07 15:26:21 +00:00 by tiagoagueda · 3 comments
Owner

Decided — see the comment below. SMTP may be disabled, but never while it is the last way anybody could get back into their account. Contact details stay in core behind a feature switch is still open.

Observation

in this context the already existing one internal plugins must comply with this, and the
implementations of Europass import XML and JSON, SMTP and Multiple contacts per user, will
be upgraded to internal shipped plugins

Two of those four fit and two do not, and the two that do not are worth stopping over. This
issue is a decision, not an implementation.

SMTP: the notifier is already a plugin; the transport is not, and should probably stay

that way

Email already ships as an internal plugin. notifications/apps.py calls
register_builtin("notifier", EmailNotifier), and its docstring says why:

it ships in the box so that an instance with working mail can notify without installing
anything, and it is a plugin like any other so that nothing about it is special

So if "SMTP becomes an internal plugin" means the notifier, it is already done.

If it means the transport -- host, port, username, password, TLS -- that is Django's
MAILERS, and it is not only the notifier's. It is what allauth sends through, and:

ACCOUNT_EMAIL_VERIFICATION = "mandatory"

Nobody can create an account, verify an address, or reset a password without it. Make
that a plugin and account verification depends on a plugin being installed, loaded and
enabled. Then #95 lands, and an administrator can switch a plugin off for the instance -- at
which point sign-up and password reset stop, and the only person who could turn it back on
is one who is already signed in. An instance can be locked out of its own recovery path by a
switch that reads like a preference.

The three ways forward:

  1. Leave the transport in core, put its settings in the interface. That is #84, already
    filed, already specified, and it gives the actual benefit -- configuring mail without
    editing a file -- with none of this risk. Recommended.
  2. Make the transport a plugin and forbid disabling it, which is a plugin that cannot be
    switched off, configured through a plugin form -- a lot of machinery to arrive back where
    it started.
  3. Make it a plugin that can be disabled, and accept that an instance can be bricked from a
    settings page.

Contact details: a plugin cannot own a table today

Reading "multiple contacts per user" as #90 (several telephone numbers) and #92 (several
postal addresses) -- say if that is wrong, because the rest of this follows from it.

Those are models. And:

INSTALLED_APPS = [ ... ]   # a static list in config/settings/base.py

Nothing in the plugin machinery touches it. A plugin is an entry point that Postulo
instantiates; it cannot be a Django app, cannot ship migrations, and cannot own a table.
Making contact details a plugin therefore means building plugin-owned migrations first, and
that brings questions with sharp edges:

  • What happens to the rows when the plugin is disabled? The data is still in the database and
    nothing can read it.
  • What happens when it is removed? Dropping a table takes people's phone numbers and
    addresses with it. A "Remove" button that deletes personal data is a different button from
    the one that exists.
  • What does core/export.py do? Taking your data out has to include it, which means the
    exporter depends on a plugin being loaded to produce a complete archive.
  • What runs a plugin's migration on upgrade, and what happens when it fails halfway?

There is a lighter design that gets the actual benefit. If the wish is that an operator
can decide their instance does not collect postal addresses -- which is a good wish, and
plain data minimisation -- then the tables stay in core and a feature switch governs
whether the interface offers them. Nobody's data is at the mercy of an entry point, the
export stays complete, and the switch is one row rather than a migration system.

That is a genuinely different design from "make it a plugin", and it is worth choosing on
purpose. Plugin-owned data may still be right one day -- but it should be decided for its own
reasons, not arrived at because contact details were the first thing to ask for it.

What this issue wants

An answer to two questions, so #90, #92 and #84 can proceed:

  1. SMTP: option 1, 2 or 3 above?
  2. Contact details: plugin-owned tables, or core tables behind a feature switch?

Classification

Security, tier 1 -- not because anything is broken now, but because the lockout in the first
half and the data-loss button in the second are both the kind of thing that is obvious in an
issue and invisible in a diff.

> **Decided** — see the comment below. SMTP may be disabled, but never while it is the last way anybody could get back into their account. Contact details stay in core behind a feature switch is still open. ## Observation > in this context the already existing one internal plugins must comply with this, and the > implementations of Europass import XML and JSON, SMTP and Multiple contacts per user, will > be upgraded to internal shipped plugins Two of those four fit and two do not, and the two that do not are worth stopping over. This issue is a decision, not an implementation. ## SMTP: the notifier is already a plugin; the transport is not, and should probably stay that way **Email already ships as an internal plugin.** `notifications/apps.py` calls `register_builtin("notifier", EmailNotifier)`, and its docstring says why: > it ships in the box so that an instance with working mail can notify without installing > anything, and it is a plugin like any other so that nothing about it is special So if "SMTP becomes an internal plugin" means the notifier, **it is already done**. If it means the *transport* -- host, port, username, password, TLS -- that is Django's `MAILERS`, and it is not only the notifier's. It is what allauth sends through, and: ```python ACCOUNT_EMAIL_VERIFICATION = "mandatory" ``` **Nobody can create an account, verify an address, or reset a password without it.** Make that a plugin and account verification depends on a plugin being installed, loaded and enabled. Then #95 lands, and an administrator can switch a plugin off for the instance -- at which point sign-up and password reset stop, and the only person who could turn it back on is one who is already signed in. An instance can be locked out of its own recovery path by a switch that reads like a preference. The three ways forward: 1. **Leave the transport in core, put its settings in the interface.** That is #84, already filed, already specified, and it gives the actual benefit -- configuring mail without editing a file -- with none of this risk. **Recommended.** 2. Make the transport a plugin and forbid disabling it, which is a plugin that cannot be switched off, configured through a plugin form -- a lot of machinery to arrive back where it started. 3. Make it a plugin that can be disabled, and accept that an instance can be bricked from a settings page. ## Contact details: a plugin cannot own a table today Reading "multiple contacts per user" as #90 (several telephone numbers) and #92 (several postal addresses) -- **say if that is wrong**, because the rest of this follows from it. Those are models. And: ```python INSTALLED_APPS = [ ... ] # a static list in config/settings/base.py ``` Nothing in the plugin machinery touches it. A plugin is an entry point that Postulo instantiates; **it cannot be a Django app, cannot ship migrations, and cannot own a table.** Making contact details a plugin therefore means building plugin-owned migrations first, and that brings questions with sharp edges: - What happens to the rows when the plugin is disabled? The data is still in the database and nothing can read it. - What happens when it is **removed**? Dropping a table takes people's phone numbers and addresses with it. A "Remove" button that deletes personal data is a different button from the one that exists. - What does `core/export.py` do? Taking your data out has to include it, which means the exporter depends on a plugin being loaded to produce a complete archive. - What runs a plugin's migration on upgrade, and what happens when it fails halfway? **There is a lighter design that gets the actual benefit.** If the wish is that an operator can decide their instance does not collect postal addresses -- which is a good wish, and plain data minimisation -- then the *tables* stay in core and a **feature switch** governs whether the interface offers them. Nobody's data is at the mercy of an entry point, the export stays complete, and the switch is one row rather than a migration system. That is a genuinely different design from "make it a plugin", and it is worth choosing on purpose. Plugin-owned data may still be right one day -- but it should be decided for its own reasons, not arrived at because contact details were the first thing to ask for it. ## What this issue wants An answer to two questions, so #90, #92 and #84 can proceed: 1. SMTP: option 1, 2 or 3 above? 2. Contact details: plugin-owned tables, or core tables behind a feature switch? ## Classification Security, tier 1 -- not because anything is broken now, but because the lockout in the first half and the data-loss button in the second are both the kind of thing that is obvious in an issue and invisible in a diff.
tiagoagueda added this to the 0.3.0 milestone 2026-09-07 15:26:21 +00:00
Author
Owner

Decided

the smtp cannot be disable if no other recovery rout exist, like sms, apprise, etc

So: not a blanket rule and not a free switch — an interlock. SMTP may be turned off, and
may not be turned off while it is the only way anybody could get back into their account.

This is the rule Postulo already applies to administrators, one level deeper. Server
settings -> People
says "The last administrator cannot be removed, deactivated or
deleted"
, and refuses rather than warns. The same shape: the last recovery route cannot be
removed either.

What that means today, checked rather than assumed

The recovery routes that actually exist:

Route Exists? Covers
Email password reset yes -- allauth's, ACCOUNT_* everybody with a verified address
SSO / OpenID Connect yes, when configured only accounts linked to the provider
Administrator resets a password no server_urls.py offers administrator, active, username, delete -- and no password
SMS no --
Apprise or any other notifier no notifiers send notifications; nothing routes a reset through one

So the interlock is implementable now, and today it will almost always answer no. The one
existing alternative is SSO, and POSTULO_OIDC_IS_SECOND_FACTOR defaults to False, which
means SSO is a login method in its own right rather than a second factor -- a genuine way
back in for anybody linked to the provider.

The SMS and Apprise routes named in the decision do not exist yet. Filed separately as a
prerequisite rather than smuggled in here.

The interlock is per person, not per instance

This is the part that would be easy to get wrong. SSO covers only accounts linked to the
provider. A notifier is a Connection row owned by one person. So "is there another way in"
is a question about every account separately, and the check is:

SMTP may be disabled only when no account's only way back is email.

One person on the instance who signed up with a password and never linked SSO is enough to
refuse it -- and the refusal should name them, the way the last-administrator refusal names
what it is protecting.

And disabling it breaks sign-up as well as recovery

Separate consequence, easy to miss:

ACCOUNT_EMAIL_VERIFICATION = "mandatory"

A new account cannot be created without a verification email, whatever alternative
routes existing accounts have. So switching SMTP off also stops registration and
invitations. Either the interlock covers that too, or turning SMTP off implies closing
registration, and the page has to say which.

What this settles

Option 3 from the issue, with the interlock making it safe -- rather than option 1, which
avoided the question by leaving the transport in core. #84 is unblocked and can proceed on
its own terms: putting the settings in the interface never required the transport to be a
plugin.

## Decided > the smtp cannot be disable if no other recovery rout exist, like sms, apprise, etc So: **not a blanket rule and not a free switch — an interlock.** SMTP may be turned off, and may not be turned off while it is the only way anybody could get back into their account. This is the rule Postulo already applies to administrators, one level deeper. *Server settings -> People* says *"The last administrator cannot be removed, deactivated or deleted"*, and refuses rather than warns. The same shape: the last recovery route cannot be removed either. ## What that means today, checked rather than assumed **The recovery routes that actually exist:** | Route | Exists? | Covers | | --- | --- | --- | | Email password reset | yes -- allauth's, `ACCOUNT_*` | everybody with a verified address | | SSO / OpenID Connect | yes, when configured | only accounts linked to the provider | | Administrator resets a password | **no** | `server_urls.py` offers administrator, active, username, delete -- and no password | | SMS | **no** | -- | | Apprise or any other notifier | **no** | notifiers send notifications; nothing routes a reset through one | So the interlock is implementable now, and today it will almost always answer *no*. The one existing alternative is SSO, and `POSTULO_OIDC_IS_SECOND_FACTOR` defaults to `False`, which means SSO is a login method in its own right rather than a second factor -- a genuine way back in for anybody linked to the provider. The SMS and Apprise routes named in the decision **do not exist yet**. Filed separately as a prerequisite rather than smuggled in here. ## The interlock is per person, not per instance This is the part that would be easy to get wrong. SSO covers only accounts linked to the provider. A notifier is a `Connection` row owned by one person. So "is there another way in" is a question about **every account separately**, and the check is: > SMTP may be disabled only when no account's only way back is email. One person on the instance who signed up with a password and never linked SSO is enough to refuse it -- and the refusal should name them, the way the last-administrator refusal names what it is protecting. ## And disabling it breaks sign-up as well as recovery Separate consequence, easy to miss: ```python ACCOUNT_EMAIL_VERIFICATION = "mandatory" ``` A **new** account cannot be created without a verification email, whatever alternative routes existing accounts have. So switching SMTP off also stops registration and invitations. Either the interlock covers that too, or turning SMTP off implies closing registration, and the page has to say which. ## What this settles Option 3 from the issue, with the interlock making it safe -- rather than option 1, which avoided the question by leaving the transport in core. #84 is unblocked and can proceed on its own terms: putting the settings in the interface never required the transport to be a plugin.
Author
Owner

Decided — the SMTP half, in full

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

Option 2 from the issue, which I had described as "a plugin that cannot be switched off... a
lot of machinery to arrive back where it started"
. That reading was too narrow, and the
staging answers it: the switch is locked by the interlock, not by a special case, so
nothing is built that has to be unbuilt later. When #103 gives an instance another way back
in, the same rule lets go on its own.

This unblocks #100 rather than deepening it. The dependency on #103 is removed: shipping
the plugin locked on needs nothing from #103, and #103 is what eventually makes the switch
usable. Filed as its own implementation issue.

Still open here: the second half, whether contact details become plugin-owned tables or stay
in core behind a feature switch.

## Decided — the SMTP half, in full > make smtp internal plugin but stays enable until this issues is resolved Option 2 from the issue, which I had described as *"a plugin that cannot be switched off... a lot of machinery to arrive back where it started"*. That reading was too narrow, and the staging answers it: the switch is locked **by the interlock**, not by a special case, so nothing is built that has to be unbuilt later. When #103 gives an instance another way back in, the same rule lets go on its own. **This unblocks #100 rather than deepening it.** The dependency on #103 is removed: shipping the plugin locked on needs nothing from #103, and #103 is what eventually makes the switch usable. Filed as its own implementation issue. Still open here: the second half, whether contact details become plugin-owned tables or stay in core behind a feature switch.
Author
Owner

Decided — core tables behind a feature switch

The second half, and with it the whole issue.

PhoneNumber and PostalAddress are ordinary models in core, owned and migrated by core.
A feature switch decides whether the interface offers them. Nobody's data sits at the
mercy of an entry point, there is no plugin migration system to build, and the switch is one
row rather than a subsystem.

It also gets the thing that was actually wanted. An operator who does not want their instance
collecting postal addresses can say so, which is plain data minimisation and a good reason on
its own — quite separate from whether the code that implements addresses happens to be a
plugin.

What that means for #90 and #92

  • Models in accounts, beside Profile. Migrated with everything else.
  • The switch follows site.ENV_OVERRIDES, the mechanism four settings already use and #84
    extends, so an operator can pin it in the environment and the field greys out.
  • Off hides the interface; it never deletes a row. Turning the feature off on an instance
    that already has addresses leaves them exactly where they are, and turning it back on shows
    them again unchanged. A switch that destroys data is a delete button with a confusing name.
  • The export carries them whether the switch is on or not. This is the one that would be
    easy to get wrong: an instance with the feature off but rows still stored must still hand
    those rows over, or take your data out has quietly stopped meaning all of it.

What this settles about plugins generally

Plugin-owned tables are not being built, and are not ruled out for ever — they are ruled out
as a thing to arrive at sideways because contact details were the first feature to ask. If a
plugin one day genuinely needs to own rows, that is its own issue with its own answers about
what happens on disable, on removal, and on a migration that fails halfway.

Both halves are now answered

  • SMTP: an internal transport plugin, shipping locked on, with the lock being the
    interlock rather than a special case — #104, with #103 as what eventually opens it.
  • Contact details: core tables, feature switch — this comment, for #90 and #92.

Closing. The implementations are the three issues that were waiting on it.

## Decided — core tables behind a feature switch The second half, and with it the whole issue. `PhoneNumber` and `PostalAddress` are ordinary models in core, owned and migrated by core. A **feature switch** decides whether the interface offers them. Nobody's data sits at the mercy of an entry point, there is no plugin migration system to build, and the switch is one row rather than a subsystem. It also gets the thing that was actually wanted. An operator who does not want their instance collecting postal addresses can say so, which is plain data minimisation and a good reason on its own — quite separate from whether the code that implements addresses happens to be a plugin. ## What that means for #90 and #92 - Models in `accounts`, beside `Profile`. Migrated with everything else. - The switch follows `site.ENV_OVERRIDES`, the mechanism four settings already use and #84 extends, so an operator can pin it in the environment and the field greys out. - **Off hides the interface; it never deletes a row.** Turning the feature off on an instance that already has addresses leaves them exactly where they are, and turning it back on shows them again unchanged. A switch that destroys data is a delete button with a confusing name. - **The export carries them whether the switch is on or not.** This is the one that would be easy to get wrong: an instance with the feature off but rows still stored must still hand those rows over, or *take your data out* has quietly stopped meaning all of it. ## What this settles about plugins generally Plugin-owned tables are not being built, and are not ruled out for ever — they are ruled out as a thing to arrive at sideways because contact details were the first feature to ask. If a plugin one day genuinely needs to own rows, that is its own issue with its own answers about what happens on disable, on removal, and on a migration that fails halfway. ## Both halves are now answered - **SMTP**: an internal transport plugin, shipping locked on, with the lock being the interlock rather than a special case — #104, with #103 as what eventually opens it. - **Contact details**: core tables, feature switch — this comment, for #90 and #92. Closing. The implementations are the three issues that were waiting on it.
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#100
No description provided.