Skip to content

fix(core): support implicit TLS (SMTPS / port 465) for outgoing email - #2510

Merged
wh1te909 merged 1 commit into
amidaware:developfrom
jptamayo76:fix/smtp-implicit-tls-465-smtps
Jul 23, 2026
Merged

fix(core): support implicit TLS (SMTPS / port 465) for outgoing email#2510
wh1te909 merged 1 commit into
amidaware:developfrom
jptamayo76:fix/smtp-implicit-tls-465-smtps

Conversation

@jptamayo76

Copy link
Copy Markdown
Contributor

What

Add support for implicit TLS (SMTPS, port 465) when Tactical sends email through CoreSettings.send_mail.

First of all — thanks for this project, it's been rock solid for us. While wiring up alert emails against an SMTP provider that only exposes port 465 I hit a wall, dug into it, and this is the small fix that made it work.

The problem

send_mail always opens the connection with smtplib.SMTP:

with smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=20) as server:
    if self.smtp_requires_auth:
        server.ehlo()
        server.starttls()
        ...

smtplib.SMTP speaks plaintext first and only upgrades the channel in-band with STARTTLS. That's exactly right for ports 587 and 25.

Port 465 works differently: it's implicit TLS (a.k.a. SMTPS). The server expects the TLS handshake immediately, the instant the socket opens — before any plaintext SMTP command. So connecting to a 465 endpoint with smtplib.SMTP sends a plaintext EHLO into a socket the server is waiting to negotiate TLS on. The result is a hang until the 20s timeout, then a failure — 465 is effectively unusable today, regardless of the smtp_port value the admin sets.

Python's stdlib already draws this line clearly: smtplib.SMTP for plaintext/STARTTLS, and smtplib.SMTP_SSL for implicit TLS.

The fix

Pick the connection class from the port — the minimal, config-free change:

  • smtp_port == 465 → open with smtplib.SMTP_SSL(...) so the socket is TLS-wrapped from the start. A ssl.create_default_context() is passed so the server certificate chain is validated.
  • Any other port (587 / 25) → unchanged: smtplib.SMTP + STARTTLS, exactly as before.
  • On the 465 path STARTTLS is skipped — the channel is already encrypted, and calling it would raise "TLS already active".

No new setting, no migration, no serializer or UI change. The security invariant is preserved and even slightly strengthened: an authenticated login() never runs over a cleartext channel — it's STARTTLS on 587/25, or an already-encrypted socket on 465.

use_ssl = self.smtp_port == 465
if use_ssl:
    server = smtplib.SMTP_SSL(
        self.smtp_host, self.smtp_port, timeout=20,
        context=ssl.create_default_context(),
    )
else:
    server = smtplib.SMTP(self.smtp_host, self.smtp_port, timeout=20)

with server:
    if self.smtp_requires_auth:
        server.ehlo()
        if not use_ssl:
            server.starttls()
        server.login(self.smtp_host_user, self.smtp_host_password)
        server.send_message(msg)
        server.quit()
    else:
        ...

The Gmail relay branch and the no-auth relay branch keep their existing behavior; the no-auth path now also benefits from implicit TLS automatically when the admin points it at a 465 endpoint.

Testing

  • Configured an account against a 465-only provider: authenticated send over SMTP_SSL succeeds (TLS 1.3, cert validated). The same account on 587 keeps working via STARTTLS.
  • python -m py_compile on the module is clean.
  • Existing send_mail unit tests mock the method itself (@patch("core.models.CoreSettings.send_mail")), so they aren't affected by the transport change.

Related

Addresses the SSL side of #1621 (SMTP server with STARTTLS or SSL support). STARTTLS was already covered for authenticated hosts; this adds the missing implicit-TLS / 465 path.

send_mail always opened the connection with smtplib.SMTP, which speaks
plaintext first and only upgrades via STARTTLS. On port 465 the server
expects the TLS handshake immediately, so the connection would hang or
fail. Select smtplib.SMTP_SSL when smtp_port == 465 (implicit TLS) and
keep smtplib.SMTP + STARTTLS for 587/25. STARTTLS is skipped on the
465 path since the channel is already encrypted.
@CLAassistant

CLAassistant commented Jul 13, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@wh1te909
wh1te909 merged commit 850604b into amidaware:develop Jul 23, 2026
2 checks passed
@wh1te909

Copy link
Copy Markdown
Member

nice one, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants