Skip to content

Commit 84aa8ca

Browse files
authored
Merge pull request ckan#9186 from TomeCirun/fix/smtp-host-port-parsing
Fix SMTP TLS handshake failure when port is embedded in hostname
2 parents a6816e5 + 05e90ca commit 84aa8ca

File tree

3 files changed

+72
-1
lines changed

3 files changed

+72
-1
lines changed

changes/9186.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix SMTP TLS error with embedded port

ckan/lib/mailer.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import socket
88
import logging
99
import mimetypes
10+
from urllib.parse import urlparse
1011
from time import time
1112
from typing import Any, Iterable, Optional, Tuple, Union, IO
1213

@@ -97,8 +98,12 @@ def _mail_recipient(
9798
smtp_user = config.get('smtp.user')
9899
smtp_password = config.get('smtp.password')
99100

101+
host, port = _parse_smtp_server(smtp_server)
102+
if not host:
103+
raise MailerException('SMTP server hostname is not configured')
104+
100105
try:
101-
smtp_connection = smtplib.SMTP(smtp_server)
106+
smtp_connection = smtplib.SMTP(host, port)
102107
except (socket.error, smtplib.SMTPConnectError) as e:
103108
log.exception(e)
104109
raise MailerException('SMTP server could not be connected to: "%s" %s'
@@ -310,3 +315,23 @@ def verify_reset_link(user: model.User, key: Optional[str]) -> bool:
310315
if not user.reset_key or len(user.reset_key) < 5:
311316
return False
312317
return key.strip() == user.reset_key
318+
319+
320+
def _parse_smtp_server(smtp_server: str) -> tuple[str | None, int]:
321+
"""Parse SMTP server that may include port.
322+
323+
Examples:
324+
'smtp.example.com' -> ('smtp.example.com', 0)
325+
'smtp.example.com:587' -> ('smtp.example.com', 587)
326+
'[::1]:587' -> ('::1', 587)
327+
"""
328+
default_port = 0
329+
330+
if "://" not in smtp_server:
331+
smtp_server = f"smtp://{smtp_server}"
332+
333+
parsed = urlparse(smtp_server)
334+
host = parsed.hostname
335+
port = parsed.port or default_port
336+
337+
return host, port

ckan/tests/lib/test_mailer.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,48 @@ def test_mail_user_with_attachments_no_media_type_provided(self, mail_server):
413413
"goals.png", "image/png",
414414
]:
415415
assert item in msg[3]
416+
417+
@pytest.mark.ckan_config("smtp.server", ":")
418+
def test_invalid_smtp_server_colon_only(self):
419+
test_email = {
420+
"recipient_name": "Bob",
421+
"recipient_email": "b@example.com",
422+
"subject": "Meeting",
423+
"body": "Test",
424+
"headers": {},
425+
}
426+
427+
with pytest.raises(mailer.MailerException) as exc:
428+
mailer.mail_recipient(**test_email)
429+
430+
assert "SMTP server hostname is not configured" in str(exc.value)
431+
432+
@pytest.mark.ckan_config("smtp.server", "localhost:1025")
433+
def test_smtp_server_with_port(self, mail_server):
434+
test_email = {
435+
"recipient_name": "Bob",
436+
"recipient_email": "b@example.com",
437+
"subject": "Meeting",
438+
"body": "Test",
439+
"headers": {},
440+
}
441+
442+
mailer.mail_recipient(**test_email)
443+
444+
msgs = mail_server.get_smtp_messages()
445+
assert len(msgs) == 1
446+
447+
@pytest.mark.ckan_config("smtp.server", "[::1]:1025")
448+
def test_smtp_server_ipv6_with_port(self, mail_server):
449+
test_email = {
450+
"recipient_name": "Bob",
451+
"recipient_email": "b@example.com",
452+
"subject": "Meeting",
453+
"body": "Test",
454+
"headers": {},
455+
}
456+
457+
mailer.mail_recipient(**test_email)
458+
459+
msgs = mail_server.get_smtp_messages()
460+
assert len(msgs) == 1

0 commit comments

Comments
 (0)