[eric] email: Settings gains a real cloud-run email toggle behind a degrade-honest proxy, ghost label survives old prod

This commit is contained in:
ciregenz
2026-08-06 14:06:46 -07:00
parent 84c40f151e
commit a926a08190
3 changed files with 186 additions and 4 deletions
+59
View File
@@ -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"))}
+65
View File
@@ -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}
@@ -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<Props> = ({ 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<EmailPrefs | null>(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<void> => {
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 => (
<Box sx={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2,
@@ -33,9 +69,31 @@ const NotificationsSection: React.FC<Props> = ({ form, setForm }) => {
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
{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')}
<Typography sx={{ fontSize: '0.8125rem', color: c.text.ghost, px: 0.5, pt: 2 }}>
Email alerts are not available yet.
</Typography>
{emailPrefs?.available ? (
<Box sx={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 2,
px: 0.5, py: 2, borderBottom: `1px solid ${c.border.subtle}`, '&:last-of-type': { borderBottom: 'none' },
}}>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontSize: '0.875rem', fontWeight: 600, color: c.text.primary }}>Email on cloud runs</Typography>
<Typography sx={{ fontSize: '0.8125rem', color: c.text.tertiary, mt: 0.25 }}>
Emails you the result when a cloud-hosted workflow finishes, even with this computer off. Every email carries its own off switch.
</Typography>
</Box>
<Switch
size="small"
disabled={saving}
checked={emailPrefs.run_emails === true}
onChange={(e) => { void flipEmail(e.target.checked); }}
/>
</Box>
) : (
<Typography sx={{ fontSize: '0.8125rem', color: c.text.ghost, px: 0.5, pt: 2 }}>
{signedIn
? 'Email alerts are not available right now.'
: 'Sign in to get an email when a cloud workflow run finishes.'}
</Typography>
)}
</Box>
);
};