Mail has two halves: the instance's and the person's #149
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
#148 A mail server a person typed is a host the server dials
Postulo/postulo
Reference
Postulo/postulo#149
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
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.
SMTPTransportis a plugin of kindtransport. Transports are the one kind exempt from the per-person policy —UNGOVERNED_KINDS = ("transport",)— with the reason written out:Its settings are in Server settings → Email, editable there and overridable by
POSTULO_EMAIL_*in the environment, andrefuse_switching_off()locks it while it is thelast 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.
EmailNotifieris a plugin of kindnotifier: per person, governed by policy, switchable by the person and overridable by anadministrator, with its own
Connection. But its docstring says what it actually is —— and its only configuration field is
to. It borrows the server's transport and sendssend_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()returnsemailwhen a transport is selected. If a person's own SMTPcould 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:
arriving from
alex@example.comrather than from the instance. This is the one thatrequires 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.
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.decidehas no way to express. Worth deciding whether the pairing is a fact about thecode 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_RATEand itssiblings 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.
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.
The structural decision the issue named, made: a new kind,
outbox.smtp(transport)own-mail(outbox)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.pyand assertsthe word "outbox" does not appear in it.
"One plugin in two parts" would have been one plugin at once governed and ungoverned,
which
policy.decidehas no way to express — the issue said as much, and it is right. Twokinds 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
Fromwas quietly replaced is a message that looks forged. One subtlety worth recording:Django puts
DEFAULT_FROM_EMAILon a message given no sender, so "no sender" never arrivesempty — 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 onesurface 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.pynow and both halvesuse one guard rather than two that can drift. And
MailSecuritywent with it: it is howTLS gets onto a session, not a model, and it was in
models.pyonly because the field usingit 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.pyreads the source ratherthan 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
9d34da0on0.3.0, withmainkept level.