SMTP that Google and Microsoft will still accept #151
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
#150 A connection that needs consent, not a password
Postulo/postulo
Reference
Postulo/postulo#151
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
Why this has a date on it
Microsoft has published the timeline, and it is close:
existing Exchange Online tenants, with administrators able to re-enable it;
Only the XOAUTH2 SASL mechanism is accepted afterwards, carrying an OAuth 2.0 bearer
token. Google reaches the same place by the same mechanism, with app passwords as the
lower-friction route for accounts that have two-step verification.
So this is not a modernisation. On the current code, an instance whose operator uses
Microsoft 365 for its mail stops being able to send verification links and password resets
within about three months, and there is no configuration that fixes it.
What exists
SMTPTransportandEmailNotifierboth end up in Django's SMTP backend, which authenticatesone way:
smtplib.SMTP.login(), username and password. There is no XOAUTH2 anywhere, and noway to reach it through the settings — Server settings → Email offers a host, a port, a
username, a password and STARTTLS, which is the whole of what Basic Auth needs and none of
what replaces it.
What this asks for
XOAUTH2 on both halves of the mail plugin: the instance's transport and the person's.
Worth being careful about
Django's backend cannot do this and must be replaced, not configured.
EmailBackendcalls
login(); XOAUTH2 needs the SASL exchange with a base64 bearer string. A subclass thatoverrides the authentication step is a small amount of code and the right small amount —
this is not a place to take a dependency that wraps a provider's SDK.
Two halves, two consent models, and they are genuinely different.
The instance's transport is configured once by an operator. Microsoft's client-credentials
flow lets an app send without any person consenting, which suits a server — and needs a
tenant administrator, an app registration and a permission scoped to one mailbox. Google's
equivalent is a service account with domain-wide delegation, which not every operator can
create. The realistic fallback for a small self-hoster is an app password where the provider
still allows one.
The person's side is an ordinary authorization-code grant per account: they consent, their
refresh token is stored, and mail leaves as them. That is the flow #148 and the connection
contract have to carry.
The operator has to register an application with Google or Microsoft, and that is worth
saying plainly rather than burying. A project whose release notes give European digital
sovereignty as the reason for its language order is now asking a self-hoster to create a
Google Cloud project so their reminders arrive. The honest framing is that this is the
person's choice of mail provider being supported rather than Postulo depending on one —
and that an instance using its own mail server needs none of it. The documentation should
say which of those a reader is in.
A token expires on its own, and a password does not. This is the operational difference
that matters most: a refresh token can be revoked by the provider, invalidated by a password
change, or expire through disuse. Mail then stops, silently, on a path nobody watches. For
the person's half that is an annoyance; for the instance's half it is password-reset mail
failing, which is people locked out — and it needs the deliverability check that today's lock
does not have.
Deliverability and the recovery lock.
recovery_routes()counts email whenever atransport is selected, not whenever one works. That gap exists already, and OAuth turns
it from a misconfiguration nobody makes twice into a thing that happens on its own after
ninety days. Filed separately, and it wants doing whether or not this does.
Both halves are one plugin in the interface and two registrations in the code (#149).
XOAUTH2 lands in both, and the shared part — the token exchange, the refresh, the SASL
string — is the argument for them being one package even if they stay two plugin kinds.
Nothing here removes username and password. A self-hosted Postfix, a Mailcow, an
institutional relay: all of them authenticate the way they do now, and most of the people
this project is written for are on one. XOAUTH2 is an addition to the field set, offered when
the provider needs it.
Done in
a7dcd884. XOAUTH2 works on both halves of the mail plugin — the instance'stransport and a person's own outbox — ahead of Microsoft's December 2026 switch-off.
Your "worth being careful about" points, one by one
"Django's backend cannot do this and must be replaced, not configured." Right, and it is a
small override rather than a replacement. Django authenticates inside
open(), only with apassword, before it publishes the connection. So a token session is opened with no password
— Django then signs in with nothing — and
GuardedBackendauthenticates with XOAUTH2 on thesame socket straight afterwards, closing it again if the token is refused. No dependency:
core/mail_auth.pyis the whole mechanism, and the one subtle part —smtplibbase64-encodeswhat it is handed, so encoding it yourself sends it twice and gets a useless refusal — is tested
by decoding what actually went on the wire.
"Two halves, two consent models."
operator agrees on the provider's page as the sending mailbox — no administrator of anything,
works at both providers) and The application sends on its own (Microsoft's
client-credentials route; needs a tenant admin). Google's equivalent is a service account with
domain-wide delegation, a different grant again, so it is not offered rather than offered and
broken — the page refuses Google + application before it is saved.
it is an ordinary authorization-code grant through the machinery #148 built. This is the first
plugin to use it. Because whether it needs consent depends on what the person chose,
needs_consentmay now take the connection's settings (a zero-argument one is asked asbefore), and Postulo renews the token before a send or a test and hands it over under
ACCESS_TOKEN, now on the plugin surface."The operator has to register an application." Said plainly in the wiki, which starts by
asking which reader you are: your own mail server → nothing changes; Gmail with an app password →
nothing changes; Microsoft 365 → this section is for you, and it has a date on it. And the
framing you asked for, in those words: your choice of mail provider being supported, not
Postulo depending on one. Google and Microsoft are presets of three addresses and a scope; a
third is one row.
"A token expires on its own, and a password does not." This is the one I was most careful
with. A withdrawn grant makes the send fail with the provider's own words, and that failure is
recorded by the same path every other mail failure is — so #152's evidence sees it, and the
recovery lock stops counting email as a way back into an account once it has failed, instead of
believing in a route that closed quietly. A test drives exactly that: revoked refresh token →
send →
mail_failuresgoes up with the reason. And a consent that comes back without arefresh token is refused on the spot, because it would work for an hour and then stop for
good, on the path nobody watches.
"Deliverability and the recovery lock." #152 had already closed the configured-versus-working
gap you filed separately, so this plugged into it rather than rebuilding it.
"One package even if they stay two plugin kinds." The shared part — the token exchange — is
now one function,
consent.exchange(), free ofConnection, which both the instance's mail andevery connection use. The refresh goes through the guarded HTTP client either way.
"Nothing here removes username and password." Correct, and tested: an instance that never
opens the Email page resolves to
password, and the SMTP transport asks for no token.Two things worth knowing
connections:consent_callback, the address #148 already asks operators to register — so it isregistered once, not twice. The two flows are told apart by a signing salt each, so neither
state can be passed off as the other, and only the administrator who started a consent can
finish it. The Email page shows the exact address to register.
POSTULO_EMAIL_AUTH,…_OAUTH_PROVIDER,…_OAUTH_GRANT,…_OAUTH_TENANT,…_OAUTH_CLIENT_ID,…_OAUTH_CLIENT_SECRET. The secret is write-only on the page, as the password is; tokens liveencrypted beside it, never in a column or a cache.
Also
tests/test_mail_xoauth2.py(36) andtests/security/test_mail_consent.py(10 — who may startand finish, cross-flow states, tampered and expired states, nothing in plain text anywhere, not
in an export). Suite 4817 passed, 29 skipped; browser suite 86 passed, the Email page included in
the axe-core walk. Forty core strings and eight in the outbox plugin's own catalogue, in all 39
European languages. Wiki: Configuration → Signing in with a token;
docs/PLUGINS.mdanddocs/THREAT-MODEL.mdupdated;.env.examplelists the variables.What I could not do from here is try it against a real tenant — every provider call is faked
in the tests. The first real Microsoft 365 or Google sign-in is worth doing on ragnar before
December.
Shipped on
0.3.0, withmainkept level. 0.3.0 has no open issues left.