diff --git a/.vscode/launch.json b/.vscode/launch.json index 929a971..b1da115 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,7 @@ "request": "launch", "module": "uvicorn", "args": [ - "app:app", + "app.main:app", "--host=0.0.0.0", "--reload", "--port=18881" diff --git a/app/core/config.py b/app/core/config.py index 9f5455c..a4c655c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,8 +1,20 @@ +from __future__ import annotations + +from typing import Literal + from pydantic_settings import BaseSettings class Settings(BaseSettings): PROJECT_NAME: str = "home-automation-backend" + SMTP_HOST: str | None = None + SMTP_PORT: int = 587 + SMTP_USERNAME: str | None = None + SMTP_PASSWORD: str | None = None + SMTP_FROM_EMAIL: str | None = None + SMTP_FROM_NAME: str | None = None + SMTP_ENCRYPTION: Literal["starttls", "ssl_tls"] = "starttls" + SMTP_TIMEOUT: float = 10.0 settings = Settings() diff --git a/app/integrations/__init__.py b/app/integrations/__init__.py new file mode 100644 index 0000000..d0aa362 --- /dev/null +++ b/app/integrations/__init__.py @@ -0,0 +1,3 @@ +from app.integrations.smtp import send_email + +__all__ = ["send_email"] diff --git a/app/integrations/openstreetmap.py b/app/integrations/openstreetmap.py new file mode 100644 index 0000000..e69de29 diff --git a/app/integrations/smtp.py b/app/integrations/smtp.py new file mode 100644 index 0000000..f889293 --- /dev/null +++ b/app/integrations/smtp.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import smtplib +from email.message import EmailMessage +from typing import TYPE_CHECKING + +from app.core.config import settings + +if TYPE_CHECKING: + from collections.abc import Sequence + + +def send_email( + *, + subject: str, + recipients: Sequence[str], + text_body: str, + html_body: str | None = None, + reply_to: str | None = None, +) -> None: + host = settings.SMTP_HOST + from_email = settings.SMTP_FROM_EMAIL + recipient_list = list(recipients) + encryption = _get_smtp_encryption() + + if not host: + raise ValueError("SMTP_HOST must be configured") + + if not from_email: + raise ValueError("SMTP_FROM_EMAIL must be configured") + + if not recipient_list: + raise ValueError("At least one recipient is required") + + message = EmailMessage() + message["Subject"] = subject + message["From"] = _format_sender(from_email=from_email, from_name=settings.SMTP_FROM_NAME) + message["To"] = ", ".join(recipient_list) + + if reply_to: + message["Reply-To"] = reply_to + + message.set_content(text_body) + + if html_body is not None: + message.add_alternative(html_body, subtype="html") + + smtp_client = _create_smtp_client( + host=host, + port=settings.SMTP_PORT, + encryption=encryption, + timeout=settings.SMTP_TIMEOUT, + ) + + try: + smtp_client.ehlo() + + if encryption == "starttls": + smtp_client.starttls() + smtp_client.ehlo() + + if settings.SMTP_USERNAME: + if settings.SMTP_PASSWORD is None: + raise ValueError("SMTP_PASSWORD must be configured when SMTP_USERNAME is set") + + smtp_client.login(settings.SMTP_USERNAME, settings.SMTP_PASSWORD) + + smtp_client.send_message(message) + finally: + smtp_client.quit() + + +def _format_sender(*, from_email: str, from_name: str | None) -> str: + if not from_name: + return from_email + + return f"{from_name} <{from_email}>" + + +def _get_smtp_encryption() -> str: + encryption = settings.SMTP_ENCRYPTION.strip().lower() + if encryption in {"ssl/tls", "ssl-tls", "tls"}: + return "ssl_tls" + + if encryption not in {"starttls", "ssl_tls"}: + raise ValueError("SMTP_ENCRYPTION must be 'starttls' or 'ssl_tls'") + + return encryption + + +def _create_smtp_client(*, host: str, port: int, encryption: str, timeout: float) -> smtplib.SMTP: + client_class = smtplib.SMTP_SSL if encryption == "ssl_tls" else smtplib.SMTP + return client_class(host, port, timeout=timeout) diff --git a/tests/test_smtp_integration.py b/tests/test_smtp_integration.py new file mode 100644 index 0000000..32c6a44 --- /dev/null +++ b/tests/test_smtp_integration.py @@ -0,0 +1,89 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from app.core.config import settings +from app.integrations.smtp import send_email + + +@pytest.fixture(autouse=True) +def smtp_settings(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "SMTP_HOST", "smtp.example.com") + monkeypatch.setattr(settings, "SMTP_PORT", 587) + monkeypatch.setattr(settings, "SMTP_USERNAME", "automation") + monkeypatch.setattr(settings, "SMTP_PASSWORD", "secret") + monkeypatch.setattr(settings, "SMTP_FROM_EMAIL", "noreply@example.com") + monkeypatch.setattr(settings, "SMTP_FROM_NAME", "Home Automation") + monkeypatch.setattr(settings, "SMTP_ENCRYPTION", "starttls") + monkeypatch.setattr(settings, "SMTP_TIMEOUT", 10.0) + + +def test_send_email_uses_starttls_and_login() -> None: + smtp_client = MagicMock() + + with patch("app.integrations.smtp.smtplib.SMTP", return_value=smtp_client) as smtp_class: + send_email( + subject="Door opened", + recipients=["user@example.com"], + text_body="The front door opened.", + html_body="

The front door opened.

", + reply_to="support@example.com", + ) + + smtp_class.assert_called_once_with("smtp.example.com", 587, timeout=10.0) + assert smtp_client.ehlo.call_count == 2 + smtp_client.starttls.assert_called_once_with() + smtp_client.login.assert_called_once_with("automation", "secret") + smtp_client.send_message.assert_called_once() + smtp_client.quit.assert_called_once_with() + + message = smtp_client.send_message.call_args.args[0] + assert message["Subject"] == "Door opened" + assert message["From"] == "Home Automation " + assert message["To"] == "user@example.com" + assert message["Reply-To"] == "support@example.com" + assert message.get_body(preferencelist=("plain",)).get_content().strip() == "The front door opened." + assert message.get_body(preferencelist=("html",)).get_content().strip() == "

The front door opened.

" + + +def test_send_email_uses_ssl_without_starttls(monkeypatch: pytest.MonkeyPatch) -> None: + smtp_client = MagicMock() + monkeypatch.setattr(settings, "SMTP_PORT", 465) + monkeypatch.setattr(settings, "SMTP_ENCRYPTION", "ssl_tls") + monkeypatch.setattr(settings, "SMTP_USERNAME", None) + monkeypatch.setattr(settings, "SMTP_PASSWORD", None) + + with patch("app.integrations.smtp.smtplib.SMTP_SSL", return_value=smtp_client) as smtp_ssl_class: + send_email( + subject="Alarm armed", + recipients=["user1@example.com", "user2@example.com"], + text_body="The alarm is armed.", + ) + + smtp_ssl_class.assert_called_once_with("smtp.example.com", 465, timeout=10.0) + smtp_client.starttls.assert_not_called() + smtp_client.login.assert_not_called() + smtp_client.send_message.assert_called_once() + + message = smtp_client.send_message.call_args.args[0] + assert message["To"] == "user1@example.com, user2@example.com" + assert message.get_body(preferencelist=("plain",)).get_content().strip() == "The alarm is armed." + + +def test_send_email_requires_host(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "SMTP_HOST", None) + + with pytest.raises(ValueError, match="SMTP_HOST"): + send_email(subject="test", recipients=["user@example.com"], text_body="body") + + +def test_send_email_requires_recipient() -> None: + with pytest.raises(ValueError, match="At least one recipient"): + send_email(subject="test", recipients=[], text_body="body") + + +def test_send_email_rejects_unencrypted_mode(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "SMTP_ENCRYPTION", "none") + + with pytest.raises(ValueError, match="SMTP_ENCRYPTION"): + send_email(subject="test", recipients=["user@example.com"], text_body="body")