Port 465 cannot work: Postulo speaks STARTTLS and not implicit TLS #158

Closed
opened 2026-09-09 13:29:21 +00:00 by tiagoagueda · 1 comment
Owner

Observation

Found on the live instance, configuring a real provider. ssl0.ovh.net:465 with the
credentials that work: the connection test times out, with the checkbox on and with it off.
The credentials were never the problem.

port 465 tls=False: SMTPServerDisconnected: Connection unexpectedly closed: timed out
port 465 tls=True:  SMTPServerDisconnected: Connection unexpectedly closed: timed out
port 587 tls=True:  OK - Connected to ssl0.ovh.net:587 and signed in
port 25  tls=True:  TimeoutError: timed out

What exists

Two ways of putting TLS on an SMTP session, and Postulo does one of them.

STARTTLS (ports 587 and 25): connect in the clear, say EHLO, ask the server to upgrade
the socket. core/mail.py does exactly this, and notifications/smtp.py passes
use_tls= to Django's EmailBackend, which does the same.

Implicit TLS, or SMTPS (port 465): the handshake comes first, before a single byte of
SMTP. It needs smtplib.SMTP_SSL, and Django's backend needs use_ssl=True. Neither
appears anywhere in the codebase.

So on 465 Postulo opens a plaintext socket and waits for a greeting the server will never
send, because the server is waiting for a ClientHello. Ten seconds later the timeout fires.
Turning the STARTTLS checkbox on changes nothing: ehlo() has already failed before
starttls() would be reached.

The settings page offers a port box and a checkbox called STARTTLS, and nothing on it says
465 cannot work. An administrator enters their provider's documented settings and gets an
unexplained timeout.

What this asks for

Implicit TLS as a configuration Postulo supports, and — whatever is decided about that — a
timeout on 465 that says what is wrong instead of just how long it waited.

Worth being careful about

Two booleans that must not both be true. Django's SMTP backend raises ValueError when
use_tls and use_ssl are both set, and rightly: they are alternatives, not layers. A
checkbox each invites the invalid pair. One control with three states — none, STARTTLS,
implicit TLS — cannot express it, and reads better besides. That is a migration on
SiteSettings.email_use_tls, whose null=True currently means "not chosen", and it has to
keep meaning that: an existing instance's setting must not change under it.

The environment override has the same shape. POSTULO_EMAIL_USE_TLS is one variable and
one boolean. Whatever replaces the field has to keep that variable meaning what it means for
instances that already set it, and needs a companion or an encoding for the third state.

The default port should follow the choice, and must not overwrite a typed one. 587 for
STARTTLS, 465 for implicit, 25 for neither — as a suggestion when the box is untouched, not
as a correction of what somebody typed.

Say it before the timeout, not instead of implementing it. Ten seconds of nothing is the
worst version of this. Even once 465 works, somebody will point implicit TLS at 587 or the
other way round, and SMTPServerDisconnected after a wait is not an answer. The check
already has the shape for this: NO_STARTTLS names the actual problem when a server does not
offer the extension.

Not only OVH. 465 is what Gmail, Microsoft 365, Fastmail, Zoho and most shared hosting
document first. This is not an unusual configuration; it is arguably the more common one, and
it is the one with no downgrade window — there is no cleartext phase for an attacker to
strip.

#151 sits next to this and is not the same. That issue is about authentication Google
and Microsoft will accept (OAuth rather than a password). This is about the transport those
same providers document. Either can land without the other, and both are needed before
"configure your Gmail account" is a sentence Postulo can say.

The plugin's test() and the send path both need it. core/mail.py proves a
configuration; notifications/smtp.py uses it. Fixing one and not the other produces the
worst outcome available: a test that passes and mail that does not go.

## Observation Found on the live instance, configuring a real provider. `ssl0.ovh.net:465` with the credentials that work: the connection test times out, with the checkbox on and with it off. The credentials were never the problem. ``` port 465 tls=False: SMTPServerDisconnected: Connection unexpectedly closed: timed out port 465 tls=True: SMTPServerDisconnected: Connection unexpectedly closed: timed out port 587 tls=True: OK - Connected to ssl0.ovh.net:587 and signed in port 25 tls=True: TimeoutError: timed out ``` ## What exists Two ways of putting TLS on an SMTP session, and Postulo does one of them. **STARTTLS** (ports 587 and 25): connect in the clear, say `EHLO`, ask the server to upgrade the socket. `core/mail.py` does exactly this, and `notifications/smtp.py` passes `use_tls=` to Django's `EmailBackend`, which does the same. **Implicit TLS, or SMTPS** (port 465): the handshake comes first, before a single byte of SMTP. It needs `smtplib.SMTP_SSL`, and Django's backend needs `use_ssl=True`. Neither appears anywhere in the codebase. So on 465 Postulo opens a plaintext socket and waits for a greeting the server will never send, because the server is waiting for a `ClientHello`. Ten seconds later the timeout fires. Turning the STARTTLS checkbox on changes nothing: `ehlo()` has already failed before `starttls()` would be reached. The settings page offers a port box and a checkbox called *STARTTLS*, and nothing on it says 465 cannot work. An administrator enters their provider's documented settings and gets an unexplained timeout. ## What this asks for Implicit TLS as a configuration Postulo supports, and — whatever is decided about that — a timeout on 465 that says what is wrong instead of just how long it waited. ## Worth being careful about **Two booleans that must not both be true.** Django's SMTP backend raises `ValueError` when `use_tls` and `use_ssl` are both set, and rightly: they are alternatives, not layers. A checkbox each invites the invalid pair. One control with three states — *none*, *STARTTLS*, *implicit TLS* — cannot express it, and reads better besides. That is a migration on `SiteSettings.email_use_tls`, whose `null=True` currently means "not chosen", and it has to keep meaning that: an existing instance's setting must not change under it. **The environment override has the same shape.** `POSTULO_EMAIL_USE_TLS` is one variable and one boolean. Whatever replaces the field has to keep that variable meaning what it means for instances that already set it, and needs a companion or an encoding for the third state. **The default port should follow the choice, and must not overwrite a typed one.** 587 for STARTTLS, 465 for implicit, 25 for neither — as a suggestion when the box is untouched, not as a correction of what somebody typed. **Say it before the timeout, not instead of implementing it.** Ten seconds of nothing is the worst version of this. Even once 465 works, somebody will point implicit TLS at 587 or the other way round, and `SMTPServerDisconnected` after a wait is not an answer. The check already has the shape for this: `NO_STARTTLS` names the actual problem when a server does not offer the extension. **Not only OVH.** 465 is what Gmail, Microsoft 365, Fastmail, Zoho and most shared hosting document first. This is not an unusual configuration; it is arguably the more common one, and it is the one with no downgrade window — there is no cleartext phase for an attacker to strip. **#151 sits next to this and is not the same.** That issue is about *authentication* Google and Microsoft will accept (OAuth rather than a password). This is about the *transport* those same providers document. Either can land without the other, and both are needed before "configure your Gmail account" is a sentence Postulo can say. **The plugin's `test()` and the send path both need it.** `core/mail.py` proves a configuration; `notifications/smtp.py` uses it. Fixing one and not the other produces the worst outcome available: a test that passes and mail that does not go.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 13:29:21 +00:00
Author
Owner

core/mail.py opens an SMTP_SSL socket when the choice is implicit TLS, and
notifications/smtp.py hands Django use_ssl instead of use_tls. Both halves, because
fixing one and not the other produces the worst outcome available: a test that passes and
mail that does not go.

One control with three states, as the issue asked — none, STARTTLS, after connecting,
TLS from the first byte — so the pair Django refuses cannot be expressed at all.

The migration adds, carries, then removes. Django's autodetector wanted the drop first,
which is correct as a schema change and would have reset every instance to "nothing chosen",
falling through to a variable most people have never set. A test asserts that order, because
losing it would be silent.

POSTULO_EMAIL_USE_TLS still means what it means. overridden_by() now accepts more than
one variable per field, POSTULO_EMAIL_SECURITY wins where both are given, and
site.env_variables() exists so nothing that iterates the whole set meets a tuple where it
expected a name.

The timeout says which mismatch it is. 465 without implicit TLS, or 587 with it, gets a
sentence naming the actual problem in front of the original error. A port that is neither is
left alone, and the suggested port only fills a box left empty — a relay on a port of its own
is ordinary for a self-hosted instance.

22 tests in tests/test_mail_security.py, a wiki section under Configuration → Email, an
updated .env.example, and eleven strings in all 39 European catalogues.

One note for anyone upgrading with 465 already configured: the old boolean was false, so it
carries to None. The port is kept; the choice has to be set to TLS from the first byte
once, and then it works.

Shipped in 2e51871 on 0.3.0, with main kept level.

`core/mail.py` opens an `SMTP_SSL` socket when the choice is implicit TLS, and `notifications/smtp.py` hands Django `use_ssl` instead of `use_tls`. Both halves, because fixing one and not the other produces the worst outcome available: a test that passes and mail that does not go. **One control with three states**, as the issue asked — *none*, *STARTTLS, after connecting*, *TLS from the first byte* — so the pair Django refuses cannot be expressed at all. **The migration adds, carries, then removes.** Django's autodetector wanted the drop first, which is correct as a schema change and would have reset every instance to "nothing chosen", falling through to a variable most people have never set. A test asserts that order, because losing it would be silent. **`POSTULO_EMAIL_USE_TLS` still means what it means.** `overridden_by()` now accepts more than one variable per field, `POSTULO_EMAIL_SECURITY` wins where both are given, and `site.env_variables()` exists so nothing that iterates the whole set meets a tuple where it expected a name. **The timeout says which mismatch it is.** 465 without implicit TLS, or 587 with it, gets a sentence naming the actual problem in front of the original error. A port that is neither is left alone, and the suggested port only fills a box left empty — a relay on a port of its own is ordinary for a self-hosted instance. 22 tests in `tests/test_mail_security.py`, a wiki section under *Configuration → Email*, an updated `.env.example`, and eleven strings in all 39 European catalogues. One note for anyone upgrading with 465 already configured: the old boolean was `false`, so it carries to *None*. The port is kept; the choice has to be set to *TLS from the first byte* once, and then it works. Shipped in `2e51871` 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#158
No description provided.