From dd9072aadb51b95f9f76e476141c86209fcca534 Mon Sep 17 00:00:00 2001
From: "Mr. The Plague"
Date: Thu, 16 Jul 2026 19:40:58 -0400
Subject: [PATCH] Ship meeting fixes, Join Us SMTP, and CSP/API polish.
Remove misleading heat map and rename Map to See the Numbers, fix share menu clipping on cards, drop duplicate party badge and partners NCMEC block, allow API origins in dev CSP, quiet theme nonce hydration warning, and email Join Us signups via Proton SMTP.
---
.env.example | 9 ++
backend/app/config.py | 23 ++++-
backend/app/email_smtp.py | 114 +++++++++++++++++++++
backend/app/routes/involve.py | 16 +++
backend/tests/test_involve.py | 32 ++++++
docker-compose.prod.example.yml | 7 ++
docker-compose.prod.ghcr.yml | 7 ++
frontend/src/app/layout.tsx | 1 +
frontend/src/app/learn/page.tsx | 6 +-
frontend/src/app/metrics/page.tsx | 14 +--
frontend/src/app/partners/page.tsx | 36 -------
frontend/src/components/Footer.tsx | 2 +-
frontend/src/components/Header.tsx | 2 +-
frontend/src/components/PoliticianCard.tsx | 13 +--
frontend/src/components/ShareButton.tsx | 4 +-
frontend/src/proxy.ts | 24 ++++-
16 files changed, 240 insertions(+), 70 deletions(-)
create mode 100644 backend/app/email_smtp.py
diff --git a/.env.example b/.env.example
index a1ff7f3..9578f49 100644
--- a/.env.example
+++ b/.env.example
@@ -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=
diff --git a/backend/app/config.py b/backend/app/config.py
index aaaf419..84d1e78 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -1,7 +1,13 @@
-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
@@ -9,13 +15,22 @@ class Settings(BaseSettings):
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()
\ No newline at end of file
diff --git a/backend/app/email_smtp.py b/backend/app/email_smtp.py
new file mode 100644
index 0000000..cba685d
--- /dev/null
+++ b/backend/app/email_smtp.py
@@ -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
diff --git a/backend/app/routes/involve.py b/backend/app/routes/involve.py
index e708820..1b33b4f 100644
--- a/backend/app/routes/involve.py
+++ b/backend/app/routes/involve.py
@@ -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__)
@@ -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.",
diff --git a/backend/tests/test_involve.py b/backend/tests/test_involve.py
index 28b6f9b..f7b44bf 100644
--- a/backend/tests/test_involve.py
+++ b/backend/tests/test_involve.py
@@ -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",
@@ -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):
diff --git a/docker-compose.prod.example.yml b/docker-compose.prod.example.yml
index 501551b..6520997 100644
--- a/docker-compose.prod.example.yml
+++ b/docker-compose.prod.example.yml
@@ -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
diff --git a/docker-compose.prod.ghcr.yml b/docker-compose.prod.ghcr.yml
index f5d6167..d4374bb 100644
--- a/docker-compose.prod.ghcr.yml
+++ b/docker-compose.prod.ghcr.yml
@@ -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}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index e175099..c58756a 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -75,6 +75,7 @@ export default async function RootLayout({
diff --git a/frontend/src/app/learn/page.tsx b/frontend/src/app/learn/page.tsx
index 7817077..5d5d57a 100644
--- a/frontend/src/app/learn/page.tsx
+++ b/frontend/src/app/learn/page.tsx
@@ -71,7 +71,7 @@ export default function LearnPage() {
Compare across Congress:{" "}
- metrics & state heat map
+ metrics & numbers
{" "}
show bill-level roll-call totals and state policy-consistency rates.
@@ -128,7 +128,7 @@ export default function LearnPage() {
Know your state: open the{" "}
- state heat map
+ state metrics
{" "}
and drill into your state's members.
@@ -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 →
diff --git a/frontend/src/app/metrics/page.tsx b/frontend/src/app/metrics/page.tsx
index 36f7b4d..28eb4b0 100644
--- a/frontend/src/app/metrics/page.tsx
+++ b/frontend/src/app/metrics/page.tsx
@@ -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";
@@ -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";
@@ -80,10 +79,10 @@ export default async function MetricsPage() {
← Back to lawmakers
-
See How Congress Voted
+
See the Numbers
- 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.