Skip to content

Commit

Permalink
feat: apply SMTP limits to connections
Browse files Browse the repository at this point in the history
  • Loading branch information
s-aga-r committed Jan 9, 2025
1 parent 777f5b1 commit 07f78e9
Show file tree
Hide file tree
Showing 4 changed files with 46 additions and 33 deletions.
12 changes: 11 additions & 1 deletion mail/mail/doctype/mail_settings/mail_settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"column_break_9j6r",
"smtp_session_duration",
"smtp_inactivity_timeout",
"smtp_cleanup_interval",
"spamassassin_tab",
"section_break_hgqa",
"enable_spamd",
Expand Down Expand Up @@ -354,12 +355,21 @@
"fieldname": "limit_outbound_message_section",
"fieldtype": "Section Break",
"label": "Message"
},
{
"default": "60",
"description": "The interval for cleaning up stale connections.",
"fieldname": "smtp_cleanup_interval",
"fieldtype": "Int",
"label": "Cleanup Interval (Seconds)",
"non_negative": 1,
"reqd": 1
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-01-09 12:17:12.266240",
"modified": "2025-01-09 12:32:06.490297",
"modified_by": "Administrator",
"module": "Mail",
"name": "Mail Settings",
Expand Down
1 change: 1 addition & 0 deletions mail/mail/doctype/mail_settings/mail_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def validate(self) -> None:
self.validate_spf_host()

def on_update(self) -> None:
frappe.cache.delete_value("smtp_limits")
frappe.cache.delete_value("root_domain_name")

if self.has_value_changed("root_domain_name"):
Expand Down
52 changes: 20 additions & 32 deletions mail/smtp.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from smtplib import SMTP, SMTP_SSL, SMTPServerDisconnected
from threading import Lock, Thread

from mail.utils.cache import get_smtp_limits


class SMTPConnectionLimitError(Exception):
pass
Expand All @@ -21,12 +23,12 @@ def __init__(
use_tls: bool,
inactivity_timeout: int,
session_duration: int,
max_emails: int,
max_messages: int,
) -> None:
self.__created_at = time.time()
self.__inactivity_timeout = inactivity_timeout
self.__session_duration = session_duration
self.__max_emails = max_emails
self.__max_messages = max_messages
self.__email_count = 0

self.session = self.__create_connection(host, port, username, password, use_ssl, use_tls)
Expand Down Expand Up @@ -59,7 +61,7 @@ def is_valid(self) -> bool:
expired = (
current_time - self.last_used > self.__inactivity_timeout
or current_time - self.__created_at > self.__session_duration
or self.__email_count >= self.__max_emails
or self.__email_count >= self.__max_messages
)
return not expired and self.is_active()

Expand Down Expand Up @@ -87,14 +89,19 @@ def __new__(cls, *args, **kwargs) -> "SMTPConnectionPool":
cls._instance._running = False
return cls._instance

def __init__(self, max_connections: int) -> None:
def __init__(self) -> None:
if hasattr(self, "_initialized"):
return

self.max_connections = max_connections
smtp_limits = get_smtp_limits()
self.max_connections = smtp_limits["max_connections"]
self.max_messages = smtp_limits["max_messages"]
self.session_duration = smtp_limits["session_duration"]
self.inactivity_timeout = smtp_limits["inactivity_timeout"]
self.cleanup_interval = smtp_limits["cleanup_interval"]

self._initialized = True
self._running = True
self._cleanup_interval = 60
self._cleanup_thread = None
self._initialize_cleanup_thread()

Expand All @@ -106,9 +113,6 @@ def get_connection(
password: str,
use_ssl: bool,
use_tls: bool,
inactivity_timeout: int,
session_duration: int,
max_emails: int,
) -> "SMTPConnection":
key = (host, port, username)
with self._pool_lock:
Expand Down Expand Up @@ -136,9 +140,9 @@ def get_connection(
password,
use_ssl,
use_tls,
inactivity_timeout,
session_duration,
max_emails,
self.inactivity_timeout,
self.session_duration,
self.max_messages,
)

raise SMTPConnectionLimitError(
Expand Down Expand Up @@ -173,7 +177,7 @@ def _initialize_cleanup_thread(self) -> None:

def _cleanup_stale_connections(self) -> None:
while self._running:
time.sleep(self._cleanup_interval)
time.sleep(self.cleanup_interval)
with self._pool_lock:
for key, pool in self._pools.items():
valid_connections = Queue(self.max_connections)
Expand Down Expand Up @@ -208,21 +212,14 @@ def __init__(
password: str,
use_ssl: bool = False,
use_tls: bool = False,
inactivity_timeout: int = 300,
session_duration: int = 600,
max_emails: int = 10,
max_connections: int = 5,
) -> None:
self._pool = SMTPConnectionPool(max_connections)
self._pool = SMTPConnectionPool()
self._host = host
self._port = port
self._username = username
self._password = password
self._use_ssl = use_ssl
self._use_tls = use_tls
self._inactivity_timeout = inactivity_timeout
self._session_duration = session_duration
self._max_emails = max_emails
self._connection = None

def __enter__(self) -> SMTP | SMTP_SSL:
Expand All @@ -233,9 +230,6 @@ def __enter__(self) -> SMTP | SMTP_SSL:
self._password,
self._use_ssl,
self._use_tls,
self._inactivity_timeout,
self._session_duration,
self._max_emails,
)
return self._connection.session

Expand All @@ -255,15 +249,9 @@ def smtp_server(
password: str,
use_ssl: bool = False,
use_tls: bool = False,
inactivity_timeout: int = 300,
session_duration: int = 600,
max_emails: int = 10,
max_connections: int = 5,
) -> Generator[type[SMTP] | type[SMTP_SSL], None, None]:
_pool = SMTPConnectionPool(max_connections)
_connection: SMTPConnection = _pool.get_connection(
host, port, username, password, use_ssl, use_tls, inactivity_timeout, session_duration, max_emails
)
_pool = SMTPConnectionPool()
_connection: SMTPConnection = _pool.get_connection(host, port, username, password, use_ssl, use_tls)

try:
yield _connection.session
Expand Down
14 changes: 14 additions & 0 deletions mail/utils/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ def generator() -> str | None:
return frappe.cache.get_value("root_domain_name", generator)


def get_smtp_limits() -> dict:
def generator() -> dict:
mail_settings = frappe.get_cached_doc("Mail Settings")
return {
"max_connections": mail_settings.smtp_max_connections,
"max_messages": mail_settings.smtp_max_messages,
"session_duration": mail_settings.smtp_session_duration,
"inactivity_timeout": mail_settings.smtp_inactivity_timeout,
"cleanup_interval": mail_settings.smtp_cleanup_interval,
}

return frappe.cache.get_value("smtp_limits", generator)


def get_postmaster_for_domain(domain_name: str) -> str:
"""Returns the postmaster for the domain."""

Expand Down

0 comments on commit 07f78e9

Please sign in to comment.