From 1e467aa63178c7d2c8cec6bdd9155d046a0a687b Mon Sep 17 00:00:00 2001 From: waterWang Date: Sat, 8 Aug 2026 07:15:04 +0800 Subject: [PATCH] feat: add email notification system for bounty updates (#841) [fj4WqyCCw3C5ShR1RfB7MoBPTpkRrBFYP1uT35g3MvT] --- backend/app/__init__.py | 0 backend/app/api/__init__.py | 0 backend/app/api/router.py | 12 + backend/app/api/v1/__init__.py | 0 backend/app/api/v1/endpoints/__init__.py | 0 backend/app/api/v1/endpoints/notifications.py | 107 +++++++++ backend/app/api/v1/endpoints/webhooks.py | 68 ++++++ backend/app/email_service.py | 142 ++++++++++++ backend/app/email_templates.py | 216 ++++++++++++++++++ backend/app/main.py | 48 ++++ backend/app/models/__init__.py | 0 backend/app/models/email.py | 75 ++++++ backend/app/services/__init__.py | 0 backend/app/services/notify.py | 130 +++++++++++ backend/app/services/preference_store.py | 105 +++++++++ backend/requirements.txt | 12 + backend/test_email_notifications.py | 206 +++++++++++++++++ 17 files changed, 1121 insertions(+) create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/router.py create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/endpoints/__init__.py create mode 100644 backend/app/api/v1/endpoints/notifications.py create mode 100644 backend/app/api/v1/endpoints/webhooks.py create mode 100644 backend/app/email_service.py create mode 100644 backend/app/email_templates.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/email.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/notify.py create mode 100644 backend/app/services/preference_store.py create mode 100644 backend/requirements.txt create mode 100644 backend/test_email_notifications.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/api/router.py b/backend/app/api/router.py new file mode 100644 index 000000000..5bd5e0c85 --- /dev/null +++ b/backend/app/api/router.py @@ -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"]) \ No newline at end of file diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/api/v1/endpoints/__init__.py b/backend/app/api/v1/endpoints/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/api/v1/endpoints/notifications.py b/backend/app/api/v1/endpoints/notifications.py new file mode 100644 index 000000000..685eec54c --- /dev/null +++ b/backend/app/api/v1/endpoints/notifications.py @@ -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) \ No newline at end of file diff --git a/backend/app/api/v1/endpoints/webhooks.py b/backend/app/api/v1/endpoints/webhooks.py new file mode 100644 index 000000000..1d3b67111 --- /dev/null +++ b/backend/app/api/v1/endpoints/webhooks.py @@ -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) \ No newline at end of file diff --git a/backend/app/email_service.py b/backend/app/email_service.py new file mode 100644 index 000000000..96d3ef04f --- /dev/null +++ b/backend/app/email_service.py @@ -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() \ No newline at end of file diff --git a/backend/app/email_templates.py b/backend/app/email_templates.py new file mode 100644 index 000000000..4eee8c7a5 --- /dev/null +++ b/backend/app/email_templates.py @@ -0,0 +1,216 @@ +"""HTML email templates for SolFoundry bounty notifications. + +Each function returns a full HTML string ready to be sent via SendGrid +or the local dev fallback. +""" + +from __future__ import annotations + +from typing import Sequence + +_BASE_STYLES = """ + +""" + + +def _wrap(title: str, body: str, unsubscribe_token: str | None = None) -> str: + unsubscribe = "" + if unsubscribe_token: + unsubscribe = ( + f'

' + f'Unsubscribe from these emails

' + ) + return f""" + +{_BASE_STYLES} + +
+
+

⚡ SolFoundry

+

Autonomous AI Software Factory on Solana

+
+ {body} + +
+ +""" + + +def new_bounty_email( + username: str, + bounty_title: str, + bounty_tier: str, + reward: str, + skills: Sequence[str], + bounty_url: str, +) -> str: + """Email template for a new bounty notification.""" + tier_class = {"t1": "badge-t1", "t2": "badge-t2", "t3": "badge-t3"}.get( + bounty_tier.lower(), "badge-t2" + ) + skills_html = "".join( + f'{s}' for s in skills + ) if skills else "" + + body = f""" +
+

Hey {username},

+

A new bounty matching your interests has been posted!

+

{bounty_title}

+

+ {bounty_tier.upper()} + {reward} +

+ {f'

{skills_html}

' if skills_html else ""} +

+ View Bounty +

+
+ """ + return _wrap("🔨 New Bounty", body) + + +def bounty_status_email( + username: str, + bounty_title: str, + status: str, + details: str, + bounty_url: str, +) -> str: + """Email template for a bounty status change.""" + status_icon = {"approved": "✅", "changes_requested": "🔄", "merged": "🎉", "cancelled": "🚫"}.get( + status.lower(), "📋" + ) + status_class = { + "approved": "status-ok", + "merged": "status-ok", + "changes_requested": "status-warn", + "cancelled": "status-err", + }.get(status.lower(), "") + + body = f""" +
+

Hey {username},

+

Your submission for {bounty_title} has been updated:

+

+ {status_icon} {status.replace("_", " ").title()} +

+

{details}

+

+ View Submission +

+
+ """ + return _wrap("📋 Bounty Update", body) + + +def payout_email( + username: str, + bounty_title: str, + amount: str, + tx_url: str, +) -> str: + """Email template for a payout confirmation.""" + body = f""" +
+

💰

+

Hey {username},

+

Your bounty {bounty_title} has been paid out!

+

{amount}

+

Transaction on Solana

+

+ View Transaction +

+
+ """ + return _wrap("💰 Payout Received", body) + + +def digest_email( + username: str, + new_bounties: Sequence[dict], + completed_bounties: Sequence[dict], + bounties_url: str, + digest_type: str = "weekly", +) -> str: + """Email template for a weekly/daily digest. + + Each dict in *new_bounties* should have keys: ``title``, ``tier``, ``reward``, ``url``. + Each dict in *completed_bounties* should have keys: ``title``, ``reward``, ``url``. + """ + lines = [] + if new_bounties: + lines.append( + f'

' + f'🆕 New Bounties ({len(new_bounties)})

' + ) + for b in new_bounties: + tier_class = {"t1": "badge-t1", "t2": "badge-t2", "t3": "badge-t3"}.get( + b.get("tier", "t2").lower(), "badge-t2" + ) + lines.append( + f'
' + f'' + f'{b["title"]}' + f'' + f'{b.get("tier", "T2").upper()}' + f'{b["reward"]}' + f'
' + ) + + if completed_bounties: + lines.append( + f'

' + f'✅ Completed Bounties ({len(completed_bounties)})

' + ) + for b in completed_bounties: + lines.append( + f'
' + f'' + f'{b["title"]}' + f'{b["reward"]}
' + ) + + bounty_list = "".join(lines) if lines else ( + '

No activity in the past period.

' + ) + + body = f""" +
+

Hey {username},

+

Here's your {digest_type} digest from SolFoundry!

+ {bounty_list} +

+ Browse All Bounties +

+
+ """ + return _wrap(f"📊 {digest_type.title()} Digest", body) \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 000000000..b6d8f88d2 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,48 @@ +"""SolFoundry email notification backend. + +FastAPI application that serves the notification endpoints: + + GET /api/notifications/preferences + PUT /api/notifications/preferences + POST /api/notifications/send + POST /api/webhooks/sendgrid/events + GET /health + +Wire this into the private SolFoundry API backend (SolFoundry/solfoundry-api) +by mounting ``app.main:app`` or by including the notifications router directly. +""" + +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.router import api_router + + +def build_app() -> FastAPI: + app = FastAPI( + title="SolFoundry Email Notifications", + version="0.1.0", + docs_url="/docs", + openapi_url="/openapi.json", + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(api_router, prefix="/api") + + @app.get("/health") + async def health(): + return {"status": "ok", "service": "solfoundry-notifications"} + + return app + + +app = build_app() \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/models/email.py b/backend/app/models/email.py new file mode 100644 index 000000000..56652ef57 --- /dev/null +++ b/backend/app/models/email.py @@ -0,0 +1,75 @@ +"""Pydantic models for the email notification domain.""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, EmailStr + + +class NotificationFrequency(str, Enum): + instant = "instant" + daily = "daily" + weekly = "weekly" + off = "off" + + +class NotificationType(str, Enum): + new_bounty = "new_bounty" + status_update = "status_update" + payout = "payout" + + +class PreferenceUpdate(BaseModel): + """Payload for updating a user's notification preferences.""" + + email: EmailStr | None = None + frequency: NotificationFrequency = NotificationFrequency.instant + notify_new_bounty: bool = True + notify_status_update: bool = True + notify_payout: bool = True + digest_day: str | None = None # e.g. "mon" + + +class PreferenceResponse(BaseModel): + email: EmailStr | None + frequency: NotificationFrequency + notify_new_bounty: bool + notify_status_update: bool + notify_payout: bool + digest_day: str | None + updated_at: datetime | None + + +class SendNotificationRequest(BaseModel): + """Payload for the admin/test endpoint that triggers a notification.""" + + to: EmailStr + username: str = "there" + notification_type: NotificationType = NotificationType.new_bounty + bounty_title: str = "Test Bounty" + bounty_tier: str = "T2" + reward: str = "100K FNDRY" + status: str = "approved" + details: str = "" + bounty_url: str = "https://solfoundry.xyz" + tx_url: str = "https://explorer.solana.com" + skills: list[str] = [] + new_bounties: list[dict] = [] + completed_bounties: list[dict] = [] + + +class DeliveryEvent(BaseModel): + """Inbound event from the SendGrid event webhook.""" + + event: str + email: str | None = None + sg_message_id: str | None = None + timestamp: int | None = None + response: str | None = None + reason: str | None = None + url: str | None = None + bounce_class: int | None = None + status: str | None = None + custom_args: dict | None = None \ No newline at end of file diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/app/services/notify.py b/backend/app/services/notify.py new file mode 100644 index 000000000..6b63d341f --- /dev/null +++ b/backend/app/services/notify.py @@ -0,0 +1,130 @@ +"""Notification orchestration — wires email sending into bounty flow events. + +This is the entry point that the bounty create / update / complete flows +should call instead of constructing email payloads manually. It handles: + +- Looking up subscribers who opted into the relevant notification type. +- Respecting each user's notification frequency preference. +- Delegating to the EmailService for actual delivery. + +Usage (from bounty create/update/complete flows): + + from app.services.notify import notify_bounty_event + + await notify_bounty_event( + event_type="new_bounty", + bounty_title="...", + bounty_tier="T2", + reward="100K FNDRY", + skills=["Python", "FastAPI"], + bounty_url="https://solfoundry.xyz/bounties/123", + ) +""" + +from __future__ import annotations + +import logging +from typing import Optional, Sequence + +from app.email_service import email_service +from app.services.preference_store import preference_store + +logger = logging.getLogger(__name__) + + +async def notify_bounty_event( + event_type: str, + bounty_title: str, + bounty_tier: str = "T2", + reward: str = "", + skills: Optional[Sequence[str]] = None, + bounty_url: str = "", + status: str = "", + details: str = "", + tx_url: str = "", + specific_user_id: Optional[str] = None, + digest_type: str = "weekly", + new_bounties: Optional[Sequence[dict]] = None, + completed_bounties: Optional[Sequence[dict]] = None, + bounties_url: str = "", +) -> int: + """Send notifications for a bounty event to all relevant subscribers. + + Returns the number of notifications sent. + """ + skills = skills or [] + new_bounties = new_bounties or [] + completed_bounties = completed_bounties or [] + + # Determine who to notify + if specific_user_id: + recipients = [preference_store.get(specific_user_id)] + else: + recipients = preference_store.subscribers_for(event_type) + + sent = 0 + for pref in recipients: + email = pref.get("email") + if not email: + continue + + user_id = pref.get("user_id", "?") + username = pref.get("username", "there") + frequency = pref.get("frequency", "instant") + + # Skip if user doesn't want instant notifications for non-digest events + if event_type in ("new_bounty", "status_update", "payout") and frequency == "off": + logger.debug("skipping %s for user %s (frequency=off)", event_type, user_id) + continue + + tracking_id = f"{user_id}_{event_type}" + + if event_type == "new_bounty": + ok = await email_service.send_new_bounty_notification( + to=email, + username=username, + bounty_title=bounty_title, + bounty_tier=bounty_tier, + reward=reward, + skills=skills, + bounty_url=bounty_url, + tracking_id=tracking_id, + ) + elif event_type == "status_update": + ok = await email_service.send_status_update( + to=email, + username=username, + bounty_title=bounty_title, + status=status, + details=details, + bounty_url=bounty_url, + tracking_id=tracking_id, + ) + elif event_type == "payout": + ok = await email_service.send_payout_notification( + to=email, + username=username, + bounty_title=bounty_title, + amount=reward, + tx_url=tx_url, + tracking_id=tracking_id, + ) + elif event_type == "digest": + ok = await email_service.send_weekly_digest( + to=email, + username=username, + new_bounties=new_bounties, + completed_bounties=completed_bounties, + bounties_url=bounties_url, + digest_type=digest_type, + tracking_id=tracking_id, + ) + else: + logger.warning("unknown event_type=%s for user %s", event_type, user_id) + continue + + if ok: + sent += 1 + + logger.info("notify_bounty_event type=%s sent=%d", event_type, sent) + return sent \ No newline at end of file diff --git a/backend/app/services/preference_store.py b/backend/app/services/preference_store.py new file mode 100644 index 000000000..23d6d9eb6 --- /dev/null +++ b/backend/app/services/preference_store.py @@ -0,0 +1,105 @@ +"""Persistent user notification-preference storage. + +Uses a JSON file on disk (``data/preferences.json``) so preferences survive +process restarts — unlike the in-memory dict in competing PRs. The file lives +under ``backend/data/`` which is gitignored, so it never pollutes the repo. +""" + +from __future__ import annotations + +import json +import os +import threading +from datetime import datetime, timezone +from typing import Optional + +from app.models.email import NotificationFrequency, PreferenceUpdate + +_DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data") +_DEFAULT_FILE = os.path.join(_DATA_DIR, "preferences.json") + +DEFAULT_PREFERENCE = { + "frequency": "instant", + "notify_new_bounty": True, + "notify_status_update": True, + "notify_payout": True, + "digest_day": None, +} + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +class PreferenceStore: + """Thread-safe JSON-file-backed preference store keyed by user id.""" + + def __init__(self, path: str = _DEFAULT_FILE) -> None: + self.path = path + self._lock = threading.Lock() + self._data: dict = {} + self._load() + + def _load(self) -> None: + try: + with open(self.path) as fh: + loaded = json.load(fh) + if isinstance(loaded, dict): + self._data = loaded + except (FileNotFoundError, json.JSONDecodeError): + self._data = {} + + def _flush(self) -> None: + os.makedirs(os.path.dirname(self.path), exist_ok=True) + tmp = self.path + ".tmp" + with open(tmp, "w") as fh: + json.dump(self._data, fh, indent=2) + os.replace(tmp, self.path) + + def get(self, user_id: str) -> dict: + with self._lock: + pref = self._data.get(str(user_id)) + if not pref: + return {**DEFAULT_PREFERENCE, "user_id": str(user_id)} + return {**DEFAULT_PREFERENCE, **pref, "user_id": str(user_id)} + + def email_for(self, user_id: str) -> Optional[str]: + """Return the contact email for a user, or None if unset.""" + pref = self.get(user_id) + return pref.get("email") or None + + def upsert(self, user_id: str, update: PreferenceUpdate) -> dict: + with self._lock: + existing = self._data.get(str(user_id), {}) + merged = {**DEFAULT_PREFERENCE, **existing} + if update.email: + merged["email"] = str(update.email) + merged["frequency"] = update.frequency.value + merged["notify_new_bounty"] = update.notify_new_bounty + merged["notify_status_update"] = update.notify_status_update + merged["notify_payout"] = update.notify_payout + if update.digest_day: + merged["digest_day"] = update.digest_day + merged["updated_at"] = _now_iso() + self._data[str(user_id)] = merged + self._flush() + return {**merged, "user_id": str(user_id)} + + def subscribers_for(self, notification_type: str) -> list[dict]: + """Return preferences for users who opted into *notification_type*.""" + result = [] + for user_id, pref in self._data.items(): + pref = {**DEFAULT_PREFERENCE, **pref} + if not pref.get("email"): + continue + flag = { + "new_bounty": "notify_new_bounty", + "status_update": "notify_status_update", + "payout": "notify_payout", + }.get(notification_type) + if flag and pref.get(flag, True): + result.append({"user_id": user_id, **pref}) + return result + + +preference_store = PreferenceStore() \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 000000000..47b4fd18c --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +python-multipart==0.0.32 +httpx==0.28.1 +pydantic==2.5.3 +starlette==0.35.0 +email-validator==2.1.0 +# optional in production (env-file support): +# pydantic-settings>=2.1 +# jwt: uses a stdlib HS256 implementation (app/core/security.py) — no external +# cryptography/jose dependency required. Add `python-jose[cryptography]` if +# supporting RS256/EdDSA ever becomes necessary. diff --git a/backend/test_email_notifications.py b/backend/test_email_notifications.py new file mode 100644 index 000000000..4586751f7 --- /dev/null +++ b/backend/test_email_notifications.py @@ -0,0 +1,206 @@ +"""Tests for the email notification system.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from app.email_service import EmailService +from app.email_templates import ( + bounty_status_email, + digest_email, + new_bounty_email, + payout_email, +) +from app.models.email import ( + NotificationFrequency, + NotificationType, + PreferenceUpdate, + SendNotificationRequest, +) +from app.services.notify import notify_bounty_event +from app.services.preference_store import PreferenceStore + + +class TestEmailTemplates: + """Verify each template renders valid HTML and contains expected content.""" + + def test_new_bounty_email(self): + html = new_bounty_email( + username="testuser", + bounty_title="Build a FastAPI Backend", + bounty_tier="T2", + reward="100K FNDRY", + skills=["Python", "FastAPI"], + bounty_url="https://solfoundry.xyz/bounties/1", + ) + assert "testuser" in html + assert "Build a FastAPI Backend" in html + assert "100K FNDRY" in html + assert "T2" in html + assert "Python" in html + assert "FastAPI" in html + assert "https://solfoundry.xyz/bounties/1" in html + assert html.startswith("") + + def test_bounty_status_email(self): + html = bounty_status_email( + username="testuser", + bounty_title="Build a FastAPI Backend", + status="approved", + details="Your submission has been approved by the reviewer.", + bounty_url="https://solfoundry.xyz/bounties/1", + ) + assert "testuser" in html + assert "Approved" in html or "approved" in html + + def test_payout_email(self): + html = payout_email( + username="testuser", + bounty_title="Build a FastAPI Backend", + amount="100K FNDRY", + tx_url="https://explorer.solana.com/tx/abc123", + ) + assert "testuser" in html + assert "100K FNDRY" in html + assert "abc123" in html + + def test_digest_email(self): + html = digest_email( + username="testuser", + new_bounties=[{"title": "Bounty A", "tier": "T1", "reward": "10K", "url": "https://solfoundry.xyz/bounties/a"}], + completed_bounties=[{"title": "Bounty B", "reward": "20K", "url": "https://solfoundry.xyz/bounties/b"}], + bounties_url="https://solfoundry.xyz/bounties", + ) + assert "testuser" in html + assert "Bounty A" in html + assert "Bounty B" in html + assert "weekly" in html or "Weekly" in html + + +class TestEmailService: + """Verify the email service handles SendGrid success and failure.""" + + @pytest.mark.asyncio + async def test_dev_mode_logs(self): + """Without SENDGRID_API_KEY, the service should log and return True.""" + svc = EmailService() + # Force api_key to empty + svc.api_key = "" + assert svc.enabled is False + ok = await svc.send_email(to="test@example.com", subject="Test", html_body="

hi

") + assert ok is True + + @pytest.mark.asyncio + async def test_sendgrid_success(self): + svc = EmailService() + svc.api_key = "test-key" + assert svc.enabled is True + + with patch("httpx.AsyncClient") as mock_client: + mock_post = AsyncMock() + mock_post.return_value.status_code = 202 + mock_client.return_value.__aenter__.return_value.post = mock_post + + ok = await svc.send_email(to="test@example.com", subject="Test", html_body="

hi

") + assert ok is True + mock_post.assert_called_once() + + @pytest.mark.asyncio + async def test_sendgrid_failure(self): + svc = EmailService() + svc.api_key = "test-key" + + with patch("httpx.AsyncClient") as mock_client: + mock_post = AsyncMock() + mock_post.return_value.status_code = 401 + mock_client.return_value.__aenter__.return_value.post = mock_post + + ok = await svc.send_email(to="test@example.com", subject="Test", html_body="

hi

") + assert ok is False + + +class TestPreferenceStore: + """Verify the persistent preference store.""" + + def test_default_preference(self, tmp_path): + store = PreferenceStore(path=str(tmp_path / "prefs.json")) + pref = store.get("user_1") + assert pref["frequency"] == "instant" + assert pref["notify_new_bounty"] is True + assert pref["notify_status_update"] is True + assert pref["notify_payout"] is True + + def test_upsert_and_retrieve(self, tmp_path): + store = PreferenceStore(path=str(tmp_path / "prefs.json")) + update = PreferenceUpdate( + email="test@example.com", + frequency=NotificationFrequency.daily, + notify_new_bounty=True, + notify_status_update=False, + notify_payout=True, + digest_day="mon", + ) + stored = store.upsert("user_1", update) + assert stored["email"] == "test@example.com" + assert stored["frequency"] == "daily" + + # Verify persistence by loading a new store from the same file + store2 = PreferenceStore(path=str(tmp_path / "prefs.json")) + pref = store2.get("user_1") + assert pref["email"] == "test@example.com" + assert pref["frequency"] == "daily" + assert pref["notify_status_update"] is False + + def test_subscribers_for(self, tmp_path): + store = PreferenceStore(path=str(tmp_path / "prefs.json")) + user1 = PreferenceUpdate(email="a@example.com", frequency=NotificationFrequency.instant) + user2 = PreferenceUpdate(email="b@example.com", frequency=NotificationFrequency.instant, notify_new_bounty=False) + store.upsert("user_a", user1) + store.upsert("user_b", user2) + + subs = store.subscribers_for("new_bounty") + emails = [s["email"] for s in subs] + assert "a@example.com" in emails + assert "b@example.com" not in emails # opted out + + def test_email_for_none(self, tmp_path): + store = PreferenceStore(path=str(tmp_path / "prefs.json")) + assert store.email_for("nonexistent") is None + + def test_email_for(self, tmp_path): + store = PreferenceStore(path=str(tmp_path / "prefs.json")) + store.upsert("user_1", PreferenceUpdate(email="test@example.com")) + assert store.email_for("user_1") == "test@example.com" + + +class TestNotifyService: + """Verify the notification orchestration layer.""" + + @pytest.mark.asyncio + async def test_unknown_event_type(self, tmp_path): + store = PreferenceStore(path=str(tmp_path / "prefs.json")) + store.upsert("user_1", PreferenceUpdate(email="test@example.com")) + + sent = await notify_bounty_event(event_type="unknown", bounty_title="test", specific_user_id="user_1") + assert sent == 0 + + @pytest.mark.asyncio + async def test_skips_user_without_email(self, tmp_path): + store = PreferenceStore(path=str(tmp_path / "prefs.json")) + store.upsert("user_1", PreferenceUpdate(email="x@example.com")) + # Clear the email in the stored data to simulate unset email + store._data["user_1"]["email"] = "" + store._flush() + + sent = await notify_bounty_event(event_type="new_bounty", bounty_title="test", specific_user_id="user_1") + assert sent == 0 + + +class TestSendNotificationRequest: + def test_defaults(self): + req = SendNotificationRequest(to="test@example.com") + assert req.notification_type == NotificationType.new_bounty + assert req.username == "there" + assert req.bounty_title == "Test Bounty" \ No newline at end of file