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({