From ec3967929f1e470432fec04003ba93dbe8433697 Mon Sep 17 00:00:00 2001 From: Riya Rani Date: Fri, 3 Jul 2026 04:22:42 +0530 Subject: [PATCH] hackbot-api: add email notifications when a run finishes (#6269) --- .../7f3a9d21b6c4_add_notify_email_to_runs.py | 28 ++++ services/hackbot-api/app/config.py | 5 + services/hackbot-api/app/database/models.py | 1 + services/hackbot-api/app/notifications.py | 118 +++++++++++++++ services/hackbot-api/app/routers/runs.py | 13 +- services/hackbot-api/app/schemas.py | 1 + services/hackbot-api/pyproject.toml | 1 + .../hackbot-api/tests/test_notifications.py | 136 ++++++++++++++++++ 8 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 services/hackbot-api/alembic/versions/7f3a9d21b6c4_add_notify_email_to_runs.py create mode 100644 services/hackbot-api/app/notifications.py create mode 100644 services/hackbot-api/tests/test_notifications.py diff --git a/services/hackbot-api/alembic/versions/7f3a9d21b6c4_add_notify_email_to_runs.py b/services/hackbot-api/alembic/versions/7f3a9d21b6c4_add_notify_email_to_runs.py new file mode 100644 index 0000000000..ddc76b0056 --- /dev/null +++ b/services/hackbot-api/alembic/versions/7f3a9d21b6c4_add_notify_email_to_runs.py @@ -0,0 +1,28 @@ +"""Add notify_email to runs. + +Revision ID: 7f3a9d21b6c4 +Revises: b5b896e1ce12 +Create Date: 2026-07-01 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "7f3a9d21b6c4" +down_revision: Union[str, Sequence[str], None] = "b5b896e1ce12" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column("runs", sa.Column("notify_email", sa.String(), nullable=True)) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("runs", "notify_email") diff --git a/services/hackbot-api/app/config.py b/services/hackbot-api/app/config.py index 8975be89d1..ceb1737e3a 100644 --- a/services/hackbot-api/app/config.py +++ b/services/hackbot-api/app/config.py @@ -21,6 +21,11 @@ class Settings(BaseSettings): # API auth external_api_key: str = "" + # Email notifications (optional; notifications are skipped if unset) + sendgrid_api_key: str = "" + notification_sender_email: str = "" + hackbot_ui_base_url: str = "" + # Server port: int = 8080 environment: str = "development" diff --git a/services/hackbot-api/app/database/models.py b/services/hackbot-api/app/database/models.py index 0f566fb035..a470552659 100644 --- a/services/hackbot-api/app/database/models.py +++ b/services/hackbot-api/app/database/models.py @@ -20,6 +20,7 @@ class Run(Base): agent: Mapped[str] = mapped_column(String, nullable=False, index=True) status: Mapped[str] = mapped_column(String, nullable=False, index=True) inputs: Mapped[dict] = mapped_column(JSONB, nullable=False) + notify_email: Mapped[str | None] = mapped_column(String, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), diff --git a/services/hackbot-api/app/notifications.py b/services/hackbot-api/app/notifications.py new file mode 100644 index 0000000000..dd831bfe47 --- /dev/null +++ b/services/hackbot-api/app/notifications.py @@ -0,0 +1,118 @@ +"""Email notifications for completed agent runs. + +Sends a best-effort email via SendGrid when a run reaches a terminal +status (succeeded / failed / timed_out), so a developer doesn't have to +keep polling ``GET /runs/{run_id}`` to find out an agent finished. + +This is entirely optional: if ``SENDGRID_API_KEY`` or +``NOTIFICATION_SENDER_EMAIL`` aren't configured, or a run was created +without a ``notify_email``, this module is a no-op. Any failure to +send (bad API key, SendGrid outage, etc.) is logged and swallowed -- +a broken notification must never fail run reconciliation. +""" + +import asyncio +import html +import logging +import re +from functools import lru_cache +from uuid import UUID + +import sendgrid +from sendgrid.helpers.mail import Content, From, HtmlContent, Mail, Subject, To + +from app.config import settings +from app.database.models import Run + +log = logging.getLogger(__name__) + +# Deliberately permissive: this only guards against obviously malformed +# input at run-creation time. SendGrid itself validates deliverability. +_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + +_STATUS_VERBS = { + "succeeded": "finished successfully", + "failed": "failed", + "timed_out": "timed out", +} + + +def is_valid_email(value: str) -> bool: + """Return whether ``value`` looks like a plausible email address.""" + return bool(_EMAIL_RE.match(value)) + + +def build_run_url(run_id: UUID) -> str | None: + """Build a link to the run's results page, or None if unconfigured.""" + base_url = settings.hackbot_ui_base_url.rstrip("/") + if not base_url: + return None + return f"{base_url}/runs/{run_id}" + + +@lru_cache(maxsize=1) +def _client() -> sendgrid.SendGridAPIClient: + return sendgrid.SendGridAPIClient(api_key=settings.sendgrid_api_key) + + +def _status_verb(status: str) -> str: + return _STATUS_VERBS.get(status, status) + + +def _build_lines(run: Run) -> list[str]: + lines = [ + f"Agent: {run.agent}", + f"Run ID: {run.run_id}", + f"Status: {run.status}", + ] + if run.error: + lines.append(f"Error: {run.error}") + run_url = build_run_url(run.run_id) + if run_url: + lines.append(f"Results: {run_url}") + return lines + + +def _build_message(run: Run) -> Mail: + lines = _build_lines(run) + plain_text = "\n".join(lines) + html_text = "
".join(html.escape(line) for line in lines) + + return Mail( + From(settings.notification_sender_email), + To(run.notify_email), + Subject(f"[hackbot] {run.agent} run {_status_verb(run.status)}"), + Content("text/plain", plain_text), + HtmlContent(f"

{html_text}

"), + ) + + +def _send_sync(run: Run) -> None: + message = _build_message(run) + response = _client().send(message=message) + log.info( + "Sent run-completion email for run %s to %s (status code %s)", + run.run_id, + run.notify_email, + response.status_code, + ) + + +async def notify_run_complete(run: Run) -> None: + """Best-effort email notification for a run that just went terminal.""" + if not settings.sendgrid_api_key: + return + if not run.notify_email: + return + if not settings.notification_sender_email: + log.warning( + "SENDGRID_API_KEY is set but NOTIFICATION_SENDER_EMAIL is not; " + "skipping notification for run %s", + run.run_id, + ) + return + + try: + await asyncio.to_thread(_send_sync, run) + except Exception: + log.exception("Failed to send completion email for run %s", run.run_id) diff --git a/services/hackbot-api/app/routers/runs.py b/services/hackbot-api/app/routers/runs.py index ff55d466e8..fe5ee7dcc6 100644 --- a/services/hackbot-api/app/routers/runs.py +++ b/services/hackbot-api/app/routers/runs.py @@ -6,7 +6,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app import gcs, jobs +from app import gcs, jobs, notifications from app.agents import AGENT_REGISTRY, AgentSpec, model_to_env from app.auth import require_api_key from app.config import settings @@ -53,6 +53,10 @@ async def list_agents() -> list[AgentDescriptor]: async def create_run( agent_name: str, payload: dict, + notify_email: str | None = Query( + default=None, + description="Optional email address to notify when this run finishes.", + ), db: AsyncSession = Depends(get_db), ) -> RunRef: agent = _lookup_agent(agent_name) @@ -61,6 +65,11 @@ async def create_run( except ValueError as exc: raise HTTPException(status_code=422, detail=str(exc)) from exc + if notify_email is not None and not notifications.is_valid_email(notify_email): + raise HTTPException( + status_code=422, detail=f"Invalid notify_email: {notify_email!r}" + ) + run_id = uuid.uuid4() results_prefix = gcs.run_prefix(str(run_id)) @@ -71,6 +80,7 @@ async def create_run( agent=agent.name, status=RunStatus.pending.value, inputs=inputs.model_dump(mode="json"), + notify_email=notify_email, results_prefix=results_prefix, artifacts=[], ) @@ -192,6 +202,7 @@ async def _reconcile(db: AsyncSession, run: Run) -> None: run.error = error await db.commit() + await notifications.notify_run_complete(run) def _terminal_status( diff --git a/services/hackbot-api/app/schemas.py b/services/hackbot-api/app/schemas.py index 3b01e033cf..b4ea6f49ea 100644 --- a/services/hackbot-api/app/schemas.py +++ b/services/hackbot-api/app/schemas.py @@ -50,6 +50,7 @@ class RunDoc(BaseModel): agent: str status: RunStatus inputs: dict[str, Any] + notify_email: str | None = None created_at: datetime updated_at: datetime execution_name: str | None = None diff --git a/services/hackbot-api/pyproject.toml b/services/hackbot-api/pyproject.toml index 69f555c7e1..3896962443 100644 --- a/services/hackbot-api/pyproject.toml +++ b/services/hackbot-api/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "google-cloud-storage>=2.16.0", "google-cloud-run>=0.10.0", "sentry-sdk>=2.51.0", + "sendgrid>=6.12.5", ] [project.optional-dependencies] diff --git a/services/hackbot-api/tests/test_notifications.py b/services/hackbot-api/tests/test_notifications.py new file mode 100644 index 0000000000..b6c88c63b8 --- /dev/null +++ b/services/hackbot-api/tests/test_notifications.py @@ -0,0 +1,136 @@ +"""Tests for the email-notification module.""" + +import uuid +from unittest.mock import patch + +import pytest +from app import notifications +from app.config import settings +from app.database.models import Run + + +def make_run(**overrides) -> Run: + run = Run( + run_id=uuid.uuid4(), + agent="bug-fix", + status="succeeded", + inputs={}, + results_prefix="runs/x/", + artifacts=[], + ) + for key, value in overrides.items(): + setattr(run, key, value) + return run + + +@pytest.mark.parametrize( + "value,expected", + [ + ("dev@example.com", True), + ("dev+tag@example.co.uk", True), + ("not-an-email", False), + ("missing-domain@", False), + ("@missing-local.com", False), + ("has space@example.com", False), + ("", False), + ], +) +def test_is_valid_email(value, expected): + assert notifications.is_valid_email(value) is expected + + +def test_build_run_url_none_when_unconfigured(monkeypatch): + monkeypatch.setattr(settings, "hackbot_ui_base_url", "") + assert notifications.build_run_url(uuid.uuid4()) is None + + +def test_build_run_url_strips_trailing_slash(monkeypatch): + monkeypatch.setattr(settings, "hackbot_ui_base_url", "https://hackbot.example/") + run_id = uuid.uuid4() + assert ( + notifications.build_run_url(run_id) == f"https://hackbot.example/runs/{run_id}" + ) + + +def test_build_lines_includes_error_and_run_url(monkeypatch): + monkeypatch.setattr(settings, "hackbot_ui_base_url", "https://hackbot.example") + run = make_run(notify_email="dev@example.com", status="failed", error="boom") + + lines = notifications._build_lines(run) + + assert "Status: failed" in lines + assert "Error: boom" in lines + assert f"Results: https://hackbot.example/runs/{run.run_id}" in lines + + +def test_build_lines_omits_error_and_url_when_absent(monkeypatch): + monkeypatch.setattr(settings, "hackbot_ui_base_url", "") + run = make_run(notify_email="dev@example.com", status="succeeded", error=None) + + lines = notifications._build_lines(run) + + assert not any(line.startswith("Error:") for line in lines) + assert not any(line.startswith("Results:") for line in lines) + + +def test_build_message_smoke(monkeypatch): + monkeypatch.setattr(settings, "notification_sender_email", "hackbot@example.com") + run = make_run(notify_email="dev@example.com") + + # Must not raise -- exercises the sendgrid Mail() construction path. + message = notifications._build_message(run) + + assert message is not None + + +async def test_notify_run_complete_noop_without_api_key(monkeypatch): + monkeypatch.setattr(settings, "sendgrid_api_key", "") + run = make_run(notify_email="dev@example.com") + + with patch.object(notifications, "_send_sync") as send: + await notifications.notify_run_complete(run) + + send.assert_not_called() + + +async def test_notify_run_complete_noop_without_notify_email(monkeypatch): + monkeypatch.setattr(settings, "sendgrid_api_key", "SG.fake") + monkeypatch.setattr(settings, "notification_sender_email", "hackbot@example.com") + run = make_run(notify_email=None) + + with patch.object(notifications, "_send_sync") as send: + await notifications.notify_run_complete(run) + + send.assert_not_called() + + +async def test_notify_run_complete_noop_without_sender(monkeypatch): + monkeypatch.setattr(settings, "sendgrid_api_key", "SG.fake") + monkeypatch.setattr(settings, "notification_sender_email", "") + run = make_run(notify_email="dev@example.com") + + with patch.object(notifications, "_send_sync") as send: + await notifications.notify_run_complete(run) + + send.assert_not_called() + + +async def test_notify_run_complete_sends_when_configured(monkeypatch): + monkeypatch.setattr(settings, "sendgrid_api_key", "SG.fake") + monkeypatch.setattr(settings, "notification_sender_email", "hackbot@example.com") + run = make_run(notify_email="dev@example.com", status="failed", error="boom") + + with patch.object(notifications, "_send_sync") as send: + await notifications.notify_run_complete(run) + + send.assert_called_once_with(run) + + +async def test_notify_run_complete_swallows_send_errors(monkeypatch): + monkeypatch.setattr(settings, "sendgrid_api_key", "SG.fake") + monkeypatch.setattr(settings, "notification_sender_email", "hackbot@example.com") + run = make_run(notify_email="dev@example.com") + + with patch.object(notifications, "_send_sync", side_effect=RuntimeError("boom")): + # Must not raise -- a broken notification must never break reconciliation. + await notifications.notify_run_complete(run)