Both connection modes carry the same message to the same relay account, and the difference is where TLS begins rather than what gets sent. One opens in cleartext and upgrades in band; the other is encrypted from the first byte. That single difference decides what a strict relay policy will accept, what a middlebox can observe, and which error message you will spend an afternoon reading. Here is how I choose between them when an application on a private network hands a message to an internal submission service.

Where the two modes actually diverge

The task is identical in both cases: authenticate as an approved account, name an allowed sender, list recipients, and wait for the relay to accept responsibility. What changes is the order of operations. Port 587 starts as a plaintext SMTP session, advertises STARTTLS in its EHLO response, and upgrades only if the client asks. Port 465 expects a TLS handshake before any SMTP command is spoken.

Submission and relay are different jobs, which is why the port numbers are not interchangeable habits. RFC 6409 describes message submission as its own service, and RFC 8314 covers the implicit-TLS variant that most modern relays expose on 465. If your relay documents one of the two, that documentation outranks any preference you have from a previous employer.

What STARTTLS on 587 gives you

The upgrade happens inside the SMTP conversation, so the relay can advertise it and the client can refuse to continue without it. In Python, starttls() raises an exception when the server does not offer the extension, which turns a downgrade attempt into a visible failure instead of a silent cleartext login. That behaviour is the main argument for the mode, and it is worth relying on deliberately rather than by accident.

The exposure window is small but real: the initial EHLO exchange is readable, and nothing before the upgrade carries credentials. On a private path between an application subnet and an internal relay, that window is usually acceptable. On a path you do not control, it is the reason some teams insist on the other mode.

An interception device that strips the STARTTLS capability produces a failed submission, not a fallback. That is the outcome you want, and it is also the fastest way to discover that something is rewriting SMTP on the path.

What implicit TLS on 465 changes

There is no plaintext phase at all. The client performs a TLS handshake as soon as the socket opens, validates the certificate against its trust store, and only then exchanges EHLO, AUTH and the message. Nothing can downgrade the session because there is no cleartext state to negotiate from, which removes an entire class of interception failure.

The trade is diagnosability. A relay that answers on 465 with plaintext SMTP, or a load balancer that terminates TLS with the wrong certificate name, fails at the handshake and gives you an SSL error rather than a mail error. When that happens, the problem is below the application, and no amount of message construction will fix it.

One submission function, two transports

The code barely differs between the modes, which is why the choice should be made from relay policy rather than from library convenience. Export the settings first, then run a single script that selects the transport from the configured port.

export SMTP_HOST=smtp.relay.internal
export SMTP_PORT=587
export SMTP_USER=svc-notify
export [email protected]
export [email protected]
export SMTP_PASSWORD="$(cat /run/secrets/smtp_password)"
import os, smtplib, ssl
from email.message import EmailMessage

ctx = ssl.create_default_context()
msg = EmailMessage()
msg["From"] = os.environ["MAIL_FROM"]
msg["To"] = os.environ["MAIL_TEST_TO"]
msg["Subject"] = "relay submission test"
msg.set_content("One authorized test message from the app.")

host = os.environ["SMTP_HOST"]
port = int(os.environ.get("SMTP_PORT", "587"))

if port == 465:
    client = smtplib.SMTP_SSL(host, port, context=ctx, timeout=15)
else:
    client = smtplib.SMTP(host, port, timeout=15)
    client.ehlo()
    client.starttls(context=ctx)
    client.ehlo()

with client:
    client.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
    refused = client.send_message(msg)

print("refused:", refused)

create_default_context() verifies the chain and the hostname, so a private CA has to be installed in the trust store rather than bypassed with an unverified context. send_message() returns a dictionary of recipients the relay declined; an empty dictionary means it accepted all of them. The fifteen-second timeout is a starting point, not a tuned value for your network.

Keep the password out of the image and out of the logs. A secret mounted at runtime and read into the environment is fine; echoing the environment into a log line is not, and neither is a debug trace of the AUTH exchange.

Choose by relay policy and path, not by habit

Three conditions settle it. First, what the relay actually advertises: a service that only listens on 587 with STARTTLS cannot be reached on 465 no matter how much you prefer implicit TLS. Second, whether the path between the application and the relay is trusted enough to accept a readable EHLO exchange. Third, whether anything in the middle rewrites or proxies SMTP, because that decides whether the downgrade risk is theoretical or live.

When the relay offers both, I take 465 for a service that runs unattended and 587 for one where a person is watching the first deployment, purely because the failure messages point at different layers. That preference is mine and it is not a policy.

Whatever you pick, record the port, the trust material and the sender domain alongside the deployment. Then send one authorized test message and confirm the relay accepted it, keeping the submission result and any later delivery report as separate records. A successful hand-off to the relay is the end of the application's responsibility, not proof that the message reached an inbox.

Lahatsoratra mifandraika