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}
+
+
+
+ {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''
+ )
+
+ 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