"""An undelivered email leaves a trace in the admins' inbox. Sending is deliberately fail-soft — a missing SMTP setting or a rejecting relay logs and returns False so nothing user-facing breaks. The cost of that used to be total silence: an instance whose SMTP was never filled in swallowed verification links, password resets and access-request notices, and the operator's first clue was somebody saying nothing arrived. """ import smtplib import pytest from sqlalchemy import select from app import email as email_mod from app.models import Notification @pytest.fixture(autouse=True) def _no_smtp_config(monkeypatch): """Every test here drives the real `_send`, so make sure it never reaches a socket.""" monkeypatch.setattr( email_mod, "_smtp", lambda: {"host": "", "port": 587, "user": "", "password": "", "from": ""}, ) def _notifications(db, user_id: int) -> list[Notification]: return list( db.execute( select(Notification) .where(Notification.user_id == user_id, Notification.type == "email_undelivered") .order_by(Notification.id) ) .scalars() .all() ) def test_a_skipped_email_notifies_every_admin(db, make_user): admin_a = make_user(email="admin-a@example.com", role="admin") admin_b = make_user(email="admin-b@example.com", role="admin") plain = make_user(email="plain@example.com") assert email_mod.send_test("someone@example.com", "en") is False # fail-soft, as before db.expire_all() for admin in (admin_a, admin_b): (n,) = _notifications(db, admin.id) assert n.data["reason"] == "not_configured" assert n.data["subject"] == "Siftlode SMTP test" assert n.data["recipients"] == ["someone@example.com"] assert _notifications(db, plain.id) == [] # not an operator's problem to see def test_the_notice_is_throttled_so_a_dead_relay_cannot_flood_the_inbox(db, make_user): admin = make_user(email="admin@example.com", role="admin") for _ in range(5): email_mod.send_test("someone@example.com", "en") db.expire_all() assert len(_notifications(db, admin.id)) == 1 def test_a_send_failure_is_reported_separately_from_a_missing_config(db, make_user, monkeypatch): # A configured-but-rejecting relay is a different operator problem from an empty config, and the # inbox copy differs — so the reason has to survive to the notification. admin = make_user(email="admin@example.com", role="admin") monkeypatch.setattr( email_mod, "_smtp", lambda: { "host": "smtp.example.com", "port": 587, "user": "u", "password": "p", "from": "Siftlode ", }, ) def _boom(*a, **kw): raise smtplib.SMTPException("nope") monkeypatch.setattr(email_mod.smtplib, "SMTP", _boom) assert email_mod.send_test("someone@example.com", "en") is False db.expire_all() (n,) = _notifications(db, admin.id) assert n.data["reason"] == "send_failed" def test_no_admins_means_no_notification_and_no_error(db, make_user): # A fresh instance can have no admin yet (the wizard has not run). The alert must stay silent # rather than blowing up inside the fail-soft path it is supposed to make visible. plain = make_user(email="plain@example.com") assert email_mod.send_test("someone@example.com", "en") is False db.expire_all() assert _notifications(db, plain.id) == [] def test_a_drop_with_nobody_to_tell_does_not_burn_the_hour(db, make_user): # The throttle window must only be spent on a notice that was actually written. Otherwise a drop # during setup — before any admin exists — silences the next hour for the admin created a minute # later, which is exactly the blind spot this alert exists to remove. email_mod.send_test("someone@example.com", "en") # no admins yet admin = make_user(email="admin@example.com", role="admin") assert email_mod.send_test("someone@example.com", "en") is False db.expire_all() assert len(_notifications(db, admin.id)) == 1 def test_a_failed_alert_does_not_burn_the_hour_either(db, make_user, monkeypatch): admin = make_user(email="admin@example.com", role="admin") boom = {"on": True} def _maybe_boom(*a, **kw): if boom["on"]: raise RuntimeError("notification store is down") from app.notifications import create_notification as real return real(*a, **kw) monkeypatch.setattr(email_mod, "create_notification", _maybe_boom) email_mod.send_test("someone@example.com", "en") # the write blows up; nothing recorded boom["on"] = False assert email_mod.send_test("someone@example.com", "en") is False db.expire_all() assert len(_notifications(db, admin.id)) == 1 def test_a_broken_alert_never_breaks_the_send(db, make_user, monkeypatch): # The alert is the fallback path; if IT fails, the caller must still get the ordinary fail-soft # False rather than an exception surfacing from a background task. make_user(email="admin@example.com", role="admin") def _boom(*a, **kw): raise RuntimeError("notification store is down") monkeypatch.setattr(email_mod, "create_notification", _boom) assert email_mod.send_test("someone@example.com", "en") is False