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