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)