From 6bbee129acb30556b77eaedd189fc01b408714df Mon Sep 17 00:00:00 2001 From: tkeriven Date: Tue, 15 Sep 2026 14:28:17 +0200 Subject: [PATCH] OP#2653 : check maximum recipient in send APIs --- app/interface/mail/InterfaceApiMailSend.py | 24 ++++++ app/utils/errors.py | 1 + .../test_mail/test_InterfaceApiMailSend.py | 79 ++++++++++++++++++- 3 files changed, 101 insertions(+), 3 deletions(-) diff --git a/app/interface/mail/InterfaceApiMailSend.py b/app/interface/mail/InterfaceApiMailSend.py index e2b54dcc..ddabad5f 100644 --- a/app/interface/mail/InterfaceApiMailSend.py +++ b/app/interface/mail/InterfaceApiMailSend.py @@ -9,6 +9,7 @@ from app.utils.exceptions import RequestException from app.utils.api.ApiBaseResponse import create_api_base_response from app.utils import constants as cs +from app.utils import errors as err from app.utils.logger.logger import logger_api if TYPE_CHECKING: @@ -38,6 +39,22 @@ def __init__( self.mail_outgoing_module = ModuleMailOutgoing(user, self.mail_settings) + def _validate_recipient_count(self, mail_data: dict) -> None: + """Check that to + cc + bcc does not exceed the domain's max recipient setting. + + SOGO_D_MAIL_MAX_RECIPIENT == 0 means no limit. + + :param mail_data: Mail data with 'to', 'cc', 'bcc' lists + :type mail_data: dict + :raises RequestException: If the total number of recipients exceeds the domain limit + """ + max_recipient = self.mail_settings.SOGO_D_MAIL_MAX_RECIPIENT + if max_recipient <= 0: + return + recipient_count = len(mail_data.get("to") or []) + len(mail_data.get("cc") or []) + len(mail_data.get("bcc") or []) + if recipient_count > max_recipient: + raise RequestException(err.ERROR_MAIL_MAX_RECIPIENT_EXCEEDED.m, err.ERROR_MAIL_MAX_RECIPIENT_EXCEEDED) + def save_draft(self, account_id: str, mail_data: dict, key: str | None = None, close: bool = False) -> tuple[dict, int]: """Save a mail as a draft in the account's Drafts folder. @@ -57,6 +74,7 @@ def save_draft(self, account_id: str, mail_data: dict, key: str | None = None, c :rtype: tuple[dict, int] """ try: + self._validate_recipient_count(mail_data) result = self.mail_module.save_draft(account_id, mail_data, key, close=close) return create_api_base_response(result) except RequestException as ex: @@ -78,6 +96,12 @@ def send_mail(self, account_id: str, mail_data: dict, key: str | None = None) -> :return: A tuple of (API response dict, status code) :rtype: tuple[dict, int] """ + try: + self._validate_recipient_count(mail_data) + except RequestException as ex: + logger_api.error("Request exception in send_mail for user %s, account %s: %s", self.user.uid, account_id, str(ex)) + return create_api_base_response(None, ex.error) + if key is not None: try: self.mail_module.validate_tmp_draft_key(key) diff --git a/app/utils/errors.py b/app/utils/errors.py index 5ccdaabf..46a3ff5d 100644 --- a/app/utils/errors.py +++ b/app/utils/errors.py @@ -148,6 +148,7 @@ def __init__(self, c:str, m:str, h:int = HTTPStatus.INTERNAL_SERVER_ERROR): ERROR_IDENTITIES_CUSTOM_NAME_FORBIDDEN = E("S000333", "Custom name in identities is forbidden for your domain", HTTPStatus.FORBIDDEN) ERROR_IDENTITIES_CUSTOM_REPLY_TO_FORBIDDEN = E("S000334", "Custom reply-to email in identities is forbidden for your domain", HTTPStatus.FORBIDDEN) ERROR_SIGNATURE_SIZE_EXCEEDED = E("S000335", "Signature size exceeds the maximum allowed limit for your domain", HTTPStatus.FORBIDDEN) +ERROR_MAIL_MAX_RECIPIENT_EXCEEDED = E("S000338", "Number of recipients (to + cc + bcc) exceeds the maximum allowed for your domain", HTTPStatus.FORBIDDEN) #SMTP ERROR_SMTP_CONNECTION_FAILED = E("S001400", "SMTP connection failed", HTTPStatus.SERVICE_UNAVAILABLE) diff --git a/tests/test_interface/test_mail/test_InterfaceApiMailSend.py b/tests/test_interface/test_mail/test_InterfaceApiMailSend.py index 0e662e5f..47f8a876 100644 --- a/tests/test_interface/test_mail/test_InterfaceApiMailSend.py +++ b/tests/test_interface/test_mail/test_InterfaceApiMailSend.py @@ -1,5 +1,6 @@ # pylint: disable=invalid-sequence-index from app.interface.mail.InterfaceApiMailSend import InterfaceApiMailSend +from app.config.settings.DomainSettings import MailSettingsObj from app.utils.exceptions import RequestException from app.utils import errors as err @@ -7,7 +8,7 @@ class InterfaceApiMailSendWithInjectedConf(InterfaceApiMailSend): """Subclass of InterfaceApiMailSend that allows injecting modules directly for testing.""" - def __init__(self, mail_module, mail_outgoing_module): + def __init__(self, mail_module, mail_outgoing_module, mail_settings=None): """Initialize with injected modules for testing. Does not call the parent __init__ to avoid requiring all the parameters it needs. @@ -15,6 +16,7 @@ def __init__(self, mail_module, mail_outgoing_module): """ self.mail_module = mail_module # noqa: SLF001 self.mail_outgoing_module = mail_outgoing_module # noqa: SLF001 + self.mail_settings = mail_settings if mail_settings is not None else MailSettingsObj() self.user = _FakeUser() @@ -104,13 +106,13 @@ def send_mail(self, account_id, mail_data, extra_headers=None): return self.send_mail_result -def make_interface(fake_mail_module=None, fake_outgoing_module=None): +def make_interface(fake_mail_module=None, fake_outgoing_module=None, mail_settings=None): """Create an InterfaceApiMailSendWithInjectedConf with the given fake modules.""" if fake_mail_module is None: fake_mail_module = FakeModuleMail() if fake_outgoing_module is None: fake_outgoing_module = FakeModuleMailOutgoing() - return InterfaceApiMailSendWithInjectedConf(fake_mail_module, fake_outgoing_module) + return InterfaceApiMailSendWithInjectedConf(fake_mail_module, fake_outgoing_module, mail_settings=mail_settings) # ========== Tests for save_draft ========== @@ -153,6 +155,46 @@ def test_save_draft_success_with_close(): assert fake_mail.save_draft_args == ("0", mail_data, "abc123", True) +def test_save_draft_recipient_limit_exceeded(): + """Test that saving a draft over the domain's max recipient count is rejected with 403.""" + fake_mail = FakeModuleMail() + mail_settings = MailSettingsObj({"SOGO_D_MAIL_MAX_RECIPIENT": 2}) + interface = make_interface(fake_mail_module=fake_mail, mail_settings=mail_settings) + + mail_data = {"to": ["a@example.com", "b@example.com"], "cc": ["c@example.com"], "bcc": []} + result, status_code = interface.save_draft(account_id="0", mail_data=mail_data) + + assert result["error_code"] == "S000338" + assert status_code == 403 + assert fake_mail.save_draft_args is None # save_draft must NOT have been called + + +def test_save_draft_recipient_limit_not_exceeded(): + """Test that saving a draft at exactly the domain's max recipient count is allowed.""" + fake_mail = FakeModuleMail() + mail_settings = MailSettingsObj({"SOGO_D_MAIL_MAX_RECIPIENT": 3}) + interface = make_interface(fake_mail_module=fake_mail, mail_settings=mail_settings) + + mail_data = {"to": ["a@example.com", "b@example.com"], "cc": ["c@example.com"], "bcc": []} + result, status_code = interface.save_draft(account_id="0", mail_data=mail_data) + + assert status_code == 200 + assert fake_mail.save_draft_args is not None + + +def test_save_draft_recipient_limit_zero_means_unlimited(): + """Test that SOGO_D_MAIL_MAX_RECIPIENT == 0 disables the check.""" + fake_mail = FakeModuleMail() + mail_settings = MailSettingsObj({"SOGO_D_MAIL_MAX_RECIPIENT": 0}) + interface = make_interface(fake_mail_module=fake_mail, mail_settings=mail_settings) + + mail_data = {"to": [f"user{i}@example.com" for i in range(50)]} + result, status_code = interface.save_draft(account_id="0", mail_data=mail_data) + + assert status_code == 200 + assert fake_mail.save_draft_args is not None + + def test_save_draft_module_error(): """Test error handling when save_draft raises RequestException.""" fake_mail = FakeModuleMail() @@ -219,6 +261,37 @@ def test_send_mail_with_key_merges_draft_attachments(): assert "draft_attach.pdf" in filenames +def test_send_mail_recipient_limit_exceeded(): + """Test that sending a mail over the domain's max recipient count is rejected with 403.""" + fake_mail = FakeModuleMail() + fake_outgoing = FakeModuleMailOutgoing() + mail_settings = MailSettingsObj({"SOGO_D_MAIL_MAX_RECIPIENT": 2}) + interface = make_interface(fake_mail_module=fake_mail, fake_outgoing_module=fake_outgoing, mail_settings=mail_settings) + + mail_data = {"to": ["a@example.com"], "cc": ["b@example.com"], "bcc": ["c@example.com"]} + result, status_code = interface.send_mail(account_id="0", mail_data=mail_data) + + assert result["error_code"] == "S000338" + assert status_code == 403 + assert fake_outgoing.send_mail_args is None # send_mail must NOT have been called + + +def test_send_mail_recipient_limit_exceeded_with_key_skips_draft_processing(): + """Test that the recipient limit is enforced before any tmp_draft key handling.""" + fake_mail = FakeModuleMail() + fake_outgoing = FakeModuleMailOutgoing() + mail_settings = MailSettingsObj({"SOGO_D_MAIL_MAX_RECIPIENT": 1}) + interface = make_interface(fake_mail_module=fake_mail, fake_outgoing_module=fake_outgoing, mail_settings=mail_settings) + + mail_data = {"to": ["a@example.com", "b@example.com"]} + result, status_code = interface.send_mail(account_id="0", mail_data=mail_data, key="abc123") + + assert result["error_code"] == "S000338" + assert status_code == 403 + assert fake_mail.validate_tmp_draft_key_args is None + assert fake_outgoing.send_mail_args is None + + def test_send_mail_invalid_key_returns_error(): """Test that an invalid tmp_draft key aborts sending.""" fake_mail = FakeModuleMail()