A channel the instance can reach a locked-out person on #143

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

Observation

when sms notifications is written on the code

Prerequisite. There is no SMS anywhere in Postulo — one comment in
notifications/transport.py naming it as a route that has not landed, and nothing else.

What exists

Two ways out of the machine, and they are not the same kind of thing:

  • a transport carries the instance's own mail — verification links, password resets,
    reminders. It is instance plumbing, belongs to nobody, and is deliberately exempt from the
    per-person plugin policy;
  • a notifier tells a person something, through a Connection that person made and
    whose credentials they supplied. Connection is an OwnedModel.

recovery_routes() returns email when a transport is selected, and passkey when no
active account is without one. Its docstring names what is missing:

When another route lands (#103 — SMS, Apprise, an administrator-issued link) it is added
here and the lock below opens by itself.

What this asks for

A way for the instance to reach somebody on a telephone, good enough to carry a verification
code and a way back in.

Worth being careful about

A recovery channel cannot be a per-person notifier, and this is the crux. A notifier's
credentials belong to the person: their Twilio account, their Apprise endpoint. A person
locked out of their account is exactly the person whose gateway may also be unreachable, and
worse, "the account holder configured the channel that proves they are the account holder"
is circular. A recovery SMS has to go through something the instance operates — which
makes it a transport in shape, not a notifier, even though the message is not mail.

That may mean a second transport kind, a transport that carries more than mail, or a new kind
altogether. Deciding it is most of this issue.

Every gateway is somebody else's jurisdiction. Postulo's whole argument is that a
self-hoster's data answers to them; an SMS route means a telephone number, a message and a
timestamp reaching Twilio, Vonage or a national aggregator on every send. That is a real
cost to state in the interface where the channel is configured, not a detail — and it is a
reason some operators will not want it, which means it must stay optional and the lock must
stay honest without it.

#103 lists three candidates and SMS is only one. An administrator issuing a recovery link
needs no third party at all and solves the same problem for a self-hosted instance with one
administrator; Apprise reaches a dozen services through one connection. Whether SMS is the
right first answer is worth asking inside this issue rather than assuming, given the
suggestion arrives with #103 already open.

Rate limits and cost. Mail that fails costs nothing; SMS that loops costs money and
annoys a stranger whose number was mistyped. POSTULO_CAPTURE_RATE and its siblings are the
precedent — a send limit per account and per number, set low, and a refusal that says so.

A code sent by SMS is a second factor's worth of security at best. SIM swapping is the
standard attack and it is not exotic. Whatever lands should say what it is worth: enough to
get back into a self-hosted job-search application, not enough to be the only thing guarding
an account that also holds passkeys.

It has to be a plugin, and therefore subject to the plugin rules — #126's surface,
#128's data question, and #129's self-containment. A transport that is also a recovery route
is already locked against being switched off (#104) while it is the last way in.

## Observation > when sms notifications is written on the code Prerequisite. There is no SMS anywhere in Postulo — one comment in `notifications/transport.py` naming it as a route that has not landed, and nothing else. ## What exists Two ways out of the machine, and they are not the same kind of thing: - a **transport** carries the instance's own mail — verification links, password resets, reminders. It is instance plumbing, belongs to nobody, and is deliberately exempt from the per-person plugin policy; - a **notifier** tells *a person* something, through a `Connection` that person made and whose credentials they supplied. `Connection` is an `OwnedModel`. `recovery_routes()` returns `email` when a transport is selected, and `passkey` when no active account is without one. Its docstring names what is missing: > When another route lands (#103 — SMS, Apprise, an administrator-issued link) it is added > here and the lock below opens by itself. ## What this asks for A way for the instance to reach somebody on a telephone, good enough to carry a verification code and a way back in. ## Worth being careful about **A recovery channel cannot be a per-person notifier, and this is the crux.** A notifier's credentials belong to the person: their Twilio account, their Apprise endpoint. A person locked out of their account is exactly the person whose gateway may also be unreachable, and worse, "the account holder configured the channel that proves they are the account holder" is circular. A recovery SMS has to go through something the **instance** operates — which makes it a transport in shape, not a notifier, even though the message is not mail. That may mean a second transport kind, a transport that carries more than mail, or a new kind altogether. Deciding it is most of this issue. **Every gateway is somebody else's jurisdiction.** Postulo's whole argument is that a self-hoster's data answers to them; an SMS route means a telephone number, a message and a timestamp reaching Twilio, Vonage or a national aggregator on every send. That is a real cost to state in the interface where the channel is configured, not a detail — and it is a reason some operators will not want it, which means it must stay optional and the lock must stay honest without it. **#103 lists three candidates and SMS is only one.** An administrator issuing a recovery link needs no third party at all and solves the same problem for a self-hosted instance with one administrator; Apprise reaches a dozen services through one connection. Whether SMS is the right first answer is worth asking inside this issue rather than assuming, given the suggestion arrives with #103 already open. **Rate limits and cost.** Mail that fails costs nothing; SMS that loops costs money and annoys a stranger whose number was mistyped. `POSTULO_CAPTURE_RATE` and its siblings are the precedent — a send limit per account and per number, set low, and a refusal that says so. **A code sent by SMS is a second factor's worth of security at best.** SIM swapping is the standard attack and it is not exotic. Whatever lands should say what it is worth: enough to get back into a self-hosted job-search application, not enough to be the only thing guarding an account that also holds passkeys. **It has to be a plugin, and therefore subject to the plugin rules** — #126's surface, #128's data question, and #129's self-containment. A transport that is also a recovery route is already locked against being switched off (#104) while it is the last way in.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 10:48:00 +00:00
Author
Owner

The open question here — a recovery channel cannot be a per-person notifier, because the account holder configuring the channel that proves they are the account holder is circular — has an answer from the maintainer, filed as #149: mail splits into an instance half and a person half, and only the instance half is ever a recovery route.

That settles the shape question this issue raised without settling this issue. A recovery SMS still has to be carried by something the instance operates, and the split says what that means: transport-shaped, ungoverned, locked by #104 while it is the last way in. Whatever lands here follows the same rule as the mail transport rather than inventing a second one.

The open question here — *a recovery channel cannot be a per-person notifier, because the account holder configuring the channel that proves they are the account holder is circular* — has an answer from the maintainer, filed as #149: mail splits into an instance half and a person half, and **only the instance half is ever a recovery route**. That settles the shape question this issue raised without settling this issue. A recovery SMS still has to be carried by something the instance operates, and the split says what that means: transport-shaped, ungoverned, locked by #104 while it is the last way in. Whatever lands here follows the same rule as the mail transport rather than inventing a second one.
Author
Owner

The decision, which the issue said was most of the work: one kind, two mediums.

Not a notifier — the issue's crux, and it is right. A notifier's credentials belong to the
person, somebody locked out is exactly the one whose own gateway may be unreachable, and "the
account holder configured the channel that proves they are the account holder" is circular.

Not a second kind either. Selection, configuration, the #104 interlock, the exemption from
per-person policy and the plugins page are identical for mail and text; what differs is the
payload. That is what a field describes and what a kind would have duplicated four times over.
So transport gains a medium — mail or text — and a transport that does not say carries
mail, so nothing already written has to be edited. Each medium keeps its own configuration
columns, because both are selected at once and one blob could hold only one of them.

Postulo ships no gateway. That is the answer to "every gateway is somebody else's
jurisdiction", not an unfinished edge: a number, a message and a timestamp reaching Twilio,
Vonage or an aggregator on every send, from an application whose argument is that a
self-hoster's data answers to them. The kind exists; the package is somebody else's, exactly
as for mail over an HTTP API. Everything works without one.

On whether SMS is the right first answer — it is not, and that is recorded rather than
assumed.
It needs a third party, costs money per message, and is defeated by a SIM swap. An
administrator issuing a recovery link needs nobody else and answers the same question for the
self-hosted instance with one administrator, which is most of them (#103). What a text gateway
is for here is confirming a number at all (#142); being a recovery route is something it may
earn afterwards.

And it earns it on the same bar the passkey route is held to: a gateway and a confirmed
number on every active account. This list decides whether mail may be switched off, so a route
covering nine accounts in ten would strand the tenth.

Two limits, not one, because two different mistakes want bounding. POSTULO_TEXT_RATE
(5/h) is somebody driving a resend button. POSTULO_TEXT_PER_NUMBER_RATE (3/h) is a stranger
whose number was mistyped into a form — the one that matters, since they never asked to be
involved and cannot switch anything off. The per-number limit applies even when nobody is
signed in, which is exactly the state a recovery flow is in.

A gateway failure is logged without the number or the body: that line reaches an operator's
log and the body is a code that gets somebody into an account.

TelephoneChannel.carrier() now answers with the gateway's name, so #146's confirmable()
becomes true the moment an operator installs one — nothing in the contract changes, only the
answer.

25 tests in tests/test_text_channel.py, each bringing its own gateway; a wiki section under
Configuration; five strings in all 39 European catalogues.

Shipped in c9796e0 on 0.3.0, with main kept level.

**The decision, which the issue said was most of the work: one kind, two mediums.** Not a notifier — the issue's crux, and it is right. A notifier's credentials belong to the person, somebody locked out is exactly the one whose own gateway may be unreachable, and "the account holder configured the channel that proves they are the account holder" is circular. Not a second kind either. Selection, configuration, the #104 interlock, the exemption from per-person policy and the plugins page are identical for mail and text; what differs is the payload. That is what a field describes and what a kind would have duplicated four times over. So `transport` gains a `medium` — `mail` or `text` — and a transport that does not say carries mail, so nothing already written has to be edited. Each medium keeps its own configuration columns, because both are selected at once and one blob could hold only one of them. **Postulo ships no gateway.** That is the answer to "every gateway is somebody else's jurisdiction", not an unfinished edge: a number, a message and a timestamp reaching Twilio, Vonage or an aggregator on every send, from an application whose argument is that a self-hoster's data answers to them. The kind exists; the package is somebody else's, exactly as for mail over an HTTP API. Everything works without one. **On whether SMS is the right first answer — it is not, and that is recorded rather than assumed.** It needs a third party, costs money per message, and is defeated by a SIM swap. An administrator issuing a recovery link needs nobody else and answers the same question for the self-hosted instance with one administrator, which is most of them (#103). What a text gateway is *for* here is confirming a number at all (#142); being a recovery route is something it may earn afterwards. And it earns it on the same bar the passkey route is held to: a gateway **and** a confirmed number on every active account. This list decides whether mail may be switched off, so a route covering nine accounts in ten would strand the tenth. **Two limits, not one**, because two different mistakes want bounding. `POSTULO_TEXT_RATE` (5/h) is somebody driving a resend button. `POSTULO_TEXT_PER_NUMBER_RATE` (3/h) is a stranger whose number was mistyped into a form — the one that matters, since they never asked to be involved and cannot switch anything off. The per-number limit applies even when nobody is signed in, which is exactly the state a recovery flow is in. A gateway failure is logged **without the number or the body**: that line reaches an operator's log and the body is a code that gets somebody into an account. `TelephoneChannel.carrier()` now answers with the gateway's name, so #146's `confirmable()` becomes true the moment an operator installs one — nothing in the contract changes, only the answer. 25 tests in `tests/test_text_channel.py`, each bringing its own gateway; a wiki section under *Configuration*; five strings in all 39 European catalogues. Shipped in `c9796e0` on `0.3.0`, with `main` kept level.
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#143
No description provided.