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
Empty file added backend/app/__init__.py
Empty file.
Empty file added backend/app/api/__init__.py
Empty file.
12 changes: 12 additions & 0 deletions backend/app/api/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""API router aggregator β€” mounts the email-notification endpoints."""

from __future__ import annotations

from fastapi import APIRouter

from app.api.v1.endpoints import notifications, webhooks

api_router = APIRouter()

api_router.include_router(notifications.router, prefix="/notifications", tags=["notifications"])
api_router.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks"])
Empty file added backend/app/api/v1/__init__.py
Empty file.
Empty file.
107 changes: 107 additions & 0 deletions backend/app/api/v1/endpoints/notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Notification endpoints.

GET /api/notifications/preferences -> preferences for a user
PUT /api/notifications/preferences -> update preferences
POST /api/notifications/send -> trigger a notification (test/admin)

The user is identified via the ``X-User-Id`` header (or the OAuth ``sub`` claim
when running behind the full auth stack). This keeps the notification module
self-contained so it can be dropped into the backend without a hard dependency
on the auth router.
"""

from __future__ import annotations

from fastapi import APIRouter, Header, Response

from app.email_service import email_service
from app.models.email import (
PreferenceResponse,
PreferenceUpdate,
SendNotificationRequest,
)
from app.services.preference_store import preference_store

router = APIRouter()


def _user_id(x_user_id: str | None) -> str:
return x_user_id or "anonymous"


@router.get("/preferences", response_model=PreferenceResponse)
async def get_preferences(x_user_id: str | None = Header(default=None)):
user_id = _user_id(x_user_id)
pref = preference_store.get(user_id)
return PreferenceResponse(
email=pref.get("email"),
frequency=pref.get("frequency", "instant"),
notify_new_bounty=pref.get("notify_new_bounty", True),
notify_status_update=pref.get("notify_status_update", True),
notify_payout=pref.get("notify_payout", True),
digest_day=pref.get("digest_day"),
updated_at=None,
)


@router.put("/preferences", response_model=PreferenceResponse)
async def update_preferences(
body: PreferenceUpdate, x_user_id: str | None = Header(default=None)
):
user_id = _user_id(x_user_id)
pref = preference_store.upsert(user_id, body)
return PreferenceResponse(
email=pref.get("email"),
frequency=pref.get("frequency", "instant"),
notify_new_bounty=pref.get("notify_new_bounty", True),
notify_status_update=pref.get("notify_status_update", True),
notify_payout=pref.get("notify_payout", True),
digest_day=pref.get("digest_day"),
updated_at=None,
)


@router.post("/send")
async def send_notification(body: SendNotificationRequest):
"""Trigger a notification (used by the admin panel / test harness).

In production the bounty create/update/complete flows call
:func:`app.services.notify.notify_bounty_event` directly; this endpoint
exists for manual testing and observability.
"""
if body.notification_type.value == "new_bounty":
await email_service.send_new_bounty_notification(
to=str(body.to),
username=body.username,
bounty_title=body.bounty_title,
bounty_tier=body.bounty_tier,
reward=body.reward,
skills=body.skills,
bounty_url=body.bounty_url,
)
elif body.notification_type.value == "status_update":
await email_service.send_status_update(
to=str(body.to),
username=body.username,
bounty_title=body.bounty_title,
status=body.status,
details=body.details,
bounty_url=body.bounty_url,
)
elif body.notification_type.value == "payout":
await email_service.send_payout_notification(
to=str(body.to),
username=body.username,
bounty_title=body.bounty_title,
amount=body.reward,
tx_url=body.tx_url,
)
else:
await email_service.send_weekly_digest(
to=str(body.to),
username=body.username,
new_bounties=body.new_bounties,
completed_bounties=body.completed_bounties,
bounties_url=body.bounty_url,
)
return Response(status_code=202)
68 changes: 68 additions & 0 deletions backend/app/api/v1/endpoints/webhooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""SendGrid inbound event webhook.

Receives delivery events (bounce, dropped, delivered, open, click) so the
system can track email delivery health. The route is

POST /api/webhooks/sendgrid/events

and is called by SendGrid's Event Webhook when the customer has configured
the webhook URL in the SendGrid dashboard to point to this endpoint.
"""

from __future__ import annotations

import logging
from typing import Sequence

from fastapi import APIRouter, BackgroundTasks, Request

from app.models.email import DeliveryEvent

logger = logging.getLogger(__name__)

router = APIRouter()


@router.post("/sendgrid/events")
async def sendgrid_event_webhook(
request: Request,
background_tasks: BackgroundTasks,
):
"""Receive a batch of SendGrid delivery events."""
try:
events: Sequence[dict] = await request.json()
except Exception:
logger.warning("sendgrid webhook: invalid JSON body")
return {"status": "accepted"}

background_tasks.add_task(_process_events, events)
return {"status": "accepted"}


def _process_events(events: Sequence[dict]) -> None:
"""Process delivery events in the background."""
for raw in events:
try:
event = DeliveryEvent(**raw)
except Exception:
logger.debug("sendgrid webhook: skipping invalid event %s", raw)
continue

if event.event in ("bounce", "dropped"):
logger.warning(
"email %s event=%s email=%s reason=%s status=%s",
event.sg_message_id,
event.event,
event.email,
event.reason,
event.status,
)
elif event.event in ("delivered", "open", "click"):
logger.debug(
"email %s event=%s email=%s",
event.sg_message_id,
event.event,
event.email,
)
else:
logger.debug("email event=%s email=%s", event.event, event.email)
142 changes: 142 additions & 0 deletions backend/app/email_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Email notification service for SolFoundry bounty updates.

Sends notifications via SendGrid when configured, otherwise falls back to
logging the outbound email (development mode). Uses httpx (already a
backend dependency) for the SendGrid REST API.
"""

from __future__ import annotations

import logging
import os
from typing import Optional, Sequence

import httpx

from app.email_templates import (
bounty_status_email,
digest_email,
new_bounty_email,
payout_email,
)

logger = logging.getLogger(__name__)

SENDGRID_API_URL = "https://api.sendgrid.com/v3/mail/send"


class EmailService:
"""Sends HTML email via SendGrid (or logs in dev mode)."""

def __init__(self) -> None:
self.from_email = os.getenv("FROM_EMAIL", "noreply@solfoundry.xyz")
self.api_key = os.getenv("SENDGRID_API_KEY", "")

@property
def enabled(self) -> bool:
return bool(self.api_key)

async def send_email(
self,
to: str,
subject: str,
html_body: str,
tracking_id: Optional[str] = None,
) -> bool:
"""Deliver an HTML email. Returns True on success (or dev-mode log)."""
if not self.enabled:
logger.info("[DEV] email to=%s subject=%s", to, subject)
return True

payload = {
"personalizations": [{"to": [{"email": to}]}],
"from": {"email": self.from_email, "name": "SolFoundry"},
"subject": subject,
"content": [{"type": "text/html", "value": html_body}],
}
if tracking_id:
payload["custom_args"] = {"tracking_id": tracking_id}

try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
SENDGRID_API_URL,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
},
json=payload,
)
if resp.status_code in (200, 202):
logger.info("sent email to=%s tracking=%s", to, tracking_id)
return True
logger.error(
"sendgrid error status=%s body=%s", resp.status_code, resp.text
)
return False
except httpx.HTTPError as exc:
logger.error("failed to send email to=%s: %s", to, exc)
return False

async def send_new_bounty_notification(
self,
to: str,
username: str,
bounty_title: str,
bounty_tier: str,
reward: str,
skills: Sequence[str],
bounty_url: str,
tracking_id: Optional[str] = None,
) -> bool:
html = new_bounty_email(username, bounty_title, bounty_tier, reward, skills, bounty_url)
return await self.send_email(
to, f"πŸ”¨ New Bounty: {bounty_title}", html, tracking_id=tracking_id
)

async def send_status_update(
self,
to: str,
username: str,
bounty_title: str,
status: str,
details: str,
bounty_url: str,
tracking_id: Optional[str] = None,
) -> bool:
html = bounty_status_email(username, bounty_title, status, details, bounty_url)
return await self.send_email(
to, f"πŸ“‹ Update: {bounty_title}", html, tracking_id=tracking_id
)

async def send_payout_notification(
self,
to: str,
username: str,
bounty_title: str,
amount: str,
tx_url: str,
tracking_id: Optional[str] = None,
) -> bool:
html = payout_email(username, bounty_title, amount, tx_url)
return await self.send_email(
to, f"πŸ’° Payout: {amount}", html, tracking_id=tracking_id
)

async def send_weekly_digest(
self,
to: str,
username: str,
new_bounties: Sequence[dict],
completed_bounties: Sequence[dict],
bounties_url: str,
digest_type: str = "weekly",
tracking_id: Optional[str] = None,
) -> bool:
html = digest_email(username, new_bounties, completed_bounties, bounties_url, digest_type)
return await self.send_email(
to, f"πŸ“Š {digest_type.title()} Digest", html, tracking_id=tracking_id
)


email_service = EmailService()
Loading
Loading