From e8b4b9b48e454ffebeeff06a437a2304c0e1c5a0 Mon Sep 17 00:00:00 2001 From: Clemens Schwaighofer Date: Mon, 27 Oct 2025 11:19:38 +0900 Subject: [PATCH] Add send email class --- src/corelibs/email_handling/__init__.py | 0 src/corelibs/email_handling/send_email.py | 199 +++ tests/unit/email_handling/test_send_email.py | 1249 ++++++++++++++++++ 3 files changed, 1448 insertions(+) create mode 100644 src/corelibs/email_handling/__init__.py create mode 100644 src/corelibs/email_handling/send_email.py create mode 100644 tests/unit/email_handling/test_send_email.py diff --git a/src/corelibs/email_handling/__init__.py b/src/corelibs/email_handling/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/corelibs/email_handling/send_email.py b/src/corelibs/email_handling/send_email.py new file mode 100644 index 0000000..df88ad6 --- /dev/null +++ b/src/corelibs/email_handling/send_email.py @@ -0,0 +1,199 @@ +""" +Send email wrapper +""" + +import smtplib +from email.message import EmailMessage +from typing import TYPE_CHECKING, Any +if TYPE_CHECKING: + from corelibs.logging_handling.log import Logger + + +class SendEmail: + """ + send emails based on a template to a list of receivers + """ + + def __init__( + self, + log: "Logger", + settings: dict[str, Any], + template: dict[str, str], + from_email: str, + combined_send: bool = True, + receivers: list[str] | None = None, + data: list[dict[str, str]] | None = None, + ): + """ + init send email class + + Args: + template (dict): Dictionary with body and subject + from_email (str): from email as "Name" + combined_send (bool): True for sending as one set for all receivers + receivers (list): list of emails to send to + data (dict): data to replace in template + args (Namespace): _description_ + """ + self.log = log + self.settings = settings + # internal settings + self.template = template + self.from_email = from_email + self.combined_send = combined_send + self.receivers = receivers + self.data = data + + def send_email( + self, + data: list[dict[str, str]] | None, + receivers: list[str] | None, + template: dict[str, str] | None = None, + from_email: str | None = None, + combined_send: bool | None = None, + test_only: bool | None = None + ): + """ + build email and send + + Arguments: + data {list[dict[str, str]] | None} -- _description_ + receivers {list[str] | None} -- _description_ + combined_send {bool | None} -- _description_ + + Keyword Arguments: + template {dict[str, str] | None} -- _description_ (default: {None}) + from_email {str | None} -- _description_ (default: {None}) + + Raises: + ValueError: _description_ + ValueError: _description_ + """ + if data is None and self.data is not None: + data = self.data + if data is None: + raise ValueError("No replace data set, cannot send email") + if receivers is None and self.receivers is not None: + receivers = self.receivers + if receivers is None: + raise ValueError("No receivers list set, cannot send email") + if combined_send is None: + combined_send = self.combined_send + if test_only is not None: + self.settings['test'] = test_only + + if template is None: + template = self.template + if from_email is None: + from_email = self.from_email + + if not template['subject'] or not template['body']: + raise ValueError("Both Subject and Body must be set") + + self.log.debug( + "[EMAIL]:\n" + f"Subject: {template['subject']}\n" + f"Body: {template['body']}\n" + f"From: {from_email}\n" + f"Combined send: {combined_send}\n" + f"Receivers: {receivers}\n" + f"Replace data: {data}" + ) + + # send email + self.send_email_list( + self.prepare_email_content( + from_email, template, data + ), + receivers, + combined_send, + test_only + ) + + def prepare_email_content( + self, + from_email: str, + template: dict[str, str], + data: list[dict[str, str]], + ) -> list[EmailMessage]: + """ + prepare email for sending + + Args: + template (dict): template data for this email + data (dict): data to replace in email + + Returns: + list: Email Message Objects as list + """ + _subject = "" + _body = "" + msg: list[EmailMessage] = [] + for replace in data: + _subject = template["subject"] + _body = template["body"] + for key, value in replace.items(): + _subject = _subject.replace(f"{{{{{key}}}}}", value) + _body = _body.replace(f"{{{{{key}}}}}", value) + # create a simple email and add subhect, from email + msg_email = EmailMessage() + # msg.set_content(_body, charset='utf-8', cte='quoted-printable') + msg_email.set_content(_body, charset="utf-8") + msg_email["Subject"] = _subject + msg_email["From"] = from_email + # push to array for sening + msg.append(msg_email) + return msg + + def send_email_list( + self, + email: list[EmailMessage], receivers: list[str], + combined_send: bool | None = None, + test_only: bool | None = None + ): + """ + send email to receivers list + + Args: + email (list): Email Message object with set obdy, subject, from as list + receivers (array): email receivers list as array + combined_send (bool): True for sending as one set for all receivers + """ + + if test_only is not None: + self.settings['test'] = test_only + + # localhost (postfix does the rest) + smtp = None + smtp_host = self.settings.get('smtp_host', "localhost") + try: + smtp = smtplib.SMTP(smtp_host) + except ConnectionRefusedError as e: + self.log.error("Could not open SMTP connection to: %s, %s", smtp_host, e) + # loop over messages and then over recievers + for msg in email: + if combined_send is True: + msg["To"] = ", ".join(receivers) + if not self.settings.get('test'): + if smtp is not None: + smtp.send_message(msg, msg["From"], receivers) + else: + self.log.info(f"[EMAIL] Test, not sending email\n{msg}") + else: + for receiver in receivers: + # send to + self.log.debug(f"===> Send to: {receiver}") + if "To" in msg: + msg.replace_header("To", receiver) + else: + msg["To"] = receiver + if not self.settings.get('test'): + if smtp is not None: + smtp.send_message(msg) + else: + self.log.info(f"[EMAIL] Test, not sending email\n{msg}") + # close smtp + if smtp is not None: + smtp.quit() + +# __END__ diff --git a/tests/unit/email_handling/test_send_email.py b/tests/unit/email_handling/test_send_email.py new file mode 100644 index 0000000..b8fc106 --- /dev/null +++ b/tests/unit/email_handling/test_send_email.py @@ -0,0 +1,1249 @@ +""" +Unit tests for email_handling.send_email module +""" + +# pylint: disable=redefined-outer-name + +from typing import Any +from unittest.mock import MagicMock, patch +from email.message import EmailMessage +import pytest + +from corelibs.email_handling.send_email import SendEmail + + +@pytest.fixture +def mock_logger() -> MagicMock: + """Create a mock logger for testing""" + logger = MagicMock() + logger.debug = MagicMock() + logger.info = MagicMock() + logger.warning = MagicMock() + logger.error = MagicMock() + return logger + + +@pytest.fixture +def basic_settings() -> dict[str, str | bool]: + """Create basic settings for testing""" + return { + 'smtp_host': 'localhost', + 'test': False + } + + +@pytest.fixture +def test_settings() -> dict[str, str | bool]: + """Create test mode settings""" + return { + 'smtp_host': 'localhost', + 'test': True + } + + +@pytest.fixture +def basic_template() -> dict[str, str]: + """Create a basic email template""" + return { + 'subject': 'Test Subject - {{name}}', + 'body': 'Hello {{name}},\n\nThis is a test email for {{purpose}}.' + } + + +@pytest.fixture +def simple_template() -> dict[str, str]: + """Create a simple template without placeholders""" + return { + 'subject': 'Simple Subject', + 'body': 'Simple body text.' + } + + +@pytest.fixture +def receivers_list() -> list[str]: + """Create a list of email receivers""" + return ['test1@example.com', 'test2@example.com', 'test3@example.com'] + + +@pytest.fixture +def single_receiver() -> list[str]: + """Create a single receiver list""" + return ['user@example.com'] + + +@pytest.fixture +def replacement_data() -> list[dict[str, str]]: + """Create replacement data for templates""" + return [ + {'name': 'John', 'purpose': 'testing'}, + {'name': 'Jane', 'purpose': 'verification'} + ] + + +@pytest.fixture +def single_replacement_data() -> list[dict[str, str]]: + """Create single replacement data""" + return [{'name': 'Alice', 'purpose': 'demo'}] + + +class TestSendEmailInit: + """Test cases for SendEmail initialization""" + + def test_init_basic( + self, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + basic_template: dict[str, str], + receivers_list: list[str] + ) -> None: + """Test basic initialization with all parameters""" + data = [{'name': 'Test'}] + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=basic_template, + from_email='sender@example.com', + combined_send=True, + receivers=receivers_list, + data=data + ) + + assert send_email.log == mock_logger + assert send_email.settings == basic_settings + assert send_email.template == basic_template + assert send_email.from_email == 'sender@example.com' + assert send_email.combined_send is True + assert send_email.receivers == receivers_list + assert send_email.data == data + + def test_init_minimal( + self, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + basic_template: dict[str, str] + ) -> None: + """Test initialization with minimal parameters""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=basic_template, + from_email='sender@example.com' + ) + + assert send_email.log == mock_logger + assert send_email.settings == basic_settings + assert send_email.template == basic_template + assert send_email.from_email == 'sender@example.com' + assert send_email.combined_send is True # default + assert send_email.receivers is None + assert send_email.data is None + + def test_init_with_separate_send( + self, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + basic_template: dict[str, str] + ) -> None: + """Test initialization with combined_send set to False""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=basic_template, + from_email='sender@example.com', + combined_send=False + ) + + assert send_email.combined_send is False + + def test_init_with_name_format_email( + self, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + basic_template: dict[str, str] + ) -> None: + """Test initialization with email in 'Name ' format""" + from_email = '"Test Sender" ' + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=basic_template, + from_email=from_email + ) + + assert send_email.from_email == from_email + + +class TestPrepareEmailContent: + """Test cases for prepare_email_content method""" + + def test_prepare_single_email( + self, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + basic_template: dict[str, str], + single_replacement_data: list[dict[str, str]] + ) -> None: + """Test preparing a single email""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=basic_template, + from_email='sender@example.com' + ) + + messages = send_email.prepare_email_content( + 'sender@example.com', + basic_template, + single_replacement_data + ) + + assert len(messages) == 1 + assert isinstance(messages[0], EmailMessage) + assert messages[0]['Subject'] == 'Test Subject - Alice' + assert messages[0]['From'] == 'sender@example.com' + assert 'Hello Alice' in messages[0].get_content() + assert 'demo' in messages[0].get_content() + + def test_prepare_multiple_emails( + self, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + basic_template: dict[str, str], + replacement_data: list[dict[str, str]] + ) -> None: + """Test preparing multiple emails""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=basic_template, + from_email='sender@example.com' + ) + + messages = send_email.prepare_email_content( + 'sender@example.com', + basic_template, + replacement_data + ) + + assert len(messages) == 2 + assert all(isinstance(msg, EmailMessage) for msg in messages) + + # Check first email + assert messages[0]['Subject'] == 'Test Subject - John' + assert 'Hello John' in messages[0].get_content() + assert 'testing' in messages[0].get_content() + + # Check second email + assert messages[1]['Subject'] == 'Test Subject - Jane' + assert 'Hello Jane' in messages[1].get_content() + assert 'verification' in messages[1].get_content() + + def test_prepare_email_no_replacements( + self, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + simple_template: dict[str, str] + ) -> None: + """Test preparing email with no placeholder replacements""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=simple_template, + from_email='sender@example.com' + ) + + data: list[dict[str, str]] = [{}] # Empty replacement data + messages = send_email.prepare_email_content( + 'sender@example.com', + simple_template, + data + ) + + assert len(messages) == 1 + assert messages[0]['Subject'] == 'Simple Subject' + assert messages[0].get_content() == 'Simple body text.\n' + + def test_prepare_email_with_special_characters( + self, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool] + ) -> None: + """Test preparing email with special characters in template""" + template = { + 'subject': 'Special: {{symbol}}', + 'body': 'Amount: {{amount}}\nEmail: {{email}}' + } + data = [{'symbol': '€$¥', 'amount': '1,000.50', 'email': 'test@example.com'}] + + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=template, + from_email='sender@example.com' + ) + + messages = send_email.prepare_email_content('sender@example.com', template, data) + + assert messages[0]['Subject'] == 'Special: €$¥' + assert 'Amount: 1,000.50' in messages[0].get_content() + assert 'Email: test@example.com' in messages[0].get_content() + + def test_prepare_email_utf8_encoding( + self, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool] + ) -> None: + """Test preparing email with UTF-8 characters""" + template = { + 'subject': 'UTF-8 Test: {{name}}', + 'body': 'Hello {{name}}, こんにちは 你好' + } + data = [{'name': 'Müller'}] + + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=template, + from_email='sender@example.com' + ) + + messages = send_email.prepare_email_content('sender@example.com', template, data) + + assert messages[0]['Subject'] == 'UTF-8 Test: Müller' + assert 'こんにちは' in messages[0].get_content() + assert '你好' in messages[0].get_content() + + def test_prepare_email_empty_replacement_value( + self, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool] + ) -> None: + """Test preparing email with empty replacement values""" + template = { + 'subject': 'Subject {{value}}', + 'body': 'Body {{value}}' + } + data = [{'value': ''}] + + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=template, + from_email='sender@example.com' + ) + + messages = send_email.prepare_email_content('sender@example.com', template, data) + + assert messages[0]['Subject'] == 'Subject ' + assert 'Body ' in messages[0].get_content() + + def test_prepare_email_missing_placeholder( + self, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool] + ) -> None: + """Test preparing email when placeholder is missing from data""" + template = { + 'subject': 'Subject {{missing}}', + 'body': 'Body {{missing}}' + } + data = [{'present': 'value'}] + + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=template, + from_email='sender@example.com' + ) + + messages = send_email.prepare_email_content('sender@example.com', template, data) + + # Placeholders should remain unchanged if no replacement data + assert '{{missing}}' in messages[0]['Subject'] + assert '{{missing}}' in messages[0].get_content() + + +class TestSendEmailList: + """Test cases for send_email_list method""" + + @patch('smtplib.SMTP') + def test_send_combined_email( + self, + mock_smtp: MagicMock, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + receivers_list: list[str] + ) -> None: + """Test sending combined email to all receivers""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template={'subject': 'Test', 'body': 'Test'}, + from_email='sender@example.com' + ) + + # Create test email messages + msg = EmailMessage() + msg.set_content('Test body') + msg['Subject'] = 'Test Subject' + msg['From'] = 'sender@example.com' + + send_email.send_email_list([msg], receivers_list, combined_send=True) + + # Verify SMTP was called + mock_smtp.assert_called_once_with('localhost') + smtp_instance = mock_smtp.return_value + smtp_instance.send_message.assert_called_once() + + # Verify To header contains all receivers + call_args = smtp_instance.send_message.call_args + sent_msg = call_args[0][0] + assert sent_msg['To'] == ', '.join(receivers_list) + + smtp_instance.quit.assert_called_once() + + @patch('smtplib.SMTP') + def test_send_separate_emails( + self, + mock_smtp: MagicMock, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + receivers_list: list[str] + ) -> None: + """Test sending separate email to each receiver""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template={'subject': 'Test', 'body': 'Test'}, + from_email='sender@example.com' + ) + + msg = EmailMessage() + msg.set_content('Test body') + msg['Subject'] = 'Test Subject' + msg['From'] = 'sender@example.com' + + send_email.send_email_list([msg], receivers_list, combined_send=False) + + # Verify SMTP connection + mock_smtp.assert_called_once_with('localhost') + smtp_instance = mock_smtp.return_value + + # Should send 3 separate emails (one per receiver) + assert smtp_instance.send_message.call_count == 3 + smtp_instance.quit.assert_called_once() + + @patch('smtplib.SMTP') + def test_send_multiple_messages_combined( + self, + mock_smtp: MagicMock, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + receivers_list: list[str] + ) -> None: + """Test sending multiple messages in combined mode""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template={'subject': 'Test', 'body': 'Test'}, + from_email='sender@example.com' + ) + + messages: list[EmailMessage] = [] + for i in range(3): + msg = EmailMessage() + msg.set_content(f'Test body {i}') + msg['Subject'] = f'Test Subject {i}' + msg['From'] = 'sender@example.com' + messages.append(msg) + + send_email.send_email_list(messages, receivers_list, combined_send=True) + + smtp_instance = mock_smtp.return_value + # Should send 3 messages (one per message in list) + assert smtp_instance.send_message.call_count == 3 + + def test_send_email_test_mode( + self, + mock_logger: MagicMock, + test_settings: dict[str, str | bool], + single_receiver: list[str] + ) -> None: + """Test sending email in test mode (should not actually send)""" + send_email = SendEmail( + log=mock_logger, + settings=test_settings, + template={'subject': 'Test', 'body': 'Test'}, + from_email='sender@example.com' + ) + + msg = EmailMessage() + msg.set_content('Test body') + msg['Subject'] = 'Test Subject' + msg['From'] = 'sender@example.com' + + with patch('smtplib.SMTP') as mock_smtp: + send_email.send_email_list([msg], single_receiver, combined_send=True) + + # SMTP should still be created but send_message should not be called + smtp_instance = mock_smtp.return_value + smtp_instance.send_message.assert_not_called() + + # Should log that it's in test mode + mock_logger.info.assert_called() + call_args = mock_logger.info.call_args[0][0] + assert 'Test' in call_args + assert 'not sending' in call_args + + def test_send_email_test_mode_override( + self, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + single_receiver: list[str] + ) -> None: + """Test overriding test mode via parameter""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template={'subject': 'Test', 'body': 'Test'}, + from_email='sender@example.com' + ) + + msg = EmailMessage() + msg.set_content('Test body') + msg['Subject'] = 'Test Subject' + msg['From'] = 'sender@example.com' + + with patch('smtplib.SMTP') as mock_smtp: + send_email.send_email_list([msg], single_receiver, combined_send=True, test_only=True) + + smtp_instance = mock_smtp.return_value + smtp_instance.send_message.assert_not_called() + + @patch('smtplib.SMTP') + def test_send_email_custom_smtp_host( + self, + mock_smtp: MagicMock, + mock_logger: MagicMock + ) -> None: + """Test sending email with custom SMTP host""" + settings = {'smtp_host': 'mail.example.com', 'test': False} + send_email = SendEmail( + log=mock_logger, + settings=settings, + template={'subject': 'Test', 'body': 'Test'}, + from_email='sender@example.com' + ) + + msg = EmailMessage() + msg.set_content('Test body') + msg['Subject'] = 'Test' + msg['From'] = 'sender@example.com' + + send_email.send_email_list([msg], ['test@example.com'], combined_send=True) + + mock_smtp.assert_called_once_with('mail.example.com') + + @patch('smtplib.SMTP') + def test_send_email_connection_error( + self, + mock_smtp: MagicMock, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool] + ) -> None: + """Test handling SMTP connection error""" + mock_smtp.side_effect = ConnectionRefusedError("Connection refused") + + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template={'subject': 'Test', 'body': 'Test'}, + from_email='sender@example.com' + ) + + msg = EmailMessage() + msg.set_content('Test body') + msg['Subject'] = 'Test' + msg['From'] = 'sender@example.com' + + # Should handle error gracefully + send_email.send_email_list([msg], ['test@example.com'], combined_send=True) + + # Should log error + mock_logger.error.assert_called() + error_args = mock_logger.error.call_args[0] + assert 'SMTP' in error_args[0] + assert 'localhost' in error_args + + @patch('smtplib.SMTP') + def test_send_email_separate_updates_to_header( + self, + mock_smtp: MagicMock, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + receivers_list: list[str] + ) -> None: + """Test that To header is properly updated when sending separately""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template={'subject': 'Test', 'body': 'Test'}, + from_email='sender@example.com' + ) + + msg = EmailMessage() + msg.set_content('Test body') + msg['Subject'] = 'Test Subject' + msg['From'] = 'sender@example.com' + + send_email.send_email_list([msg], receivers_list, combined_send=False) + + smtp_instance = mock_smtp.return_value + + # Should send 3 separate emails (one per receiver) + assert smtp_instance.send_message.call_count == 3 + + # The To header will be the last receiver since the same message object is reused + # This is how the actual implementation works + + +class TestSendEmail: + """Test cases for the main send_email method""" + + @patch('smtplib.SMTP') + def test_send_email_basic( + self, + mock_smtp: MagicMock, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + basic_template: dict[str, str], + single_receiver: list[str], + single_replacement_data: list[dict[str, str]] + ) -> None: + """Test basic email sending""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=basic_template, + from_email='sender@example.com' + ) + + send_email.send_email( + data=single_replacement_data, + receivers=single_receiver + ) + + # Verify debug logging was called + mock_logger.debug.assert_called() + + # Verify SMTP was used + mock_smtp.assert_called_once() + smtp_instance = mock_smtp.return_value + smtp_instance.send_message.assert_called_once() + + @patch('smtplib.SMTP') + def test_send_email_with_init_data( + self, + mock_smtp: MagicMock, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + basic_template: dict[str, str], + single_receiver: list[str], + single_replacement_data: list[dict[str, str]] + ) -> None: + """Test sending email using data from initialization""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=basic_template, + from_email='sender@example.com', + receivers=single_receiver, + data=single_replacement_data + ) + + # Call without data and receivers (should use init values) + send_email.send_email(data=None, receivers=None) + + smtp_instance = mock_smtp.return_value + smtp_instance.send_message.assert_called_once() + + def test_send_email_no_data_raises_error( + self, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + basic_template: dict[str, str], + single_receiver: list[str] + ) -> None: + """Test that sending without data raises ValueError""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=basic_template, + from_email='sender@example.com' + ) + + with pytest.raises(ValueError, match="No replace data set"): + send_email.send_email(data=None, receivers=single_receiver) + + def test_send_email_no_receivers_raises_error( + self, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + basic_template: dict[str, str], + single_replacement_data: list[dict[str, str]] + ) -> None: + """Test that sending without receivers raises ValueError""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=basic_template, + from_email='sender@example.com' + ) + + with pytest.raises(ValueError, match="No receivers list set"): + send_email.send_email(data=single_replacement_data, receivers=None) + + def test_send_email_empty_subject_raises_error( + self, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + single_receiver: list[str], + single_replacement_data: list[dict[str, str]] + ) -> None: + """Test that empty subject raises ValueError""" + template = {'subject': '', 'body': 'Test body'} + + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=template, + from_email='sender@example.com' + ) + + with pytest.raises(ValueError, match="Both Subject and Body must be set"): + send_email.send_email(data=single_replacement_data, receivers=single_receiver) + + def test_send_email_empty_body_raises_error( + self, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + single_receiver: list[str], + single_replacement_data: list[dict[str, str]] + ) -> None: + """Test that empty body raises ValueError""" + template = {'subject': 'Test subject', 'body': ''} + + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=template, + from_email='sender@example.com' + ) + + with pytest.raises(ValueError, match="Both Subject and Body must be set"): + send_email.send_email(data=single_replacement_data, receivers=single_receiver) + + @patch('smtplib.SMTP') + def test_send_email_override_template( + self, + mock_smtp: MagicMock, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + basic_template: dict[str, str], + single_receiver: list[str], + single_replacement_data: list[dict[str, str]] + ) -> None: + """Test overriding template at send time""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=basic_template, + from_email='sender@example.com' + ) + + override_template = { + 'subject': 'Override Subject', + 'body': 'Override Body' + } + + send_email.send_email( + data=single_replacement_data, + receivers=single_receiver, + template=override_template + ) + + smtp_instance = mock_smtp.return_value + sent_msg = smtp_instance.send_message.call_args[0][0] + assert sent_msg['Subject'] == 'Override Subject' + + @patch('smtplib.SMTP') + def test_send_email_override_from_email( + self, + mock_smtp: MagicMock, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + basic_template: dict[str, str], + single_receiver: list[str], + single_replacement_data: list[dict[str, str]] + ) -> None: + """Test overriding from_email at send time""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=basic_template, + from_email='sender@example.com' + ) + + send_email.send_email( + data=single_replacement_data, + receivers=single_receiver, + from_email='override@example.com' + ) + + smtp_instance = mock_smtp.return_value + sent_msg = smtp_instance.send_message.call_args[0][0] + assert sent_msg['From'] == 'override@example.com' + + @patch('smtplib.SMTP') + def test_send_email_override_combined_send( + self, + mock_smtp: MagicMock, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + basic_template: dict[str, str], + receivers_list: list[str], + replacement_data: list[dict[str, str]] + ) -> None: + """Test overriding combined_send at send time""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=basic_template, + from_email='sender@example.com', + combined_send=True + ) + + send_email.send_email( + data=replacement_data, + receivers=receivers_list, + combined_send=False + ) + + smtp_instance = mock_smtp.return_value + # Should send separately (2 messages * 3 receivers = 6 sends) + assert smtp_instance.send_message.call_count == 6 + + @patch('smtplib.SMTP') + def test_send_email_test_mode_parameter( + self, + mock_smtp: MagicMock, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + basic_template: dict[str, str], + single_receiver: list[str], + single_replacement_data: list[dict[str, str]] + ) -> None: + """Test test_only parameter""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=basic_template, + from_email='sender@example.com' + ) + + send_email.send_email( + data=single_replacement_data, + receivers=single_receiver, + test_only=True + ) + + smtp_instance = mock_smtp.return_value + smtp_instance.send_message.assert_not_called() + + @patch('smtplib.SMTP') + def test_send_email_debug_logging( + self, + mock_smtp: MagicMock, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + basic_template: dict[str, str], + single_receiver: list[str], + single_replacement_data: list[dict[str, str]] + ) -> None: + """Test that debug logging contains expected information""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=basic_template, + from_email='sender@example.com' + ) + + send_email.send_email( + data=single_replacement_data, + receivers=single_receiver + ) + + # Check debug was called + mock_logger.debug.assert_called() + debug_msg = mock_logger.debug.call_args[0][0] + + # Verify debug message contains key information + assert 'EMAIL' in debug_msg + assert 'Subject' in debug_msg + assert 'Body' in debug_msg + assert 'From' in debug_msg + assert 'Receivers' in debug_msg + + +class TestIntegration: + """Integration tests for complete workflows""" + + @patch('smtplib.SMTP') + def test_full_workflow_single_email(self, mock_smtp: MagicMock, mock_logger: MagicMock) -> None: + """Test complete workflow for single email""" + settings = {'smtp_host': 'localhost', 'test': False} + template = { + 'subject': 'Welcome {{name}}!', + 'body': 'Dear {{name}},\n\nWelcome to our service!' + } + data = [{'name': 'John Doe'}] + receivers = ['john.doe@example.com'] + + send_email = SendEmail( + log=mock_logger, + settings=settings, + template=template, + from_email='noreply@example.com', + combined_send=True, + receivers=receivers, + data=data + ) + + send_email.send_email(data=None, receivers=None) + + smtp_instance = mock_smtp.return_value + smtp_instance.send_message.assert_called_once() + + sent_msg = smtp_instance.send_message.call_args[0][0] + assert sent_msg['Subject'] == 'Welcome John Doe!' + assert 'Dear John Doe' in sent_msg.get_content() + assert sent_msg['From'] == 'noreply@example.com' + + @patch('smtplib.SMTP') + def test_full_workflow_bulk_combined(self, mock_smtp: MagicMock, mock_logger: MagicMock) -> None: + """Test complete workflow for bulk combined email""" + settings = {'smtp_host': 'localhost', 'test': False} + template = { + 'subject': 'Newsletter', + 'body': 'Hello everyone!' + } + data: list[dict[Any, Any]] = [{}] + receivers = ['user1@example.com', 'user2@example.com', 'user3@example.com'] + + send_email = SendEmail( + log=mock_logger, + settings=settings, + template=template, + from_email='newsletter@example.com', + combined_send=True + ) + + send_email.send_email(data=data, receivers=receivers) + + smtp_instance = mock_smtp.return_value + smtp_instance.send_message.assert_called_once() + + sent_msg = smtp_instance.send_message.call_args[0][0] + assert sent_msg['To'] == ', '.join(receivers) + + @patch('smtplib.SMTP') + def test_full_workflow_personalized_separate(self, mock_smtp: MagicMock, mock_logger: MagicMock) -> None: + """Test complete workflow for personalized separate emails""" + settings = {'smtp_host': 'localhost', 'test': False} + template = { + 'subject': 'Personal message for {{name}}', + 'body': 'Hi {{name}}, your account balance is {{balance}}.' + } + data = [ + {'name': 'Alice', 'balance': '$100'}, + {'name': 'Bob', 'balance': '$200'}, + ] + receivers = ['alice@example.com', 'bob@example.com'] + + send_email = SendEmail( + log=mock_logger, + settings=settings, + template=template, + from_email='accounts@example.com', + combined_send=False + ) + + send_email.send_email(data=data, receivers=receivers) + + smtp_instance = mock_smtp.return_value + # 2 messages * 2 receivers = 4 sends + assert smtp_instance.send_message.call_count == 4 + + +class TestEdgeCases: + """Test edge cases and boundary conditions""" + + @patch('smtplib.SMTP') + def test_empty_receivers_list( + self, + mock_smtp: MagicMock, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + basic_template: dict[str, str] + ) -> None: + """Test with empty receivers list""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=basic_template, + from_email='sender@example.com' + ) + + # Empty list will still try to send (with empty To list in combined mode) + send_email.send_email(data=[{}], receivers=[]) + + smtp_instance = mock_smtp.return_value + # Will call send_message with empty receivers list in combined mode + smtp_instance.send_message.assert_called_once() + + @patch('smtplib.SMTP') + def test_empty_data_list( + self, + mock_smtp: MagicMock, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + simple_template: dict[str, str] + ) -> None: + """Test with empty data list""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=simple_template, + from_email='sender@example.com' + ) + + # Empty data list means no emails to prepare + send_email.send_email(data=[], receivers=['test@example.com']) + + smtp_instance = mock_smtp.return_value + smtp_instance.send_message.assert_not_called() + + def test_missing_smtp_host_setting(self, mock_logger: MagicMock, basic_template: dict[str, str]) -> None: + """Test with missing smtp_host in settings (should default to localhost)""" + settings = {'test': False} # No smtp_host + + send_email = SendEmail( + log=mock_logger, + settings=settings, + template=basic_template, + from_email='sender@example.com' + ) + + with patch('smtplib.SMTP') as mock_smtp: + send_email.send_email_list( + [EmailMessage()], + ['test@example.com'], + combined_send=True + ) + + # Should default to localhost + mock_smtp.assert_called_once_with('localhost') + + @patch('smtplib.SMTP') + def test_large_recipient_list( + self, + mock_smtp: MagicMock, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + simple_template: dict[str, str] + ) -> None: + """Test with large number of recipients""" + receivers = [f'user{i}@example.com' for i in range(100)] + + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=simple_template, + from_email='sender@example.com' + ) + + send_email.send_email(data=[{}], receivers=receivers, combined_send=True) + + smtp_instance = mock_smtp.return_value + smtp_instance.send_message.assert_called_once() + + sent_msg = smtp_instance.send_message.call_args[0][0] + assert len(sent_msg['To'].split(', ')) == 100 + + @patch('smtplib.SMTP') + def test_multiple_placeholders_same_key( + self, + mock_smtp: MagicMock, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool] + ) -> None: + """Test template with same placeholder appearing multiple times""" + template = { + 'subject': '{{name}} - {{name}}', + 'body': 'Hello {{name}}, welcome {{name}}!' + } + data = [{'name': 'Test'}] + + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=template, + from_email='sender@example.com' + ) + + messages = send_email.prepare_email_content('sender@example.com', template, data) + + assert messages[0]['Subject'] == 'Test - Test' + assert 'Hello Test, welcome Test!' in messages[0].get_content() + + @patch('smtplib.SMTP') + def test_very_long_email_body( + self, + mock_smtp: MagicMock, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool] + ) -> None: + """Test with very long email body""" + long_text = 'Lorem ipsum ' * 1000 + template = { + 'subject': 'Long email', + 'body': long_text + } + + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=template, + from_email='sender@example.com' + ) + + send_email.send_email(data=[{}], receivers=['test@example.com']) + + smtp_instance = mock_smtp.return_value + smtp_instance.send_message.assert_called_once() + + +class TestParametrized: + """Parametrized tests for comprehensive coverage""" + + @pytest.mark.parametrize("combined_send", [True, False]) + @patch('smtplib.SMTP') + def test_combined_send_variations( + self, + mock_smtp: MagicMock, + combined_send: bool, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + simple_template: dict[str, str] + ) -> None: + """Test with both combined and separate send modes""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=simple_template, + from_email='sender@example.com', + combined_send=combined_send + ) + + receivers = ['user1@example.com', 'user2@example.com'] + send_email.send_email(data=[{}], receivers=receivers) + + smtp_instance = mock_smtp.return_value + if combined_send: + smtp_instance.send_message.assert_called_once() + else: + assert smtp_instance.send_message.call_count == 2 + + @pytest.mark.parametrize("test_mode", [True, False]) + @patch('smtplib.SMTP') + def test_test_mode_variations( + self, + mock_smtp: MagicMock, + test_mode: bool, + mock_logger: MagicMock, + simple_template: dict[str, str] + ) -> None: + """Test with test mode enabled and disabled""" + settings = {'smtp_host': 'localhost', 'test': test_mode} + + send_email = SendEmail( + log=mock_logger, + settings=settings, + template=simple_template, + from_email='sender@example.com' + ) + + send_email.send_email(data=[{}], receivers=['test@example.com']) + + smtp_instance = mock_smtp.return_value + if test_mode: + smtp_instance.send_message.assert_not_called() + else: + smtp_instance.send_message.assert_called_once() + + @pytest.mark.parametrize("from_email_format,expected_format", [ + ('simple@example.com', 'simple@example.com'), + ('"Simple Name" ', 'Simple Name '), + ('Name ', 'Name '), + ('', 'bare@example.com'), + ]) + @patch('smtplib.SMTP') + def test_from_email_formats( + self, + mock_smtp: MagicMock, + from_email_format: str, + expected_format: str, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + simple_template: dict[str, str] + ) -> None: + """Test various from email formats""" + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=simple_template, + from_email=from_email_format + ) + + send_email.send_email(data=[{}], receivers=['test@example.com']) + + smtp_instance = mock_smtp.return_value + sent_msg = smtp_instance.send_message.call_args[0][0] + # EmailMessage normalizes the From header, removing extra quotes + assert sent_msg['From'] == expected_format + + @pytest.mark.parametrize("num_data_items", [1, 2, 5, 10]) + @patch('smtplib.SMTP') + def test_various_data_counts( + self, + mock_smtp: MagicMock, + num_data_items: int, + mock_logger: MagicMock, + basic_settings: dict[str, str | bool], + basic_template: dict[str, str] + ) -> None: + """Test with varying numbers of data items""" + data = [{'name': f'User{i}', 'purpose': 'test'} for i in range(num_data_items)] + + send_email = SendEmail( + log=mock_logger, + settings=basic_settings, + template=basic_template, + from_email='sender@example.com' + ) + + messages = send_email.prepare_email_content('sender@example.com', basic_template, data) + + assert len(messages) == num_data_items + +# __END__