SMTP that Google and Microsoft will still accept #151

Closed
opened 2026-09-09 11:06:21 +00:00 by tiagoagueda · 1 comment
Owner

Observation

i want that the smtp internal plugin (both sides) can handle newer authntification protocols
like the ones deployed by google or microsoft365

Why this has a date on it

Microsoft has published the timeline, and it is close:

  • end of December 2026 — SMTP AUTH Basic Authentication disabled by default for
    existing Exchange Online tenants, with administrators able to re-enable it;
  • after December 2026 — unavailable by default for new tenants;
  • second half of 2027 — a final removal date to be announced.

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

SMTPTransport and EmailNotifier both end up in Django's SMTP backend, which authenticates
one way: smtplib.SMTP.login(), username and password. There is no XOAUTH2 anywhere, and no
way 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. EmailBackend
calls login(); XOAUTH2 needs the SASL exchange with a base64 bearer string. A subclass that
overrides 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 a
transport 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.

## Observation > i want that the smtp internal plugin (both sides) can handle newer authntification protocols > like the ones deployed by google or microsoft365 ## Why this has a date on it Microsoft has published the timeline, and it is close: - **end of December 2026** — SMTP AUTH Basic Authentication **disabled by default** for existing Exchange Online tenants, with administrators able to re-enable it; - **after December 2026** — unavailable by default for new tenants; - **second half of 2027** — a final removal date to be announced. 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 `SMTPTransport` and `EmailNotifier` both end up in Django's SMTP backend, which authenticates one way: `smtplib.SMTP.login()`, username and password. There is no XOAUTH2 anywhere, and no way 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.** `EmailBackend` calls `login()`; XOAUTH2 needs the SASL exchange with a base64 bearer string. A subclass that overrides 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 a transport 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.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 11:06:21 +00:00
Author
Owner

Done in a7dcd884. XOAUTH2 works on both halves of the mail plugin — the instance's
transport 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 a
password, before it publishes the connection. So a token session is opened with no password
— Django then signs in with nothing — and GuardedBackend authenticates with XOAUTH2 on the
same socket straight afterwards, closing it again if the token is refused. No dependency:
core/mail_auth.py is the whole mechanism, and the one subtle part — smtplib base64-encodes
what 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."

  • The instance, from Server settings → Email, with two grants: Signed in once (an
    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.
  • The person, from Your own email: choose Google or Microsoft 365 under Sign in with, and
    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_consent may now take the connection's settings (a zero-argument one is asked as
    before), 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_failures goes up with the reason. And a consent that comes back without a
refresh 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 of Connection, which both the instance's mail and
every 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

  • One callback address for everything. The instance's consent comes back through
    connections:consent_callback, the address #148 already asks operators to register — so it is
    registered 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.
  • Everything is pinnable from the environment, like the host: 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 live
    encrypted beside it, never in a column or a cache.

Also

tests/test_mail_xoauth2.py (36) and tests/security/test_mail_consent.py (10 — who may start
and 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.md and
docs/THREAT-MODEL.md updated; .env.example lists 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, with main kept level. 0.3.0 has no open issues left.

Done in `a7dcd884`. XOAUTH2 works on both halves of the mail plugin — the instance's transport 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 a password, before it publishes the connection. So a token session is opened with *no* password — Django then signs in with nothing — and `GuardedBackend` authenticates with XOAUTH2 on the same socket straight afterwards, closing it again if the token is refused. **No dependency**: `core/mail_auth.py` is the whole mechanism, and the one subtle part — `smtplib` base64-encodes what 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."** - *The instance*, from **Server settings → Email**, with two grants: **Signed in once** (an 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. - *The person*, from **Your own email**: choose Google or Microsoft 365 under *Sign in with*, and 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_consent` may now take the connection's settings (a zero-argument one is asked as before), 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_failures` goes up with the reason. And a consent that comes back **without a refresh 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 of `Connection`, which both the instance's mail and every 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 - **One callback address for everything.** The instance's consent comes back through `connections:consent_callback`, the address #148 already asks operators to register — so it is registered 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. - **Everything is pinnable from the environment**, like the host: `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 live encrypted beside it, never in a column or a cache. ## Also `tests/test_mail_xoauth2.py` (36) and `tests/security/test_mail_consent.py` (10 — who may start and 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.md` and `docs/THREAT-MODEL.md` updated; `.env.example` lists 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`, with `main` kept level. **0.3.0 has no open issues left.**
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#151
No description provided.