Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ NEXT_PUBLIC_API_URL=http://localhost:8000
# Canonical public site URL (Open Graph / social sharing)
NEXT_PUBLIC_SITE_URL=https://operationchildshield.org

# Proton SMTP (Join Us form notifications). Use SMTP token as password.
SMTP_HOST=smtp.protonmail.ch
SMTP_PORT=587
SMTP_USER=contact@operationchildshield.org
SMTP_PASSWORD=
SMTP_FROM=contact@operationchildshield.org
INVOLVE_NOTIFY_TO=contact@operationchildshield.org
INVOLVE_SEND_AUTO_REPLY=true

# Optional public social profile URLs (leave blank to hide footer links)
NEXT_PUBLIC_SOCIAL_X_URL=
NEXT_PUBLIC_SOCIAL_FACEBOOK_URL=
Expand Down
23 changes: 19 additions & 4 deletions backend/app/config.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,36 @@
from pydantic_settings import BaseSettings
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)

congress_api_key: str = ""
congress_number: int = 119
cache_ttl_seconds: int = 86400 # 24 hours
cors_origins: str = "http://localhost:3000"
congress_base_url: str = "https://api.congress.gov/v3"
cache_dir: str = "/app/data"

class Config:
env_file = ".env"
env_file_encoding = "utf-8"
# Proton / SMTP for Join Us form notifications
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_from: str = ""
involve_notify_to: str = ""
involve_send_auto_reply: bool = True

@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]

@property
def smtp_configured(self) -> bool:
return bool(self.smtp_host and self.smtp_user and self.smtp_password)


settings = Settings()
114 changes: 114 additions & 0 deletions backend/app/email_smtp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""SMTP helpers for Join Us notifications (stdlib only)."""

from __future__ import annotations

import logging
import smtplib
import ssl
from email.message import EmailMessage
from typing import Any

from app.config import settings

logger = logging.getLogger(__name__)


def _from_address() -> str:
return (settings.smtp_from or settings.smtp_user or "").strip()


def _notify_to() -> str:
return (settings.involve_notify_to or settings.smtp_user or "").strip()


def send_email(
*,
to: str,
subject: str,
body: str,
reply_to: str | None = None,
) -> None:
if not settings.smtp_configured:
raise RuntimeError("SMTP is not configured")

msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = _from_address()
msg["To"] = to
if reply_to:
msg["Reply-To"] = reply_to
msg.set_content(body)

context = ssl.create_default_context()
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=30) as smtp:
smtp.ehlo()
smtp.starttls(context=context)
smtp.ehlo()
smtp.login(settings.smtp_user, settings.smtp_password)
smtp.send_message(msg)


def notify_involve_signup(record: dict[str, Any]) -> bool:
"""Email the ops inbox about a new Join Us submission. Returns True if sent."""
if not settings.smtp_configured:
logger.warning("SMTP not configured; involve signup not emailed")
return False

to = _notify_to()
if not to:
logger.warning("No involve notify recipient configured")
return False

name = record.get("name") or "(no name)"
email = record.get("email") or "(no email)"
interest = record.get("interest") or "(none)"
state = record.get("state") or "(none)"
message = record.get("message") or ""
submitted = record.get("submittedAt") or ""

body = (
"New Operation Child Shield Join Us signup\n"
"=========================================\n\n"
f"Submitted: {submitted}\n"
f"Name: {name}\n"
f"Email: {email}\n"
f"State: {state}\n"
f"Interest: {interest}\n\n"
f"Message:\n{message}\n"
)

send_email(
to=to,
subject=f"[OCS Join Us] {interest} — {name}",
body=body,
reply_to=email if "@" in str(email) else None,
)
return True


def auto_reply_involve_signup(record: dict[str, Any]) -> bool:
"""Optional confirmation email to the person who signed up."""
if not settings.smtp_configured or not settings.involve_send_auto_reply:
return False

email = (record.get("email") or "").strip()
if not email or "@" not in email:
return False

name = (record.get("name") or "").strip() or "friend"
body = (
f"Hi {name},\n\n"
"Thank you for signing up with Operation Child Shield. We received your "
"message and will follow up when there is a good fit for your interest.\n\n"
"If you need to reach us sooner, email Contact@OperationChildShield.com.\n\n"
"— Operation Child Shield\n"
"Protect | Educate | Mobilize\n"
"https://operationchildshield.org\n"
)

send_email(
to=email,
subject="We received your Operation Child Shield signup",
body=body,
)
return True
16 changes: 16 additions & 0 deletions backend/app/routes/involve.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from pydantic import BaseModel, Field, field_validator

from app.config import settings
from app.email_smtp import auto_reply_involve_signup, notify_involve_signup
from app.security import client_ip, rate_limit

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -136,6 +137,21 @@ async def submit_involve_signup(body: InvolveSignupRequest, request: Request):
interest or "none",
body.state or "none",
)

# Email ops (+ optional auto-reply). Persistence already succeeded; do not fail
# the HTTP response if SMTP is down — signups stay in involve_signups.jsonl.
try:
if notify_involve_signup(record):
logger.info("Involve signup emailed to notify inbox")
except Exception:
logger.exception("Failed to email involve notify")

try:
if auto_reply_involve_signup(record):
logger.info("Involve auto-reply sent to submitter")
except Exception:
logger.exception("Failed to send involve auto-reply")

return {
"ok": True,
"message": "Thank you. We received your signup and will follow up.",
Expand Down
32 changes: 32 additions & 0 deletions backend/tests/test_involve.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ def _app(tmp_path: Path, monkeypatch) -> TestClient:


def test_involve_signup_persists_jsonl(tmp_path, monkeypatch):
sent: list[dict] = []

def fake_notify(record):
sent.append(record)
return True

monkeypatch.setattr(involve, "notify_involve_signup", fake_notify)
monkeypatch.setattr(involve, "auto_reply_involve_signup", lambda _r: False)

client = _app(tmp_path, monkeypatch)
res = client.post(
"/api/involve",
Expand All @@ -38,6 +47,29 @@ def test_involve_signup_persists_jsonl(tmp_path, monkeypatch):
assert record["interest"] == "volunteer"
assert record["state"] == "Texas"
assert "submittedAt" in record
assert len(sent) == 1
assert sent[0]["email"] == "jane@example.com"


def test_involve_signup_succeeds_when_smtp_fails(tmp_path, monkeypatch):
def boom(_record):
raise RuntimeError("smtp down")

monkeypatch.setattr(involve, "notify_involve_signup", boom)
monkeypatch.setattr(involve, "auto_reply_involve_signup", boom)

client = _app(tmp_path, monkeypatch)
res = client.post(
"/api/involve",
json={
"name": "Jane Doe",
"email": "jane@example.com",
"consent": True,
},
)
assert res.status_code == 200
assert res.json()["ok"] is True
assert (tmp_path / "involve_signups.jsonl").exists()


def test_involve_requires_consent(tmp_path, monkeypatch):
Expand Down
7 changes: 7 additions & 0 deletions docker-compose.prod.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ services:
dockerfile: Dockerfile
environment:
- CONGRESS_API_KEY=${CONGRESS_API_KEY}
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
- SMTP_FROM=${SMTP_FROM:-}
- INVOLVE_NOTIFY_TO=${INVOLVE_NOTIFY_TO:-}
- INVOLVE_SEND_AUTO_REPLY=${INVOLVE_SEND_AUTO_REPLY:-true}
- CONGRESS_NUMBER=${CONGRESS_NUMBER:-119}
- CACHE_TTL_SECONDS=${CACHE_TTL_SECONDS:-86400}
- CORS_ORIGINS=https://operationchildshield.org,https://www.operationchildshield.org
Expand Down
7 changes: 7 additions & 0 deletions docker-compose.prod.ghcr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ services:
image: ghcr.io/dotnetrussell/operationchildshield-backend:${IMAGE_TAG:-main}
environment:
- CONGRESS_API_KEY=${CONGRESS_API_KEY}
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASSWORD=${SMTP_PASSWORD:-}
- SMTP_FROM=${SMTP_FROM:-}
- INVOLVE_NOTIFY_TO=${INVOLVE_NOTIFY_TO:-}
- INVOLVE_SEND_AUTO_REPLY=${INVOLVE_SEND_AUTO_REPLY:-true}
- CONGRESS_NUMBER=${CONGRESS_NUMBER:-119}
- CACHE_TTL_SECONDS=${CACHE_TTL_SECONDS:-86400}
- CORS_ORIGINS=${CORS_ORIGINS:-https://operationchildshield.org,https://www.operationchildshield.org}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export default async function RootLayout({
<head>
<script
nonce={nonce}
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }}
/>
</head>
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/app/learn/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export default function LearnPage() {
<li>
<strong className="text-foreground">Compare across Congress:</strong>{" "}
<Link href="/metrics" className="text-red font-semibold hover:underline">
metrics &amp; state heat map
metrics &amp; numbers
</Link>{" "}
show bill-level roll-call totals and state policy-consistency rates.
</li>
Expand Down Expand Up @@ -128,7 +128,7 @@ export default function LearnPage() {
<li>
<strong className="text-foreground">Know your state:</strong> open the{" "}
<Link href="/metrics" className="text-red font-semibold hover:underline">
state heat map
state metrics
</Link>{" "}
and drill into your state&apos;s members.
</li>
Expand Down Expand Up @@ -166,7 +166,7 @@ export default function LearnPage() {
href="/metrics"
className="rounded-[10px] border border-card-border bg-surface px-5 py-4 text-center font-bold text-blue no-underline hover:bg-surface-muted"
>
Explore the Map
See the Numbers
</Link>
</div>
</div>
Expand Down
14 changes: 4 additions & 10 deletions frontend/src/app/metrics/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { MetricsCharts } from "@/components/metrics/MetricsCharts";
import { StateHeatMap } from "@/components/metrics/StateHeatMap";
import { StatePerformanceTable } from "@/components/metrics/StatePerformanceTable";
import { getMetrics, getMetricsExportUrl } from "@/lib/api";
import { formatUtcTimestamp } from "@/lib/format";
Expand All @@ -10,7 +9,7 @@ import type { BillMetricsRow } from "@/lib/metrics-types";
export const metadata: Metadata = {
title: "See How Congress Voted",
description:
"State heat map and roll-call totals for tracked child safety legislation.",
"Roll-call totals and state metrics for tracked child safety legislation.",
};

export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -80,10 +79,10 @@ export default async function MetricsPage() {
← Back to lawmakers
</Link>

<h1 className="text-3xl font-bold text-blue mt-4">See How Congress Voted</h1>
<h1 className="text-3xl font-bold text-blue mt-4">See the Numbers</h1>
<p className="mt-4 text-muted leading-relaxed max-w-3xl">
State heat map and roll-call totals on tracked child safety bills.
Click a state to meet its members.
Roll-call totals and state-level metrics on tracked child safety bills.
Use the state table below to open members by state.
</p>

{error ? (
Expand All @@ -95,11 +94,6 @@ export default async function MetricsPage() {
{formatUtcTimestamp(data.lastUpdated)}
</p>

{/* min-w-0 so wide map scroll stays inside the page, not clipped by flex ancestors */}
<section className="mt-6 w-full min-w-0">
<StateHeatMap byState={data.byState ?? []} />
</section>

<div className="mt-10 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
<KpiCard label="Members Tracked" value={data.kpis.totalMembersTracked} />
<KpiCard
Expand Down
Loading