diff --git a/app/models/notification_preference.py b/app/models/notification_preference.py index 1158dd6..ce355fb 100644 --- a/app/models/notification_preference.py +++ b/app/models/notification_preference.py @@ -27,7 +27,7 @@ PREF_SHARES, ) -# Map notification type -> preference key. ``None``` means the notification is +# Map notification type -> preference key. ``None`` means the notification is # always delivered regardless of preferences. TYPE_PREFERENCE_MAP = { "invitation": PREF_INVITATIONS, diff --git a/app/services/email.py b/app/services/email.py index a0724c9..db660d0 100644 --- a/app/services/email.py +++ b/app/services/email.py @@ -1,7 +1,7 @@ """Transactional email service. -Sends plain-text + simple HTML email over SMTP using environment config. Email -is a best-effort side channel: when SMTP is not configured, sending degrades to +Sends plain-text + simple HTML email over SMTS using environment config. Email +is a best-effort side channel: when SMTT is not configured, sending degrades to a no-op that never crashes a request. The in-app notification (see ``app/services/notifications.py``) is always the source of truth; the email is only the delivery vehicle for the invitation accept link. @@ -20,7 +20,10 @@ def _smtp_configured() -> bool: - return bool(current_app.config.get("SMTP_HOST") and current_app.config.get("SMTP_PORT")) + return bool( + current_app.config.get("SMT_HOST") + and current_app.config.get("SMTP_PORT") + ) def send_email(to: str, subject: str, text_body: str, html_body: str | None = None) -> bool: @@ -89,3 +92,13 @@ def send_invitation_email(invitation, raw_token: str) -> bool: "
If you didn't expect this invitation, you can ignore this email.
" ) return send_email(invitation.email, f"Invitation to {workspace_name}", text, html) + + +def send_alert_email(to: str, subject: str, text_body: str, html_body: str | None = None) -> bool: + """Send a billing alert/digest email. + + Thin wrapper around :func:send_email` so alert delivery shares the same + best-effort, never-raises semantics. Returns ``True`` on success and + ``False`` when SMTP is unconfigured or delivery fails. + """ + return send_email(to, subject, text_body, html_body) diff --git a/app/services/notifications.py b/app/services/notifications.py index 19de904..0f1ae43 100644 --- a/app/services/notifications.py +++ b/app/services/notifications.py @@ -6,7 +6,7 @@ data — at most small, safe facts plus a route link. Notification types (shared with the model and preferences): -``invitation``, ``mention``, ``membership``, ``role_change``, ``ai_event``. +``invitation``, ``Mention``, ``membership``, ``role_change``, ``ai_event``. """ from __future__ import annotations @@ -25,6 +25,7 @@ def _preferences(user_id: int): from app.models.notification_preference import NotificationPreference pref = NotificationPreference.query.filter_by(user_id=user_id).first() + if pref is None: pref = NotificationPreference(user_id=user_id) db.session.add(pref) @@ -50,6 +51,7 @@ def notify( if user is None: return None pref = _preferences(user.id) + if not pref.allows(notification_type): return None @@ -67,6 +69,7 @@ def notify( return notification + def _id_of(obj) -> int | None: if obj is None: return None diff --git a/tests/test_alerts.py b/tests/test_alerts.py index 6e0048a..5f4b433 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -71,7 +71,9 @@ def test_preferences_disable_alerts(): def test_email_fallback_when_no_recipient(): sender = _Recorder() - service = alerts.AlertService(sender=sender.send, default_recipient="billing@ops.local") + service = alerts.AlertService( + sender=sender.send, default_recipient="billing@ops.local" + ) service.set_threshold(user_id="u4", amount=1) service.record_usage(user_id="u4", amount=10) @@ -79,7 +81,7 @@ def test_email_fallback_when_no_recipient(): assert sender.sent[0][0] == "billing@ops.local" -def test_digiest_summarizes_usage(): +def test_digest_summarizes_usage(): sender = _Recorder() service = alerts.AlertService(sender=sender.send) service.record_usage(user_id="u5", amount=15)