Mail has two halves: the instance's and the person's #149

Closed
opened 2026-09-09 11:02:03 +00:00 by tiagoagueda · 2 comments
Owner

Observation

lets smtp plugin be internal split in 2 part, 1 part, server site that cannot be disable and
the settings reside in the admin interface and a second part, user side, that can be
enable/disable by the user / admin with smtp settings by user and handle a disting workflow
all server related email workflow goes thru the first part of the plugin

This answers the open question in #143 — 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
. The split is the resolution: the instance's mail is the instance's, the person's
mail is the person's, and only the first is ever a way back in.

What exists

The first half is built, and behaves as described. SMTPTransport is a plugin of kind
transport. Transports are the one kind exempt from the per-person policy —
UNGOVERNED_KINDS = ("transport",) — with the reason written out:

Mail delivery is instance infrastructure rather than a capability a person holds, and
forced off for a transport would be an account nobody can recover (#104).

Its settings are in Server settings → Email, editable there and overridable by
POSTULO_EMAIL_* in the environment, and refuse_switching_off() locks it while it is the
last way anybody could get back in. That is "cannot be disabled, settings in the admin
interface"
, already.

The second half exists in shape and not in substance. EmailNotifier is a plugin of kind
notifier: per person, governed by policy, switchable by the person and overridable by an
administrator, with its own Connection. But its docstring says what it actually is —

The built-in notifier: plain email, through the instance's mail settings.

— and its only configuration field is to. It borrows the server's transport and sends
send_mail(). So there is a user-side plugin, and it has no mail settings of its own.

What this asks for

Give the second half its own SMTP, and a workflow of its own to carry.

Worth being careful about

The invariant that must not bend: the person's mail is never a recovery route.
recovery_routes() returns email when a transport is selected. If a person's own SMTP
could carry a password reset, then switching their own plugin off would remove their own way
back in — the exact failure #104 was written to prevent, arriving through a door nobody
locked. The user half stays out of recovery_routes() by construction, not by convention,
and a test should say so.

What the second workflow actually is, is worth naming rather than assuming. Two candidates
and they want different things:

  • Sending as yourself — a follow-up note, a speculative letter, a reply to a recruiter,
    arriving from alex@example.com rather than from the instance. This is the one that
    requires per-user SMTP: sending as somebody else through the instance's server is
    spoofing, and SPF and DKIM will bounce it or bin it. It also joins up with #133, where
    Emails are proposed as a kind of document — a thing composed, frozen and kept.
  • Being notified somewhere the instance cannot reach — which the existing notifier already
    half does, and which Apprise would do better.

The first is the one that cannot be done any other way, and is probably the answer.

"One plugin in two parts" is two plugins today, and that may be the better shape. A
transport and a notifier already exist as separate registrations with separate manifests and
separate kinds. Presenting them as one thing in the interface is reasonable; merging them in
the code would mean one plugin that is simultaneously ungoverned and governed, which
policy.decide has no way to express. Worth deciding whether the pairing is a fact about the
code or a fact about the page.

A per-person transport is a shape that does not exist yet. The user half is not a notifier
(it does not tell somebody something, it sends their correspondence) and not a transport (a
transport belongs to nobody). Whether transports become ownable, or the notifier grows mail
settings, or a new kind arrives, is the structural decision inside this issue — and #126's
plugin surface is where the answer has to be expressible.

The From address is the whole point and the whole risk. Sending as the person means the
person's own domain, their own SPF record, their own reputation. It also means a bounce comes
back to them and not to the instance, which is right — and that Postulo must not silently
rewrite the sender, because a rewritten sender is what makes mail look forged.

Rate limits are per account here, as everywhere else. POSTULO_CAPTURE_RATE and its
siblings are the precedent, and mail somebody sends to strangers is the surface where a
mistake is loudest.

An administrator may switch the user half off, and that has to say what it means. Not
"you cannot be notified" — the server half still notifies — but "you cannot send from your
own address here". The plugins page already explains who decided and why; this needs wording
that does not read as though mail has been taken away.

## Observation > lets smtp plugin be internal split in 2 part, 1 part, server site that cannot be disable and > the settings reside in the admin interface and a second part, user side, that can be > enable/disable by the user / admin with smtp settings by user and handle a disting workflow > all server related email workflow goes thru the first part of the plugin This answers the open question in #143 — *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*. The split is the resolution: the instance's mail is the instance's, the person's mail is the person's, and only the first is ever a way back in. ## What exists **The first half is built, and behaves as described.** `SMTPTransport` is a plugin of kind `transport`. Transports are the one kind exempt from the per-person policy — `UNGOVERNED_KINDS = ("transport",)` — with the reason written out: > Mail delivery is instance infrastructure rather than a capability a person holds, and > *forced off* for a transport would be an account nobody can recover (#104). Its settings are in *Server settings → Email*, editable there and overridable by `POSTULO_EMAIL_*` in the environment, and `refuse_switching_off()` locks it while it is the last way anybody could get back in. That is *"cannot be disabled, settings in the admin interface"*, already. **The second half exists in shape and not in substance.** `EmailNotifier` is a plugin of kind `notifier`: per person, governed by policy, switchable by the person and overridable by an administrator, with its own `Connection`. But its docstring says what it actually is — > The built-in notifier: plain email, **through the instance's mail settings**. — and its only configuration field is `to`. It borrows the server's transport and sends `send_mail()`. So there is a user-side plugin, and it has no mail settings of its own. ## What this asks for Give the second half its own SMTP, and a workflow of its own to carry. ## Worth being careful about **The invariant that must not bend: the person's mail is never a recovery route.** `recovery_routes()` returns `email` when a transport is selected. If a person's own SMTP could carry a password reset, then switching their own plugin off would remove their own way back in — the exact failure #104 was written to prevent, arriving through a door nobody locked. The user half stays out of `recovery_routes()` by construction, not by convention, and a test should say so. **What the second workflow actually is, is worth naming rather than assuming.** Two candidates and they want different things: - *Sending as yourself* — a follow-up note, a speculative letter, a reply to a recruiter, arriving from `alex@example.com` rather than from the instance. This is the one that **requires** per-user SMTP: sending as somebody else through the instance's server is spoofing, and SPF and DKIM will bounce it or bin it. It also joins up with #133, where Emails are proposed as a kind of document — a thing composed, frozen and kept. - *Being notified somewhere the instance cannot reach* — which the existing notifier already half does, and which Apprise would do better. The first is the one that cannot be done any other way, and is probably the answer. **"One plugin in two parts" is two plugins today, and that may be the better shape.** A transport and a notifier already exist as separate registrations with separate manifests and separate kinds. Presenting them as one thing in the interface is reasonable; merging them in the code would mean one plugin that is simultaneously ungoverned and governed, which `policy.decide` has no way to express. Worth deciding whether the pairing is a fact about the code or a fact about the page. **A per-person transport is a shape that does not exist yet.** The user half is not a notifier (it does not tell somebody something, it sends their correspondence) and not a transport (a transport belongs to nobody). Whether transports become ownable, or the notifier grows mail settings, or a new kind arrives, is the structural decision inside this issue — and #126's plugin surface is where the answer has to be expressible. **The From address is the whole point and the whole risk.** Sending as the person means the person's own domain, their own SPF record, their own reputation. It also means a bounce comes back to them and not to the instance, which is right — and that Postulo must not silently rewrite the sender, because a rewritten sender is what makes mail look forged. **Rate limits are per account here, as everywhere else.** `POSTULO_CAPTURE_RATE` and its siblings are the precedent, and mail somebody sends to strangers is the surface where a mistake is loudest. **An administrator may switch the user half off, and that has to say what it means.** Not "you cannot be notified" — the server half still notifies — but "you cannot send from your own address here". The plugins page already explains who decided and why; this needs wording that does not read as though mail has been taken away.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 11:02:03 +00:00
Author
Owner

Both halves need #151: Microsoft disables SMTP AUTH Basic Authentication by default for existing tenants at the end of December 2026, with final removal announced for the second half of 2027, and only XOAUTH2 accepted afterwards. That is about three months from now, and it lands on the instance half first — verification links and password resets, not conveniences.

It also argues for the two halves being one package even while they stay two plugin kinds: the token exchange, the refresh and the SASL string are identical, and only the consent model differs — client credentials or an app password for the instance, an authorization-code grant per person for the user side.

Both halves need #151: Microsoft disables SMTP AUTH Basic Authentication by default for existing tenants at the **end of December 2026**, with final removal announced for the second half of 2027, and only XOAUTH2 accepted afterwards. That is about three months from now, and it lands on the instance half first — verification links and password resets, not conveniences. It also argues for the two halves being one package even while they stay two plugin kinds: the token exchange, the refresh and the SASL string are identical, and only the consent model differs — client credentials or an app password for the instance, an authorization-code grant per person for the user side.
Author
Owner

The structural decision the issue named, made: a new kind, outbox.

Whether transports become ownable, or the notifier grows mail settings, or a new kind
arrives, is the structural decision inside this issue

smtp (transport) own-mail (outbox)
Belongs to the instance the person
Sends as the instance the person
Configured Server settings → Email Settings → Connections
Switched off never, while it is the way back in by the person, or by an administrator
Recovers yes never

The last row decided the shape. Getting back into an account reads transports. An
outbox is not one, so a person switching their own mail off cannot thereby remove their own
way back in — the failure #104 exists to prevent, arriving through the door #143 asked about.
By construction rather than by convention: a test reads accounts/recovery.py and asserts
the word "outbox" does not appear in it.

"One plugin in two parts" would have been one plugin at once governed and ungoverned,
which policy.decide has no way to express — the issue said as much, and it is right. Two
kinds is what is true; the pairing is a fact about the page.

Sending as yourself is the candidate that cannot be done any other way, so it is what
this builds. An address on a message leaving the instance's server is spoofing; SPF says that
server is not authorised for the domain. So it goes over their server with their address on
it.

The warnings

The From address. Never rewritten. Postulo fills in the connection's address when a
message has none and refuses one that claims a different address, because a message whose
From was quietly replaced is a message that looks forged. One subtlety worth recording:
Django puts DEFAULT_FROM_EMAIL on a message given no sender, so "no sender" never arrives
empty — that fill-in is treated as unset rather than as a claim, or the ordinary call would
be refused and callers would learn to pass the address by hand.

Rate limits per account. POSTULO_OUTBOX_RATE, 60/h. Low on purpose: this is the one
surface where a mistake reaches strangers rather than the person who made it.

What switching it off means. "You cannot send from your own address here" — never "you
cannot be notified". The instance still notifies, still recovers, still sends everything of
its own, and the wording says so in the plugin's own description.

Two things moved on the way, both because a test said so

The destination guard was inside the transport. A person's outbox dials wherever they
typed — exactly the shape #148 exists for — so it is in core/mail.py now and both halves
use one guard rather than two that can drift. And MailSecurity went with it: it is how
TLS gets onto a session, not a model, and it was in models.py only because the field using
it lives there. #129's "no shipped plugin reaches for a model" is what caught that.

And one thing found that is worth more than the feature

Three plugins import gettext_lazy as _lazy. scripts/messages.py reads the source rather
than importing it, so it knows a call by the name at the call site — and it had never been
told about that alias. Their descriptions were never extracted and never translatable,
silently, for as long as they had existed: the catalogues were complete and the strings were
simply not in them, which is the one shape of translation bug a completeness check cannot
see. The extractor knows the alias, a test fails on the next alias somebody invents, and the
fifteen strings are translated into all thirty-nine European languages.

What this does not build

The composing surface. An outbox can be set up, proved with the Test button, and used by
anything that asks — what a person writes belongs to #133, which proposed emails as a kind
of document and now has a shape to be sent through. Saying so rather than implying otherwise:
today this is the machinery and the guarantee, not a compose window.

11 tests. Shipped in 9d34da0 on 0.3.0, with main kept level.

The structural decision the issue named, made: **a new kind**, `outbox`. > Whether transports become ownable, or the notifier grows mail settings, or a new kind > arrives, is the structural decision inside this issue | | `smtp` (transport) | `own-mail` (outbox) | | --- | --- | --- | | Belongs to | the instance | the person | | Sends as | the instance | the person | | Configured | *Server settings → Email* | *Settings → Connections* | | Switched off | never, while it is the way back in | by the person, or by an administrator | | Recovers | yes | **never** | **The last row decided the shape.** Getting back into an account reads *transports*. An outbox is not one, so a person switching their own mail off cannot thereby remove their own way back in — the failure #104 exists to prevent, arriving through the door #143 asked about. By construction rather than by convention: a test reads `accounts/recovery.py` and asserts the word "outbox" does not appear in it. **"One plugin in two parts" would have been one plugin at once governed and ungoverned**, which `policy.decide` has no way to express — the issue said as much, and it is right. Two kinds is what is true; the pairing is a fact about the page. **Sending as yourself is the candidate that cannot be done any other way**, so it is what this builds. An address on a message leaving the instance's server is spoofing; SPF says that server is not authorised for the domain. So it goes over their server with their address on it. ## The warnings **The From address.** Never rewritten. Postulo fills in the connection's address when a message has none and *refuses* one that claims a different address, because a message whose `From` was quietly replaced is a message that looks forged. One subtlety worth recording: Django puts `DEFAULT_FROM_EMAIL` on a message given no sender, so "no sender" never arrives empty — that fill-in is treated as unset rather than as a claim, or the ordinary call would be refused and callers would learn to pass the address by hand. **Rate limits per account.** `POSTULO_OUTBOX_RATE`, 60/h. Low on purpose: this is the one surface where a mistake reaches strangers rather than the person who made it. **What switching it off means.** "You cannot send from your own address here" — never "you cannot be notified". The instance still notifies, still recovers, still sends everything of its own, and the wording says so in the plugin's own description. ## Two things moved on the way, both because a test said so The **destination guard** was inside the transport. A person's outbox dials wherever they typed — exactly the shape #148 exists for — so it is in `core/mail.py` now and both halves use one guard rather than two that can drift. And **`MailSecurity`** went with it: it is how TLS gets onto a session, not a model, and it was in `models.py` only because the field using it lives there. #129's "no shipped plugin reaches for a model" is what caught that. ## And one thing found that is worth more than the feature Three plugins import `gettext_lazy as _lazy`. `scripts/messages.py` reads the source rather than importing it, so it knows a call by the name at the call site — and it had never been told about that alias. **Their descriptions were never extracted and never translatable**, silently, for as long as they had existed: the catalogues were complete and the strings were simply not in them, which is the one shape of translation bug a completeness check cannot see. The extractor knows the alias, a test fails on the next alias somebody invents, and the fifteen strings are translated into all thirty-nine European languages. ## What this does not build The composing surface. An outbox can be set up, proved with the Test button, and used by anything that asks — what a person *writes* belongs to #133, which proposed emails as a kind of document and now has a shape to be sent through. Saying so rather than implying otherwise: today this is the machinery and the guarantee, not a compose window. 11 tests. Shipped in `9d34da0` 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#149
No description provided.