add smtp
This commit is contained in:
Vendored
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"module": "uvicorn",
|
"module": "uvicorn",
|
||||||
"args": [
|
"args": [
|
||||||
"app:app",
|
"app.main:app",
|
||||||
"--host=0.0.0.0",
|
"--host=0.0.0.0",
|
||||||
"--reload",
|
"--reload",
|
||||||
"--port=18881"
|
"--port=18881"
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
PROJECT_NAME: str = "home-automation-backend"
|
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()
|
settings = Settings()
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.integrations.smtp import send_email
|
||||||
|
|
||||||
|
__all__ = ["send_email"]
|
||||||
@@ -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)
|
||||||
@@ -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="<p>The front door opened.</p>",
|
||||||
|
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 <noreply@example.com>"
|
||||||
|
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() == "<p>The front door opened.</p>"
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
Reference in New Issue
Block a user