add ip change notification and refine sender display
pytest / test (push) Successful in 57s
pytest / test (pull_request) Successful in 54s

This commit is contained in:
2026-04-29 13:03:12 +02:00
parent 3ea3498e58
commit 779e160b95
9 changed files with 266 additions and 18 deletions
+3 -2
View File
@@ -3,8 +3,9 @@ from sqlalchemy.orm import Session
from app.dependencies import get_auth_db, get_current_auth_session from app.dependencies import get_auth_db, get_current_auth_session
from app.schemas.public_ip import PublicIPCheckResponse from app.schemas.public_ip import PublicIPCheckResponse
from app.config import get_settings
from app.services.auth import AuthenticatedSession from app.services.auth import AuthenticatedSession
from app.services.public_ip import check_public_ipv4 from app.services.public_ip import check_public_ipv4_and_notify
router = APIRouter(tags=["public-ip"]) router = APIRouter(tags=["public-ip"])
@@ -17,7 +18,7 @@ def run_public_ip_check(
if current_auth is None: if current_auth is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="authentication required") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="authentication required")
result = check_public_ipv4(session) result = check_public_ipv4_and_notify(session, bootstrap_settings=get_settings())
return PublicIPCheckResponse( return PublicIPCheckResponse(
status=result.status, status=result.status,
checked_at=result.checked_at, checked_at=result.checked_at,
+1
View File
@@ -28,6 +28,7 @@ class Settings(BaseSettings):
smtp_port: int = 587 smtp_port: int = 587
smtp_username: str = "" smtp_username: str = ""
smtp_password: str = "" smtp_password: str = ""
smtp_from_name: str = ""
smtp_from_address: str = "" smtp_from_address: str = ""
smtp_to_address: str = "" smtp_to_address: str = ""
smtp_use_starttls: bool = True smtp_use_starttls: bool = True
+2 -2
View File
@@ -19,7 +19,7 @@ from app.api.routes.ticktick import router as ticktick_router
from app.config import get_settings from app.config import get_settings
from app.services.auth import AuthBootstrapError, initialize_auth_schema from app.services.auth import AuthBootstrapError, initialize_auth_schema
from app.services.config_page import seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap from app.services.config_page import seed_missing_config_from_bootstrap, sync_app_hostname_from_bootstrap
from app.services.public_ip import check_public_ipv4 from app.services.public_ip import check_public_ipv4_and_notify
from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db from scripts.app_db_adopt import AppDatabaseAdoptionError, validate_app_runtime_db
from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db from scripts.location_db_adopt import LocationDatabaseAdoptionError, validate_location_runtime_db
from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db from scripts.poo_db_adopt import PooDatabaseAdoptionError, validate_poo_runtime_db
@@ -29,7 +29,7 @@ def _run_scheduled_public_ip_check() -> None:
session_local = auth_db.get_auth_session_local() session_local = auth_db.get_auth_session_local()
session: Session = session_local() session: Session = session_local()
try: try:
check_public_ipv4(session) check_public_ipv4_and_notify(session, bootstrap_settings=get_settings())
finally: finally:
session.close() session.close()
+2
View File
@@ -32,6 +32,7 @@ CONFIG_FIELDS: tuple[ConfigField, ...] = (
ConfigField("SMTP", "SMTP_PORT", "smtp_port", "SMTP Port"), ConfigField("SMTP", "SMTP_PORT", "smtp_port", "SMTP Port"),
ConfigField("SMTP", "SMTP_USERNAME", "smtp_username", "SMTP Username"), ConfigField("SMTP", "SMTP_USERNAME", "smtp_username", "SMTP Username"),
ConfigField("SMTP", "SMTP_PASSWORD", "smtp_password", "SMTP Password", secret=True), ConfigField("SMTP", "SMTP_PASSWORD", "smtp_password", "SMTP Password", secret=True),
ConfigField("SMTP", "SMTP_FROM_NAME", "smtp_from_name", "SMTP From Name"),
ConfigField("SMTP", "SMTP_FROM_ADDRESS", "smtp_from_address", "SMTP From Address"), ConfigField("SMTP", "SMTP_FROM_ADDRESS", "smtp_from_address", "SMTP From Address"),
ConfigField("SMTP", "SMTP_TO_ADDRESS", "smtp_to_address", "SMTP To Address"), ConfigField("SMTP", "SMTP_TO_ADDRESS", "smtp_to_address", "SMTP To Address"),
ConfigField("SMTP", "SMTP_USE_STARTTLS", "smtp_use_starttls", "SMTP Use STARTTLS"), ConfigField("SMTP", "SMTP_USE_STARTTLS", "smtp_use_starttls", "SMTP Use STARTTLS"),
@@ -273,6 +274,7 @@ def _settings_payload(settings: Settings) -> dict[str, Any]:
"smtp_port": settings.smtp_port, "smtp_port": settings.smtp_port,
"smtp_username": settings.smtp_username, "smtp_username": settings.smtp_username,
"smtp_password": settings.smtp_password, "smtp_password": settings.smtp_password,
"smtp_from_name": settings.smtp_from_name,
"smtp_from_address": settings.smtp_from_address, "smtp_from_address": settings.smtp_from_address,
"smtp_to_address": settings.smtp_to_address, "smtp_to_address": settings.smtp_to_address,
"smtp_use_starttls": settings.smtp_use_starttls, "smtp_use_starttls": settings.smtp_use_starttls,
+46 -3
View File
@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime
from email.message import EmailMessage from email.message import EmailMessage
from email.utils import formataddr
import smtplib import smtplib
from app.config import Settings from app.config import Settings
@@ -21,6 +23,7 @@ class SMTPConfig:
port: int port: int
username: str username: str
password: str password: str
from_name: str
from_address: str from_address: str
to_address: str to_address: str
use_starttls: bool use_starttls: bool
@@ -47,6 +50,7 @@ def get_smtp_config(settings: Settings, *, require_enabled: bool = True) -> SMTP
port=settings.smtp_port, port=settings.smtp_port,
username=settings.smtp_username, username=settings.smtp_username,
password=settings.smtp_password, password=settings.smtp_password,
from_name=settings.smtp_from_name,
from_address=settings.smtp_from_address, from_address=settings.smtp_from_address,
to_address=settings.smtp_to_address, to_address=settings.smtp_to_address,
use_starttls=settings.smtp_use_starttls, use_starttls=settings.smtp_use_starttls,
@@ -72,7 +76,7 @@ def send_plaintext_email(
smtp_config = get_smtp_config(settings, require_enabled=require_enabled) smtp_config = get_smtp_config(settings, require_enabled=require_enabled)
message = EmailMessage() message = EmailMessage()
message["Subject"] = subject message["Subject"] = subject
message["From"] = smtp_config.from_address message["From"] = _build_from_header(smtp_config)
message["To"] = recipient or smtp_config.to_address message["To"] = recipient or smtp_config.to_address
message.set_content(body) message.set_content(body)
@@ -84,7 +88,11 @@ def send_plaintext_email(
smtp.ehlo() smtp.ehlo()
if smtp_config.username: if smtp_config.username:
smtp.login(smtp_config.username, smtp_config.password) smtp.login(smtp_config.username, smtp_config.password)
smtp.send_message(message) smtp.send_message(
message,
from_addr=smtp_config.from_address,
to_addrs=[recipient or smtp_config.to_address],
)
except (OSError, smtplib.SMTPException) as exc: except (OSError, smtplib.SMTPException) as exc:
error_message = _sanitize_error_message(str(exc), smtp_config.password) error_message = _sanitize_error_message(str(exc), smtp_config.password)
raise EmailDeliveryError(error_message or "SMTP delivery failed") from exc raise EmailDeliveryError(error_message or "SMTP delivery failed") from exc
@@ -99,8 +107,43 @@ def send_smtp_test_email(settings: Settings) -> None:
) )
def send_public_ip_changed_email(
settings: Settings,
*,
previous_ipv4: str,
current_ipv4: str,
detected_at: datetime,
) -> None:
send_plaintext_email(
settings,
subject="Public IP changed",
body=(
"Your public IPv4 address has changed.\n\n"
f"Previous IP: {previous_ipv4}\n"
f"Current IP: {current_ipv4}\n"
f"Detected at: {_format_utc_timestamp(detected_at)}\n\n"
"If you use Namecheap API trusted IP restrictions, you may need to "
"update the trusted IP manually.\n"
),
)
def _sanitize_error_message(message: str, password: str) -> str: def _sanitize_error_message(message: str, password: str) -> str:
sanitized = message sanitized = message
if password: if password:
sanitized = sanitized.replace(password, "[redacted]") sanitized = sanitized.replace(password, "[redacted]")
return sanitized return sanitized
def _format_utc_timestamp(value: datetime) -> str:
if value.tzinfo is None:
normalized = value.replace(tzinfo=UTC)
else:
normalized = value.astimezone(UTC)
return normalized.strftime("%Y-%m-%d %H:%M:%S UTC")
def _build_from_header(smtp_config: SMTPConfig) -> str:
if smtp_config.from_name:
return formataddr((smtp_config.from_name, smtp_config.from_address))
return smtp_config.from_address
+56 -4
View File
@@ -10,7 +10,10 @@ import httpx
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import Settings
from app.models.public_ip import PublicIPHistory, PublicIPState from app.models.public_ip import PublicIPHistory, PublicIPState
from app.services.config_page import build_runtime_settings
from app.services.email import EmailConfigurationError, EmailDeliveryError, send_public_ip_changed_email
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -31,6 +34,8 @@ class PublicIPCheckResult:
status: PublicIPResultStatus status: PublicIPResultStatus
checked_at: datetime checked_at: datetime
changed: bool changed: bool
previous_ipv4: str | None = None
current_ipv4: str | None = None
def check_public_ipv4( def check_public_ipv4(
@@ -77,7 +82,12 @@ def check_public_ipv4(
) )
) )
session.commit() session.commit()
return PublicIPCheckResult(status="first_seen", checked_at=checked_at, changed=False) return PublicIPCheckResult(
status="first_seen",
checked_at=checked_at,
changed=False,
current_ipv4=current_ipv4,
)
if state.current_ipv4 == current_ipv4: if state.current_ipv4 == current_ipv4:
state.last_checked_at = checked_at state.last_checked_at = checked_at
@@ -85,9 +95,15 @@ def check_public_ipv4(
state.last_check_error = None state.last_check_error = None
state.last_provider = provider_name state.last_provider = provider_name
session.commit() session.commit()
return PublicIPCheckResult(status="unchanged", checked_at=checked_at, changed=False) return PublicIPCheckResult(
status="unchanged",
checked_at=checked_at,
changed=False,
current_ipv4=current_ipv4,
)
state.previous_ipv4 = state.current_ipv4 previous_ipv4 = state.current_ipv4
state.previous_ipv4 = previous_ipv4
state.current_ipv4 = current_ipv4 state.current_ipv4 = current_ipv4
state.last_checked_at = checked_at state.last_checked_at = checked_at
state.last_changed_at = checked_at state.last_changed_at = checked_at
@@ -103,7 +119,43 @@ def check_public_ipv4(
) )
) )
session.commit() session.commit()
return PublicIPCheckResult(status="changed", checked_at=checked_at, changed=True) return PublicIPCheckResult(
status="changed",
checked_at=checked_at,
changed=True,
previous_ipv4=previous_ipv4,
current_ipv4=current_ipv4,
)
def check_public_ipv4_and_notify(
session: Session,
*,
bootstrap_settings: Settings,
fetch_public_ipv4: PublicIPv4Fetcher | None = None,
provider_name: str = PUBLIC_IP_PROVIDER_NAME,
) -> PublicIPCheckResult:
result = check_public_ipv4(
session,
fetch_public_ipv4=fetch_public_ipv4,
provider_name=provider_name,
)
if result.status != "changed" or result.previous_ipv4 is None or result.current_ipv4 is None:
return result
runtime_settings = build_runtime_settings(session, bootstrap_settings)
try:
send_public_ip_changed_email(
runtime_settings,
previous_ipv4=result.previous_ipv4,
current_ipv4=result.current_ipv4,
detected_at=result.checked_at,
)
except (EmailConfigurationError, EmailDeliveryError) as exc:
logger.warning("Public IPv4 change notification failed: %s", exc)
return result
def fetch_public_ipv4_from_provider() -> str: def fetch_public_ipv4_from_provider() -> str:
+2
View File
@@ -61,6 +61,7 @@ def test_settings_support_smtp_fields(monkeypatch) -> None:
monkeypatch.setenv("SMTP_PORT", "2525") monkeypatch.setenv("SMTP_PORT", "2525")
monkeypatch.setenv("SMTP_USERNAME", "smtp-user") monkeypatch.setenv("SMTP_USERNAME", "smtp-user")
monkeypatch.setenv("SMTP_PASSWORD", "smtp-password") monkeypatch.setenv("SMTP_PASSWORD", "smtp-password")
monkeypatch.setenv("SMTP_FROM_NAME", "Home Automation")
monkeypatch.setenv("SMTP_FROM_ADDRESS", "sender@example.com") monkeypatch.setenv("SMTP_FROM_ADDRESS", "sender@example.com")
monkeypatch.setenv("SMTP_TO_ADDRESS", "recipient@example.com") monkeypatch.setenv("SMTP_TO_ADDRESS", "recipient@example.com")
monkeypatch.setenv("SMTP_USE_STARTTLS", "false") monkeypatch.setenv("SMTP_USE_STARTTLS", "false")
@@ -72,6 +73,7 @@ def test_settings_support_smtp_fields(monkeypatch) -> None:
assert settings.smtp_port == 2525 assert settings.smtp_port == 2525
assert settings.smtp_username == "smtp-user" assert settings.smtp_username == "smtp-user"
assert settings.smtp_password == "smtp-password" assert settings.smtp_password == "smtp-password"
assert settings.smtp_from_name == "Home Automation"
assert settings.smtp_from_address == "sender@example.com" assert settings.smtp_from_address == "sender@example.com"
assert settings.smtp_to_address == "recipient@example.com" assert settings.smtp_to_address == "recipient@example.com"
assert settings.smtp_use_starttls is False assert settings.smtp_use_starttls is False
+87 -3
View File
@@ -6,7 +6,9 @@ from fastapi.testclient import TestClient
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
from app.services.public_ip import PublicIPCheckResult, check_public_ipv4 from app.config import Settings
from app.services.email import EmailDeliveryError
from app.services.public_ip import PublicIPCheckResult, check_public_ipv4, check_public_ipv4_and_notify
def _make_session(database_url: str) -> Session: def _make_session(database_url: str) -> Session:
@@ -152,8 +154,8 @@ def test_public_ip_check_endpoint_hides_ip_values(client: TestClient, monkeypatc
monkeypatch.setattr( monkeypatch.setattr(
public_ip_route, public_ip_route,
"check_public_ipv4", "check_public_ipv4_and_notify",
lambda session: PublicIPCheckResult( lambda session, bootstrap_settings: PublicIPCheckResult(
status="changed", status="changed",
checked_at=fixed_checked_at, checked_at=fixed_checked_at,
changed=True, changed=True,
@@ -172,3 +174,85 @@ def test_public_ip_check_endpoint_hides_ip_values(client: TestClient, monkeypatc
assert "current_ipv4" not in response.text assert "current_ipv4" not in response.text
assert "previous_ipv4" not in response.text assert "previous_ipv4" not in response.text
assert "203.0.113.10" not in response.text assert "203.0.113.10" not in response.text
def _notification_settings() -> Settings:
return Settings(
_env_file=None,
app_env="development",
app_hostname="localhost:8000",
app_database_url="sqlite:///./data/app.db",
location_database_url="sqlite:///./data/locationRecorder.db",
poo_database_url="sqlite:///./data/pooRecorder.db",
auth_bootstrap_username="admin",
auth_bootstrap_password="secret-password",
smtp_enabled=True,
smtp_host="smtp.example.com",
smtp_port=587,
smtp_username="smtp-user",
smtp_password="super-secret-password",
smtp_from_address="sender@example.com",
smtp_to_address="recipient@example.com",
smtp_use_starttls=True,
)
def test_public_ip_notification_sends_only_when_changed(auth_database, monkeypatch) -> None:
session = _make_session(auth_database["app_url"])
sent = []
monkeypatch.setattr(
"app.services.public_ip.send_public_ip_changed_email",
lambda settings, *, previous_ipv4, current_ipv4, detected_at: sent.append(
(previous_ipv4, current_ipv4, detected_at)
),
)
try:
first_seen = check_public_ipv4_and_notify(
session,
bootstrap_settings=_notification_settings(),
fetch_public_ipv4=lambda: "203.0.113.10",
)
unchanged = check_public_ipv4_and_notify(
session,
bootstrap_settings=_notification_settings(),
fetch_public_ipv4=lambda: "203.0.113.10",
)
changed = check_public_ipv4_and_notify(
session,
bootstrap_settings=_notification_settings(),
fetch_public_ipv4=lambda: "198.51.100.25",
)
finally:
session.close()
assert first_seen.status == "first_seen"
assert unchanged.status == "unchanged"
assert changed.status == "changed"
assert len(sent) == 1
assert sent[0][0] == "203.0.113.10"
assert sent[0][1] == "198.51.100.25"
assert sent[0][2] == changed.checked_at
def test_public_ip_notification_failure_does_not_break_changed_result(auth_database, monkeypatch) -> None:
session = _make_session(auth_database["app_url"])
monkeypatch.setattr(
"app.services.public_ip.send_public_ip_changed_email",
lambda settings, *, previous_ipv4, current_ipv4, detected_at: (_ for _ in ()).throw(
EmailDeliveryError("smtp down")
),
)
try:
check_public_ipv4(session, fetch_public_ipv4=lambda: "203.0.113.10")
result = check_public_ipv4_and_notify(
session,
bootstrap_settings=_notification_settings(),
fetch_public_ipv4=lambda: "198.51.100.25",
)
finally:
session.close()
assert result.status == "changed"
assert result.changed is True
assert result.previous_ipv4 == "203.0.113.10"
assert result.current_ipv4 == "198.51.100.25"
+67 -4
View File
@@ -5,7 +5,13 @@ import smtplib
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.config import Settings from app.config import Settings
from app.services.email import EmailDeliveryError, get_smtp_config, is_smtp_ready, send_smtp_test_email from app.services.email import (
EmailDeliveryError,
get_smtp_config,
is_smtp_ready,
send_public_ip_changed_email,
send_smtp_test_email,
)
def _extract_csrf_token(html: str) -> str: def _extract_csrf_token(html: str) -> str:
@@ -43,6 +49,7 @@ def _smtp_settings(**overrides) -> Settings:
"smtp_port": 587, "smtp_port": 587,
"smtp_username": "smtp-user", "smtp_username": "smtp-user",
"smtp_password": "super-secret-password", "smtp_password": "super-secret-password",
"smtp_from_name": "Home Automation",
"smtp_from_address": "sender@example.com", "smtp_from_address": "sender@example.com",
"smtp_to_address": "recipient@example.com", "smtp_to_address": "recipient@example.com",
"smtp_use_starttls": True, "smtp_use_starttls": True,
@@ -60,6 +67,7 @@ def test_get_smtp_config_reads_runtime_values() -> None:
assert smtp_config.port == 2525 assert smtp_config.port == 2525
assert smtp_config.username == "smtp-user" assert smtp_config.username == "smtp-user"
assert smtp_config.password == "super-secret-password" assert smtp_config.password == "super-secret-password"
assert smtp_config.from_name == "Home Automation"
assert smtp_config.from_address == "sender@example.com" assert smtp_config.from_address == "sender@example.com"
assert smtp_config.to_address == "recipient@example.com" assert smtp_config.to_address == "recipient@example.com"
assert smtp_config.use_starttls is False assert smtp_config.use_starttls is False
@@ -96,11 +104,13 @@ def test_send_smtp_test_email_success(monkeypatch) -> None:
sent["username"] = username sent["username"] = username
sent["password"] = password sent["password"] = password
def send_message(self, message): def send_message(self, message, from_addr=None, to_addrs=None):
sent["subject"] = message["Subject"] sent["subject"] = message["Subject"]
sent["from"] = message["From"] sent["from"] = message["From"]
sent["to"] = message["To"] sent["to"] = message["To"]
sent["body"] = message.get_content() sent["body"] = message.get_content()
sent["envelope_from"] = from_addr
sent["envelope_to"] = to_addrs
monkeypatch.setattr("app.services.email.smtplib.SMTP", FakeSMTP) monkeypatch.setattr("app.services.email.smtplib.SMTP", FakeSMTP)
@@ -113,8 +123,10 @@ def test_send_smtp_test_email_success(monkeypatch) -> None:
assert sent["username"] == "smtp-user" assert sent["username"] == "smtp-user"
assert sent["password"] == "super-secret-password" assert sent["password"] == "super-secret-password"
assert sent["subject"] == "Home Automation SMTP Test" assert sent["subject"] == "Home Automation SMTP Test"
assert sent["from"] == "sender@example.com" assert sent["from"] == "Home Automation <sender@example.com>"
assert sent["to"] == "recipient@example.com" assert sent["to"] == "recipient@example.com"
assert sent["envelope_from"] == "sender@example.com"
assert sent["envelope_to"] == ["recipient@example.com"]
assert "This is a test email" in sent["body"] assert "This is a test email" in sent["body"]
@@ -140,8 +152,10 @@ def test_send_smtp_test_email_does_not_require_smtp_enabled(monkeypatch) -> None
def login(self, username, password): def login(self, username, password):
return None return None
def send_message(self, message): def send_message(self, message, from_addr=None, to_addrs=None):
sent["subject"] = message["Subject"] sent["subject"] = message["Subject"]
sent["from"] = message["From"]
sent["envelope_from"] = from_addr
monkeypatch.setattr("app.services.email.smtplib.SMTP", FakeSMTP) monkeypatch.setattr("app.services.email.smtplib.SMTP", FakeSMTP)
@@ -149,6 +163,8 @@ def test_send_smtp_test_email_does_not_require_smtp_enabled(monkeypatch) -> None
assert sent["host"] == "smtp.example.com" assert sent["host"] == "smtp.example.com"
assert sent["subject"] == "Home Automation SMTP Test" assert sent["subject"] == "Home Automation SMTP Test"
assert sent["from"] == "Home Automation <sender@example.com>"
assert sent["envelope_from"] == "sender@example.com"
def test_send_smtp_test_email_failure_sanitizes_password(monkeypatch) -> None: def test_send_smtp_test_email_failure_sanitizes_password(monkeypatch) -> None:
@@ -178,6 +194,53 @@ def test_send_smtp_test_email_failure_sanitizes_password(monkeypatch) -> None:
assert "[redacted]" in str(exc) assert "[redacted]" in str(exc)
def test_send_public_ip_changed_email_contains_expected_english_content(monkeypatch) -> None:
sent = {}
class FakeSMTP:
def __init__(self, host, port, timeout):
sent["host"] = host
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return None
def ehlo(self):
return None
def starttls(self):
return None
def login(self, username, password):
return None
def send_message(self, message, from_addr=None, to_addrs=None):
sent["subject"] = message["Subject"]
sent["body"] = message.get_content()
sent["from"] = message["From"]
sent["envelope_from"] = from_addr
monkeypatch.setattr("app.services.email.smtplib.SMTP", FakeSMTP)
send_public_ip_changed_email(
_smtp_settings(),
previous_ipv4="203.0.113.10",
current_ipv4="198.51.100.25",
detected_at=__import__("datetime").datetime(2026, 4, 29, 10, 0, tzinfo=__import__("datetime").UTC),
)
assert sent["subject"] == "Public IP changed"
assert sent["from"] == "Home Automation <sender@example.com>"
assert sent["envelope_from"] == "sender@example.com"
assert "Your public IPv4 address has changed." in sent["body"]
assert "Previous IP: 203.0.113.10" in sent["body"]
assert "Current IP: 198.51.100.25" in sent["body"]
assert "Detected at: 2026-04-29 10:00:00 UTC" in sent["body"]
assert "update the trusted IP manually" in sent["body"]
def test_config_update_does_not_clear_existing_smtp_password( def test_config_update_does_not_clear_existing_smtp_password(
client: TestClient, test_database_urls client: TestClient, test_database_urls
) -> None: ) -> None: