Initial commit: CI Agent competitive-intelligence monitoring app

FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring,
source collection, LLM-based change analysis, enrichment, and account
security (Turnstile, escalating lockout, email verification).
This commit is contained in:
2026-08-05 10:48:20 -04:00
commit 1a4c80958f
365 changed files with 43541 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
"""Notification provider interface. Every alert dispatch (app/services/
alert_service.py) and every "test destination" action goes through this
Protocol, never a specific vendor SDK - swapping providers or adding a new
one doesn't touch the dispatch logic.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class NotificationMessage:
destination_value: str
subject: str
body_text: str
body_html: str | None = None
@dataclass(frozen=True)
class DeliveryResult:
success: bool
external_message_id: str | None = None
error: str | None = None
class NotificationProvider(Protocol):
provider_name: str
async def send(self, message: NotificationMessage) -> DeliveryResult: ...
+23
View File
@@ -0,0 +1,23 @@
"""Console provider: logs the notification instead of sending it anywhere.
Used in local dev fallback and wherever a destination type isn't yet wired
to a real transport."""
from __future__ import annotations
from app.core.logging import get_logger
from app.notifications.base import DeliveryResult, NotificationMessage
logger = get_logger(__name__)
class ConsoleProvider:
provider_name = "console"
async def send(self, message: NotificationMessage) -> DeliveryResult:
logger.info(
"console_notification",
destination=message.destination_value,
subject=message.subject,
body=message.body_text,
)
return DeliveryResult(success=True)
+31
View File
@@ -0,0 +1,31 @@
"""Resolves a NotificationType to a concrete provider. Console is always
available and never costs anything; email/SMS are only meaningfully
configured when their settings are present (callers gate on
NOTIFICATION_SMS_ENABLED before ever routing to SMS - see alert_service.py).
SMS has two interchangeable vendor providers (`SMS_PROVIDER=twilio|telnyx`),
same pattern as LLM_PROVIDER/SEARCH_PROVIDER elsewhere in the app - swapping
the setting changes which class this returns and nothing else has to change.
"""
from __future__ import annotations
from app.core.config import Settings
from app.models.enums import NotificationType
from app.notifications.base import NotificationProvider
from app.notifications.console import ConsoleProvider
from app.notifications.smtp_email import SmtpEmailProvider
from app.notifications.telnyx_sms import TelnyxSmsProvider
from app.notifications.twilio_sms import TwilioSmsProvider
def get_notification_provider(
notification_type: NotificationType, settings: Settings
) -> NotificationProvider:
if notification_type == NotificationType.EMAIL:
return SmtpEmailProvider(settings)
if notification_type == NotificationType.SMS:
if settings.sms_provider == "telnyx":
return TelnyxSmsProvider(settings)
return TwilioSmsProvider(settings)
return ConsoleProvider()
@@ -0,0 +1,68 @@
"""Builds the actual email/SMS/console message bodies for an alert. Kept
separate from alert_service.py's dispatch loop so the message format can be
tested in isolation.
"""
from __future__ import annotations
from app.core.config import Settings
from app.models.alert import Alert
from app.models.company import Company
from app.models.enums import NotificationType
from app.notifications.base import NotificationMessage
def build_alert_message(
notification_type: NotificationType,
destination_value: str,
company: Company,
alert: Alert,
settings: Settings,
) -> NotificationMessage:
dashboard_link = f"{settings.frontend_url}/alerts"
if notification_type == NotificationType.SMS:
text = (
f"CI Alert [{alert.severity.value.upper()}]: {alert.title}. "
f"Confidence {alert.confidence:.0%}. View details: {dashboard_link}"
)
return NotificationMessage(
destination_value=destination_value, subject=alert.title, body_text=text[:480]
)
subject = f"[{alert.severity.value.upper()}] {company.name}: {alert.title}"
text_lines = [
f"Company: {company.name}",
f"Alert: {alert.title}",
f"Severity: {alert.severity.value.title()}",
f"Confidence: {alert.confidence:.0%}",
"",
"What changed:",
alert.summary,
"",
"Why it matters:",
alert.why_it_matters,
"",
f"View in dashboard: {dashboard_link}",
f"Manage notification preferences: {settings.frontend_url}/settings",
]
body_text = "\n".join(text_lines)
body_html = (
f"<h2>{subject}</h2>"
f"<p><strong>Company:</strong> {company.name}<br>"
f"<strong>Severity:</strong> {alert.severity.value.title()}<br>"
f"<strong>Confidence:</strong> {alert.confidence:.0%}</p>"
f"<p><strong>What changed:</strong><br>{alert.summary}</p>"
f"<p><strong>Why it matters:</strong><br>{alert.why_it_matters}</p>"
f'<p><a href="{dashboard_link}">View in dashboard</a></p>'
f'<p style="color:#666;font-size:12px">'
f'<a href="{settings.frontend_url}/settings">Manage notification preferences</a></p>'
)
return NotificationMessage(
destination_value=destination_value,
subject=subject,
body_text=body_text,
body_html=body_html,
)
@@ -0,0 +1,50 @@
"""Resend HTTP API email provider - a separate transport from SmtpEmailProvider
(which also happens to point at Resend's SMTP relay for alert notifications
in this deployment, but that's a different concern/sender identity; see
app/services/security_email_service.py). Fixed, trusted first-party vendor
endpoint - no SSRF guard needed, same reasoning as the NinjaPear/USPTO calls
elsewhere in this app.
"""
from __future__ import annotations
import httpx
from app.core.config import Settings
from app.core.logging import get_logger
from app.notifications.base import DeliveryResult, NotificationMessage
logger = get_logger(__name__)
_RESEND_API_URL = "https://api.resend.com/emails"
class ResendEmailProvider:
provider_name = "resend"
def __init__(self, settings: Settings) -> None:
self._settings = settings
async def send(self, message: NotificationMessage) -> DeliveryResult:
payload = {
"from": self._settings.resend_security_from_email,
"to": [message.destination_value],
"subject": message.subject,
"text": message.body_text,
}
if message.body_html:
payload["html"] = message.body_html
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(
_RESEND_API_URL,
headers={"Authorization": f"Bearer {self._settings.resend_api_key}"},
json=payload,
)
response.raise_for_status()
data = response.json()
return DeliveryResult(success=True, external_message_id=data.get("id"))
except Exception as exc: # noqa: BLE001 - network/API failure path
logger.error("resend_send_failed", error=str(exc))
return DeliveryResult(success=False, error=str(exc))
+53
View File
@@ -0,0 +1,53 @@
"""SMTP email provider. Points at Mailpit in local dev (see docker-compose.yml)
and any real SMTP server in production - same code path either way. Uses
stdlib `smtplib` off the event loop via `asyncio.to_thread` rather than
adding an async SMTP dependency.
"""
from __future__ import annotations
import asyncio
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from app.core.config import Settings
from app.core.logging import get_logger
from app.notifications.base import DeliveryResult, NotificationMessage
logger = get_logger(__name__)
class SmtpEmailProvider:
provider_name = "smtp"
def __init__(self, settings: Settings) -> None:
self._settings = settings
async def send(self, message: NotificationMessage) -> DeliveryResult:
try:
await asyncio.to_thread(self._send_sync, message)
return DeliveryResult(success=True)
except Exception as exc: # pragma: no cover - network/SMTP failure path
logger.error("smtp_send_failed", error=str(exc))
return DeliveryResult(success=False, error=str(exc))
def _send_sync(self, message: NotificationMessage) -> None:
mime_message = MIMEMultipart("alternative")
mime_message["Subject"] = message.subject
mime_message["From"] = self._settings.smtp_from_email
mime_message["To"] = message.destination_value
mime_message.attach(MIMEText(message.body_text, "plain"))
if message.body_html:
mime_message.attach(MIMEText(message.body_html, "html"))
with smtplib.SMTP(self._settings.smtp_host, self._settings.smtp_port, timeout=10) as server:
if self._settings.smtp_use_tls:
server.starttls()
if self._settings.smtp_username:
server.login(self._settings.smtp_username, self._settings.smtp_password)
server.sendmail(
self._settings.smtp_from_email,
[message.destination_value],
mime_message.as_string(),
)
+50
View File
@@ -0,0 +1,50 @@
"""Telnyx SMS provider via Telnyx's Programmable Messaging REST API (no SDK
dependency - just a Bearer-authenticated POST), mirroring twilio_sms.py's
shape. No-ops with a clear error if Telnyx isn't configured; the caller
(alert_service) is what additionally gates on `NOTIFICATION_SMS_ENABLED`
before ever constructing this provider.
"""
from __future__ import annotations
import httpx
from app.core.config import Settings
from app.notifications.base import DeliveryResult, NotificationMessage
_API_URL = "https://api.telnyx.com/v2/messages"
class TelnyxSmsProvider:
provider_name = "telnyx_sms"
def __init__(self, settings: Settings) -> None:
self._settings = settings
async def send(self, message: NotificationMessage) -> DeliveryResult:
settings = self._settings
if not (settings.telnyx_api_key and settings.telnyx_from_number):
return DeliveryResult(success=False, error="Telnyx is not configured")
try:
async with httpx.AsyncClient(timeout=15) as client:
response = await client.post(
_API_URL,
headers={"Authorization": f"Bearer {settings.telnyx_api_key}"},
json={
"from": settings.telnyx_from_number,
"to": message.destination_value,
"text": message.body_text,
},
)
except httpx.HTTPError as exc:
return DeliveryResult(success=False, error=str(exc))
data = response.json()
if response.status_code >= 400:
errors = data.get("errors") or []
detail = errors[0].get("detail") if errors else response.text[:200]
return DeliveryResult(
success=False, error=f"Telnyx error {response.status_code}: {detail}"
)
return DeliveryResult(success=True, external_message_id=data.get("data", {}).get("id"))
+52
View File
@@ -0,0 +1,52 @@
"""Twilio SMS provider via Twilio's plain REST API (no SDK dependency -
just an authenticated POST). No-ops with a clear error if Twilio isn't
configured; the caller (alert_service) is what additionally gates on
`NOTIFICATION_SMS_ENABLED` before ever constructing this provider.
"""
from __future__ import annotations
import httpx
from app.core.config import Settings
from app.notifications.base import DeliveryResult, NotificationMessage
_API_BASE = "https://api.twilio.com/2010-04-01"
class TwilioSmsProvider:
provider_name = "twilio_sms"
def __init__(self, settings: Settings) -> None:
self._settings = settings
async def send(self, message: NotificationMessage) -> DeliveryResult:
settings = self._settings
if not (
settings.twilio_account_sid
and settings.twilio_auth_token
and settings.twilio_from_number
):
return DeliveryResult(success=False, error="Twilio is not configured")
url = f"{_API_BASE}/Accounts/{settings.twilio_account_sid}/Messages.json"
try:
async with httpx.AsyncClient(timeout=15) as client:
response = await client.post(
url,
auth=(settings.twilio_account_sid, settings.twilio_auth_token),
data={
"From": settings.twilio_from_number,
"To": message.destination_value,
"Body": message.body_text,
},
)
except httpx.HTTPError as exc:
return DeliveryResult(success=False, error=str(exc))
if response.status_code >= 400:
return DeliveryResult(
success=False, error=f"Twilio error {response.status_code}: {response.text[:200]}"
)
data = response.json()
return DeliveryResult(success=True, external_message_id=data.get("sid"))