From a926a081903caf5c506adf6c00d46f37fc885deb Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 6 Aug 2026 14:06:46 -0700 Subject: [PATCH] [eric] email: Settings gains a real cloud-run email toggle behind a degrade-honest proxy, ghost label survives old prod --- backend/apps/auth/router.py | 59 +++++++++++++++++ backend/tests/test_auth_router.py | 65 ++++++++++++++++++ .../sections/general/NotificationsSection.tsx | 66 +++++++++++++++++-- 3 files changed, 186 insertions(+), 4 deletions(-) diff --git a/backend/apps/auth/router.py b/backend/apps/auth/router.py index dd813efd..b79548b1 100644 --- a/backend/apps/auth/router.py +++ b/backend/apps/auth/router.py @@ -235,3 +235,62 @@ async def signout(): p_sync_identity_to_service(settings_obj) await p_sync_pro_routing(settings_obj) return {"ok": True} + + +# --------------------------------------------------------------------------- /api/auth/email-prefs --------------------------------------------------------------------------- + +class EmailPrefsUpdate(BaseModel): + run_emails: bool + + +def p_email_prefs_unavailable() -> dict: + return {"available": False, "run_emails": None} + + +@auth.router.get("/email-prefs") +async def get_email_prefs(): + """Proxy the cloud's signed-in run-email preference for the Settings toggle. + + Degrades to available:false (the Settings row falls back to its honest + ghost label) when signed out, when the cloud is unreachable, or when prod + predates the prefs endpoint. Never 500s the Settings page. + """ + from backend.apps.settings.credentials import account_auth + + token, base = account_auth(load_settings()) + if not token: + return p_email_prefs_unavailable() + try: + async with httpx.AsyncClient(timeout=6.0) as client: + r = await client.get( + f"{base}/api/email-prefs/mine", + headers={"Authorization": f"Bearer {token}"}, + ) + except httpx.HTTPError: + return p_email_prefs_unavailable() + if r.status_code != 200: + return p_email_prefs_unavailable() + data = r.json() + return {"available": True, "run_emails": bool(data.get("run_emails"))} + + +@auth.router.put("/email-prefs") +async def put_email_prefs(body: EmailPrefsUpdate): + from backend.apps.settings.credentials import account_auth + + token, base = account_auth(load_settings()) + if not token: + return p_email_prefs_unavailable() + try: + async with httpx.AsyncClient(timeout=6.0) as client: + r = await client.put( + f"{base}/api/email-prefs/mine", + headers={"Authorization": f"Bearer {token}"}, + json={"run_emails": body.run_emails}, + ) + except httpx.HTTPError: + return p_email_prefs_unavailable() + if r.status_code != 200: + return p_email_prefs_unavailable() + data = r.json() + return {"available": True, "run_emails": bool(data.get("run_emails"))} diff --git a/backend/tests/test_auth_router.py b/backend/tests/test_auth_router.py index 8690f21d..6986ce63 100644 --- a/backend/tests/test_auth_router.py +++ b/backend/tests/test_auth_router.py @@ -208,3 +208,68 @@ def test_dev_token_is_dev_only(): assert noauth.get("/api/dev/token").status_code == 404 finally: os.environ.pop("OPENSWARM_PACKAGED", None) + + +# --------------------------------------------------------------------------- /api/auth/email-prefs --------------------------------------------------------------------------- + +def p_set_bearer(value): + from backend.apps.settings.settings import load_settings, save_settings + s = load_settings() + s.openswarm_bearer_token = value + save_settings(s) + + +def test_email_prefs_signed_out_reads_unavailable(client, reset_settings): + p_set_bearer(None) + r = client.get("/api/auth/email-prefs") + assert r.status_code == 200 + assert r.json() == {"available": False, "run_emails": None} + + +def test_email_prefs_passthrough_when_cloud_answers(client, reset_settings): + p_set_bearer("bearer-abc") + fake_response = AsyncMock() + fake_response.status_code = 200 + fake_response.json = lambda: {"run_emails": True} + with patch("httpx.AsyncClient") as MockClient: + instance = MockClient.return_value.__aenter__.return_value + instance.get = AsyncMock(return_value=fake_response) + r = client.get("/api/auth/email-prefs") + assert r.json() == {"available": True, "run_emails": True} + sent_headers = instance.get.call_args.kwargs["headers"] + assert sent_headers["Authorization"] == "Bearer bearer-abc" + + +def test_email_prefs_old_prod_404_degrades_to_unavailable(client, reset_settings): + p_set_bearer("bearer-abc") + fake_response = AsyncMock() + fake_response.status_code = 404 + with patch("httpx.AsyncClient") as MockClient: + instance = MockClient.return_value.__aenter__.return_value + instance.get = AsyncMock(return_value=fake_response) + r = client.get("/api/auth/email-prefs") + assert r.json() == {"available": False, "run_emails": None} + + +def test_email_prefs_network_failure_never_500s(client, reset_settings): + import httpx as p_httpx + p_set_bearer("bearer-abc") + with patch("httpx.AsyncClient") as MockClient: + instance = MockClient.return_value.__aenter__.return_value + instance.get = AsyncMock(side_effect=p_httpx.ConnectError("down")) + r = client.get("/api/auth/email-prefs") + assert r.status_code == 200 + assert r.json() == {"available": False, "run_emails": None} + + +def test_email_prefs_put_flips_through_the_cloud(client, reset_settings): + p_set_bearer("bearer-abc") + fake_response = AsyncMock() + fake_response.status_code = 200 + fake_response.json = lambda: {"run_emails": False} + with patch("httpx.AsyncClient") as MockClient: + instance = MockClient.return_value.__aenter__.return_value + instance.put = AsyncMock(return_value=fake_response) + r = client.put("/api/auth/email-prefs", json={"run_emails": False}) + assert r.json() == {"available": True, "run_emails": False} + assert instance.put.call_args.kwargs["json"] == {"run_emails": False} diff --git a/frontend/src/app/pages/Settings/sections/general/NotificationsSection.tsx b/frontend/src/app/pages/Settings/sections/general/NotificationsSection.tsx index 47e48fce..7240de09 100644 --- a/frontend/src/app/pages/Settings/sections/general/NotificationsSection.tsx +++ b/frontend/src/app/pages/Settings/sections/general/NotificationsSection.tsx @@ -1,8 +1,10 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Switch from '@mui/material/Switch'; +import { useAppSelector } from '@/shared/hooks'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { API_BASE } from '@/shared/config'; import type { AppSettings } from '@/shared/state/settingsSlice'; interface Props { @@ -10,9 +12,43 @@ interface Props { setForm: (next: AppSettings) => void; } +interface EmailPrefs { + available: boolean; + run_emails: boolean | null; +} + // Both toggles gate REAL notification paths (notifications.ts checks them before firing); nothing here is decorative. const NotificationsSection: React.FC = ({ form, setForm }) => { const c = useClaudeTokens(); + const signedIn = useAppSelector((s) => Boolean(s.settings.data.openswarm_bearer_token)); + // The email pref lives in the CLOUD (the sender must read it with the laptop shut), so this row round-trips instead of writing local settings. + const [emailPrefs, setEmailPrefs] = useState(null); + const [saving, setSaving] = useState(false); + useEffect(() => { + let alive = true; + fetch(`${API_BASE}/auth/email-prefs`, { cache: 'no-store' }) + .then((r) => r.json()) + .then((d: EmailPrefs) => { if (alive) setEmailPrefs(d); }) + .catch(() => { if (alive) setEmailPrefs({ available: false, run_emails: null }); }); + return () => { alive = false; }; + }, [signedIn]); + + const flipEmail = async (next: boolean): Promise => { + setSaving(true); + setEmailPrefs({ available: true, run_emails: next }); + try { + const r = await fetch(`${API_BASE}/auth/email-prefs`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ run_emails: next }), + }); + setEmailPrefs((await r.json()) as EmailPrefs); + } catch { + setEmailPrefs({ available: false, run_emails: null }); + } finally { + setSaving(false); + } + }; const row = (title: string, body: string, key: 'notify_agent_completion' | 'notify_workflow_runs'): React.ReactElement => ( = ({ form, setForm }) => { {row('Agent completion', 'Native notification when an agent finishes or errors while the window is in the background.', 'notify_agent_completion')} {row('Workflow runs', 'Notification Center alert when a scheduled workflow run finishes, with quick actions.', 'notify_workflow_runs')} - - Email alerts are not available yet. - + {emailPrefs?.available ? ( + + + Email on cloud runs + + Emails you the result when a cloud-hosted workflow finishes, even with this computer off. Every email carries its own off switch. + + + { void flipEmail(e.target.checked); }} + /> + + ) : ( + + {signedIn + ? 'Email alerts are not available right now.' + : 'Sign in to get an email when a cloud workflow run finishes.'} + + )} ); };