From 0ec2b12e65a4134f415d95e0f96576cf273ba119 Mon Sep 17 00:00:00 2001 From: wangxiaolei Date: Wed, 14 Jan 2026 19:30:37 +0800 Subject: [PATCH] feat: allow pass hostname in docker env (#30975) --- api/.env.example | 3 +++ api/configs/feature/__init__.py | 6 +++++ api/libs/smtp.py | 27 ++++++++++--------- api/tests/integration_tests/.env.example | 2 ++ api/tests/unit_tests/libs/test_smtp_client.py | 6 ++--- .../unit_tests/tasks/test_mail_send_task.py | 8 +++--- docker/.env.example | 2 ++ docker/docker-compose.yaml | 1 + 8 files changed, 36 insertions(+), 19 deletions(-) diff --git a/api/.env.example b/api/.env.example index 8099c4a42a..15981c14b8 100644 --- a/api/.env.example +++ b/api/.env.example @@ -417,6 +417,8 @@ SMTP_USERNAME=123 SMTP_PASSWORD=abc SMTP_USE_TLS=true SMTP_OPPORTUNISTIC_TLS=false +# Optional: override the local hostname used for SMTP HELO/EHLO +SMTP_LOCAL_HOSTNAME= # Sendgid configuration SENDGRID_API_KEY= # Sentry configuration @@ -713,3 +715,4 @@ ANNOTATION_IMPORT_MAX_CONCURRENT=5 SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD=21 SANDBOX_EXPIRED_RECORDS_CLEAN_BATCH_SIZE=1000 SANDBOX_EXPIRED_RECORDS_RETENTION_DAYS=30 + diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index cf855b1cc0..cf71a33fa8 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -949,6 +949,12 @@ class MailConfig(BaseSettings): default=False, ) + SMTP_LOCAL_HOSTNAME: str | None = Field( + description="Override the local hostname used in SMTP HELO/EHLO. " + "Useful behind NAT or when the default hostname causes rejections.", + default=None, + ) + EMAIL_SEND_IP_LIMIT_PER_MINUTE: PositiveInt = Field( description="Maximum number of emails allowed to be sent from the same IP address in a minute", default=50, diff --git a/api/libs/smtp.py b/api/libs/smtp.py index 4044c6f7ed..6f82f1440a 100644 --- a/api/libs/smtp.py +++ b/api/libs/smtp.py @@ -3,6 +3,8 @@ import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from configs import dify_config + logger = logging.getLogger(__name__) @@ -19,20 +21,21 @@ class SMTPClient: self.opportunistic_tls = opportunistic_tls def send(self, mail: dict): - smtp = None + smtp: smtplib.SMTP | None = None + local_host = dify_config.SMTP_LOCAL_HOSTNAME try: - if self.use_tls: - if self.opportunistic_tls: - smtp = smtplib.SMTP(self.server, self.port, timeout=10) - # Send EHLO command with the HELO domain name as the server address - smtp.ehlo(self.server) - smtp.starttls() - # Resend EHLO command to identify the TLS session - smtp.ehlo(self.server) - else: - smtp = smtplib.SMTP_SSL(self.server, self.port, timeout=10) + if self.use_tls and not self.opportunistic_tls: + # SMTP with SSL (implicit TLS) + smtp = smtplib.SMTP_SSL(self.server, self.port, timeout=10, local_hostname=local_host) else: - smtp = smtplib.SMTP(self.server, self.port, timeout=10) + # Plain SMTP or SMTP with STARTTLS (explicit TLS) + smtp = smtplib.SMTP(self.server, self.port, timeout=10, local_hostname=local_host) + + assert smtp is not None + if self.use_tls and self.opportunistic_tls: + smtp.ehlo(self.server) + smtp.starttls() + smtp.ehlo(self.server) # Only authenticate if both username and password are non-empty if self.username and self.password and self.username.strip() and self.password.strip(): diff --git a/api/tests/integration_tests/.env.example b/api/tests/integration_tests/.env.example index acc268f1d4..39effbab58 100644 --- a/api/tests/integration_tests/.env.example +++ b/api/tests/integration_tests/.env.example @@ -103,6 +103,8 @@ SMTP_USERNAME=123 SMTP_PASSWORD=abc SMTP_USE_TLS=true SMTP_OPPORTUNISTIC_TLS=false +# Optional: override the local hostname used for SMTP HELO/EHLO +SMTP_LOCAL_HOSTNAME= # Sentry configuration SENTRY_DSN= diff --git a/api/tests/unit_tests/libs/test_smtp_client.py b/api/tests/unit_tests/libs/test_smtp_client.py index fcee01ca00..042bc15643 100644 --- a/api/tests/unit_tests/libs/test_smtp_client.py +++ b/api/tests/unit_tests/libs/test_smtp_client.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch import pytest @@ -17,7 +17,7 @@ def test_smtp_plain_success(mock_smtp_cls: MagicMock): client = SMTPClient(server="smtp.example.com", port=25, username="", password="", _from="noreply@example.com") client.send(_mail()) - mock_smtp_cls.assert_called_once_with("smtp.example.com", 25, timeout=10) + mock_smtp_cls.assert_called_once_with("smtp.example.com", 25, timeout=10, local_hostname=ANY) mock_smtp.sendmail.assert_called_once() mock_smtp.quit.assert_called_once() @@ -38,7 +38,7 @@ def test_smtp_tls_opportunistic_success(mock_smtp_cls: MagicMock): ) client.send(_mail()) - mock_smtp_cls.assert_called_once_with("smtp.example.com", 587, timeout=10) + mock_smtp_cls.assert_called_once_with("smtp.example.com", 587, timeout=10, local_hostname=ANY) assert mock_smtp.ehlo.call_count == 2 mock_smtp.starttls.assert_called_once() mock_smtp.login.assert_called_once_with("user", "pass") diff --git a/api/tests/unit_tests/tasks/test_mail_send_task.py b/api/tests/unit_tests/tasks/test_mail_send_task.py index 736871d784..5cb933e3f3 100644 --- a/api/tests/unit_tests/tasks/test_mail_send_task.py +++ b/api/tests/unit_tests/tasks/test_mail_send_task.py @@ -9,7 +9,7 @@ This module tests the mail sending functionality including: """ import smtplib -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch import pytest @@ -151,7 +151,7 @@ class TestSMTPIntegration: client.send(mail_data) # Assert - mock_smtp_ssl.assert_called_once_with("smtp.example.com", 465, timeout=10) + mock_smtp_ssl.assert_called_once_with("smtp.example.com", 465, timeout=10, local_hostname=ANY) mock_server.login.assert_called_once_with("user@example.com", "password123") mock_server.sendmail.assert_called_once() mock_server.quit.assert_called_once() @@ -181,7 +181,7 @@ class TestSMTPIntegration: client.send(mail_data) # Assert - mock_smtp.assert_called_once_with("smtp.example.com", 587, timeout=10) + mock_smtp.assert_called_once_with("smtp.example.com", 587, timeout=10, local_hostname=ANY) mock_server.ehlo.assert_called() mock_server.starttls.assert_called_once() assert mock_server.ehlo.call_count == 2 # Before and after STARTTLS @@ -213,7 +213,7 @@ class TestSMTPIntegration: client.send(mail_data) # Assert - mock_smtp.assert_called_once_with("smtp.example.com", 25, timeout=10) + mock_smtp.assert_called_once_with("smtp.example.com", 25, timeout=10, local_hostname=ANY) mock_server.login.assert_called_once() mock_server.sendmail.assert_called_once() mock_server.quit.assert_called_once() diff --git a/docker/.env.example b/docker/.env.example index 9a3a7239c6..627a3a23da 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -968,6 +968,8 @@ SMTP_USERNAME= SMTP_PASSWORD= SMTP_USE_TLS=true SMTP_OPPORTUNISTIC_TLS=false +# Optional: override the local hostname used for SMTP HELO/EHLO +SMTP_LOCAL_HOSTNAME= # Sendgid configuration SENDGRID_API_KEY= diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index fcb07dda36..6439cccf47 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -425,6 +425,7 @@ x-shared-env: &shared-api-worker-env SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_USE_TLS: ${SMTP_USE_TLS:-true} SMTP_OPPORTUNISTIC_TLS: ${SMTP_OPPORTUNISTIC_TLS:-false} + SMTP_LOCAL_HOSTNAME: ${SMTP_LOCAL_HOSTNAME:-} SENDGRID_API_KEY: ${SENDGRID_API_KEY:-} INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: ${INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH:-4000} INVITE_EXPIRY_HOURS: ${INVITE_EXPIRY_HOURS:-72}