Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/models/notification_preference.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
19 changes: 16 additions & 3 deletions app/services/email.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -89,3 +92,13 @@ def send_invitation_email(invitation, raw_token: str) -> bool:
"<p>If you didn't expect this invitation, you can ignore this email.</p>"
)
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)
5 changes: 4 additions & 1 deletion app/services/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -50,6 +51,7 @@ def notify(
if user is None:
return None
pref = _preferences(user.id)

if not pref.allows(notification_type):
return None

Expand All @@ -67,6 +69,7 @@ def notify(
return notification



def _id_of(obj) -> int | None:
if obj is None:
return None
Expand Down
6 changes: 4 additions & 2 deletions tests/test_alerts.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,17 @@ 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)
assert len(sender.sent) == 1
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)
Expand Down
Loading