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
Original file line number Diff line number Diff line change
@@ -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")
5 changes: 5 additions & 0 deletions services/hackbot-api/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions services/hackbot-api/app/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
118 changes: 118 additions & 0 deletions services/hackbot-api/app/notifications.py
Original file line number Diff line number Diff line change
@@ -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 = "<br>".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"<p>{html_text}</p>"),
)


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)
13 changes: 12 additions & 1 deletion services/hackbot-api/app/routers/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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))

Expand All @@ -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=[],
)
Expand Down Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions services/hackbot-api/app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions services/hackbot-api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
136 changes: 136 additions & 0 deletions services/hackbot-api/tests/test_notifications.py
Original file line number Diff line number Diff line change
@@ -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)