Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/interface/mail/InterfaceApiMailSend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

Expand All @@ -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:
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions app/utils/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
79 changes: 76 additions & 3 deletions tests/test_interface/test_mail/test_InterfaceApiMailSend.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
# 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


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.
The modules are set directly so tests can inject fake/mock modules.
"""
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()


Expand Down Expand Up @@ -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 ==========
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down
Loading