"""notify-send: deliver the events nobody has handled yet, and the Admin's test send. Per event: rule off -> dispatched `rule_off`; immediate -> one message to the resolved recipients, one notifications_sent row, dispatched with the result; digest -> an outbox row, dispatched `queued`. NOTIFY_DRY_RUN records `dry_run` and sends nothing, except a test send, which always goes. """ from __future__ import annotations import logging from collections.abc import Sequence from datetime import datetime from typing import Any from zoneinfo import ZoneInfo from psycopg.rows import dict_row from psycopg.types.json import Jsonb from ..settings import Settings from . import smtp from .catalog import EVENT_TYPES from .routing import DIRECTORY_DOWN, DirectoryDown, NotifyDirectory, Recipient, Resolver, load_rules log = logging.getLogger(__name__) MAX_DIRECTORY_TRIES = 3 RETRY_PREFIX = "directory_retry:" BATCH = 200 FOOTER = "Sent by Packet Station. Who gets this is set on the Admin page, Notifications tab." def record( conn: Any, *, event_type: str, subject: str, recipients: Sequence[Recipient], result: str, error: str | None = None, event_ids: Sequence[int] = (), digest: bool = False, test: bool = False, requested_by: Any = None, ) -> int: row = conn.execute( "INSERT INTO notifications_sent (event_type, event_ids, digest, test, subject, recipients, result, error," " requested_by) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id", ( event_type, list(event_ids), digest, test, subject, Jsonb([r.as_json() for r in recipients]), result, (error or None) and str(error)[:1000], requested_by, ), ).fetchone() return int(row[0]) def deliver( settings: Settings, subject: str, body: str, recipients: Sequence[Recipient], *, test: bool = False ) -> tuple[str, str | None]: """(result, error) for one message. No recipients -> no_recipients; dry run (not a test) -> dry_run.""" if not recipients: return "no_recipients", None if settings.notify_dry_run and not test: return "dry_run", None try: note = smtp.send(settings, subject, body, [r.address for r in recipients]) except smtp.MailError as e: log.warning("notify: mail not sent: %s", e) return "failed", str(e) return "sent", note def site_time(settings: Settings, at: datetime | None) -> str: if at is None: return "" return at.astimezone(ZoneInfo(settings.site_tz)).strftime("%Y-%m-%d %H:%M") def label_of(event_type: str) -> str: et = EVENT_TYPES.get(event_type) return et.label if et else event_type def event_title(ev: dict[str, Any]) -> str: return str(ev.get("title") or label_of(str(ev.get("event_type")))) def event_lines(settings: Settings, ev: dict[str, Any]) -> list[str]: """The plain-text block for one event (a whole immediate message, or one entry in a digest).""" lines = [event_title(ev)] if ev.get("body"): lines += ["", str(ev["body"]).rstrip()] lines.append("") lines.append(f"Event: {label_of(str(ev.get('event_type')))}") if ev.get("subject"): lines.append(f"About: {ev['subject']}") if ev.get("dept_code"): lines.append(f"Department: {ev['dept_code']}") lines.append(f"When: {site_time(settings, ev.get('created_at'))} ({settings.site_tz})") if int(ev.get("raised_count") or 1) > 1: lines.append(f"Seen {ev['raised_count']} times, last {site_time(settings, ev.get('last_raised_at'))}") return lines def footer(settings: Settings) -> list[str]: return ["", "--", f"{FOOTER} Stack {settings.stack}, build {settings.ps_build}."] def immediate_text(settings: Settings, ev: dict[str, Any]) -> tuple[str, str]: return smtp.full_subject(settings, event_title(ev)), "\n".join(event_lines(settings, ev) + footer(settings)) def _tries(result: Any) -> int: text = str(result or "") if text.startswith(RETRY_PREFIX): try: return int(text[len(RETRY_PREFIX) :]) except ValueError: return 0 return 0 def dispatch_pending(conn: Any, settings: Settings, directory: NotifyDirectory | None) -> dict[str, int]: """Handle every undispatched event once. Returns counts by outcome.""" counts: dict[str, int] = {} ids = [ int(r[0]) for r in conn.execute( "SELECT id FROM notification_events WHERE dispatched_at IS NULL AND event_type IS NOT NULL" " ORDER BY id LIMIT %s", (BATCH,), ).fetchall() ] if not ids: return counts rules = load_rules(conn) resolver = Resolver(conn, settings, directory) for eid in ids: with conn.transaction(), conn.cursor(row_factory=dict_row) as cur: ev = cur.execute( "SELECT * FROM notification_events WHERE id = %s AND dispatched_at IS NULL FOR UPDATE SKIP LOCKED", (eid,), ).fetchone() if ev is None: continue outcome = _dispatch_one(conn, settings, resolver, rules.get(str(ev["event_type"])), dict(ev)) counts[outcome] = counts.get(outcome, 0) + 1 return counts def _done(conn: Any, eid: int, result: str) -> None: conn.execute( "UPDATE notification_events SET dispatched_at = now(), dispatch_result = %s WHERE id = %s", (result, eid) ) def _dispatch_one(conn: Any, settings: Settings, resolver: Resolver, rule: Any, ev: dict[str, Any]) -> str: eid = int(ev["id"]) if rule is None: _done(conn, eid, "no_rule") return "no_rule" if not rule.enabled: _done(conn, eid, "rule_off") return "rule_off" if rule.delivery == "digest": conn.execute("INSERT INTO notification_outbox (event_id, event_type) VALUES (%s, %s)", (eid, ev["event_type"])) _done(conn, eid, "queued") return "queued" subject, body = immediate_text(settings, ev) try: recipients = resolver.resolve(rule, ev.get("dept_code")) except DirectoryDown: tries = _tries(ev.get("dispatch_result")) + 1 record( conn, event_type=ev["event_type"], subject=subject, recipients=[], result="failed", error=DIRECTORY_DOWN, event_ids=[eid], ) if tries >= MAX_DIRECTORY_TRIES: _done(conn, eid, "failed") return "failed" conn.execute( "UPDATE notification_events SET dispatch_result = %s WHERE id = %s", (f"{RETRY_PREFIX}{tries}", eid) ) return "retry" result, error = deliver(settings, subject, body, recipients) record( conn, event_type=ev["event_type"], subject=subject, recipients=recipients, result=result, error=error, event_ids=[eid], ) _done(conn, eid, result) return result # ---------------------------------------------------------------- test send def send_test( conn: Any, settings: Settings, directory: NotifyDirectory | None, event_type: str, *, to: str | None = None, requested_by: Any = None, ) -> dict[str, Any]: """Send a test of one event type now, even in dry run. With `to`, only to that address.""" et = EVENT_TYPES[event_type] subject = smtp.full_subject(settings, f"Test: {et.label}") body = "\n".join( [ f'This is a test of the Packet Station notification "{et.label}".', "", et.description, "", "Nothing has happened. An Admin sent this from the Notifications tab.", *footer(settings), ] ) recipients: list[Recipient] = [] error: str | None = None result: str | None = None if to: recipients = [Recipient(to.strip(), None, "test address")] else: rule = load_rules(conn, event_type).get(event_type) try: recipients = Resolver(conn, settings, directory).resolve(rule, None) if rule else [] except DirectoryDown: result, error = "failed", DIRECTORY_DOWN if result is None: result, error = deliver(settings, subject, body, recipients, test=True) sent_id = record( conn, event_type=event_type, subject=subject, recipients=recipients, result=result, error=error, test=True, requested_by=requested_by, ) return { "result": result, "subject": subject, "recipients": [r.as_json() for r in recipients], "error": error, "sent_id": sent_id, }