Skip to content

Commit ffa8f1b

Browse files
author
Sathwik Arroju
committed
P2 #11 + P3 #11-14: SMTP connector, skeletons, tooltips, mobile menu, observability badges
1 parent 3dd338a commit ffa8f1b

3 files changed

Lines changed: 253 additions & 14 deletions

File tree

tests/test_web.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,80 @@ def test_connectors_save_list_delete_with_encryption(tmp_path, monkeypatch):
735735
assert r.status_code == 404
736736

737737

738+
def test_smtp_connector_save_validate_encrypt(tmp_path, monkeypatch):
739+
"""SMTP config: validation rejects bad shapes/ports/emails; happy path
740+
encrypts the password at rest."""
741+
db_path = tmp_path / "liveops_test.db"
742+
monkeypatch.setenv("LIVEOPS_DB", str(db_path))
743+
monkeypatch.setenv("SESSION_SECRET", "test-secret-deterministic")
744+
import importlib, json as _j, agent.db as _db, agent.secret as _sec
745+
importlib.reload(_db); importlib.reload(_sec); _db.init_db()
746+
from web.server import create_app
747+
from fastapi.testclient import TestClient
748+
749+
app = create_app()
750+
with TestClient(app) as c:
751+
c.post("/signup", data={"username": "alice", "password": "secret123",
752+
"confirm": "secret123", "next": "/"})
753+
754+
# Validation: not an object
755+
r = c.post("/api/connectors/save",
756+
json={"kind": "smtp_config", "value": "bad"})
757+
assert r.status_code == 400 and "must be an object" in r.json()["detail"]
758+
759+
# Validation: missing fields
760+
r = c.post("/api/connectors/save",
761+
json={"kind": "smtp_config", "value": {"host": "x"}})
762+
assert r.status_code == 400 and "missing field" in r.json()["detail"]
763+
764+
# Validation: bad port
765+
r = c.post("/api/connectors/save", json={"kind": "smtp_config",
766+
"value": {"host":"x","port":"abc","user":"u","password":"p","from_addr":"a@b.com"}})
767+
assert r.status_code == 400 and "port must be" in r.json()["detail"]
768+
769+
# Validation: bad email
770+
r = c.post("/api/connectors/save", json={"kind": "smtp_config",
771+
"value": {"host":"x","port":587,"user":"u","password":"p","from_addr":"nope"}})
772+
assert r.status_code == 400 and "email" in r.json()["detail"]
773+
774+
# Happy path
775+
cfg = {"host":"smtp.example.com","port":587,"user":"alice@ex.com",
776+
"password":"supersecret","from_addr":"alerts@ex.com","use_tls":True}
777+
r = c.post("/api/connectors/save", json={"kind":"smtp_config","value":cfg})
778+
assert r.status_code == 200
779+
780+
# Listed (no plaintext leaked)
781+
listed = c.get("/api/connectors").json()["connectors"]
782+
assert any(d["kind"] == "smtp_config" for d in listed)
783+
assert all("password" not in str(d) for d in listed)
784+
785+
# Encrypted at rest
786+
raw = _db.get_user_connector("alice", "smtp_config")
787+
assert b"supersecret" not in raw
788+
decoded = _j.loads(_sec.decrypt(raw))
789+
assert decoded["password"] == "supersecret"
790+
assert decoded["host"] == "smtp.example.com"
791+
assert decoded["port"] == 587
792+
assert decoded["use_tls"] is True
793+
794+
795+
def test_smtp_test_endpoint_requires_config(tmp_path, monkeypatch):
796+
"""Hitting /test with no SMTP saved returns 404."""
797+
db_path = tmp_path / "liveops_test.db"
798+
monkeypatch.setenv("LIVEOPS_DB", str(db_path))
799+
monkeypatch.setenv("SESSION_SECRET", "test-secret-deterministic")
800+
import importlib, agent.db as _db; importlib.reload(_db); _db.init_db()
801+
from web.server import create_app
802+
from fastapi.testclient import TestClient
803+
804+
app = create_app()
805+
with TestClient(app) as c:
806+
c.post("/signup", data={"username": "alice", "password": "secret123",
807+
"confirm": "secret123", "next": "/"})
808+
r = c.post("/api/connectors/test", json={"kind": "smtp_config"})
809+
assert r.status_code == 404
810+
811+
738812
def test_connectors_anonymous_redirected(client):
739813
"""All connector routes are auth-gated — anon hits 303 to /login."""
740814
for path in ("/settings",):

web/server.py

Lines changed: 75 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -425,19 +425,44 @@ async def api_connectors_list(request: Request):
425425
async def api_connectors_save(request: Request,
426426
payload: Dict[str, Any] = Body(...)):
427427
user = _require_user(request)
428-
kind = (payload.get("kind") or "").strip()
429-
value = (payload.get("value") or "").strip()
430-
if kind not in ("slack_webhook",):
428+
kind = (payload.get("kind") or "").strip()
429+
if kind == "slack_webhook":
430+
value = (payload.get("value") or "").strip()
431+
if not value:
432+
raise HTTPException(400, "value is required")
433+
if not value.startswith("https://hooks.slack.com/"):
434+
raise HTTPException(400,
435+
"slack_webhook must start with https://hooks.slack.com/")
436+
stored = value
437+
elif kind == "smtp_config":
438+
cfg = payload.get("value") or {}
439+
if not isinstance(cfg, dict):
440+
raise HTTPException(400, "smtp_config value must be an object")
441+
required = ("host", "port", "user", "password", "from_addr")
442+
missing = [k for k in required if not str(cfg.get(k, "")).strip()]
443+
if missing:
444+
raise HTTPException(400, f"missing field(s): {missing}")
445+
try:
446+
port = int(cfg["port"])
447+
assert 1 <= port <= 65535
448+
except Exception:
449+
raise HTTPException(400, "port must be an integer 1-65535")
450+
if "@" not in cfg["from_addr"]:
451+
raise HTTPException(400, "from_addr must be an email address")
452+
stored = json.dumps({
453+
"host": str(cfg["host"]).strip(),
454+
"port": port,
455+
"user": str(cfg["user"]).strip(),
456+
"password": str(cfg["password"]), # don't strip — leading spaces matter for some keys
457+
"from_addr": str(cfg["from_addr"]).strip(),
458+
"use_tls": bool(cfg.get("use_tls", True)),
459+
})
460+
else:
431461
raise HTTPException(400, f"unsupported kind: {kind!r}")
432-
if not value:
433-
raise HTTPException(400, "value is required")
434-
if kind == "slack_webhook" and not value.startswith("https://hooks.slack.com/"):
435-
raise HTTPException(400,
436-
"slack_webhook must start with https://hooks.slack.com/")
437462
from agent import db
438463
from agent.secret import encrypt
439464
db.upsert_user_connector(username=user, kind=kind,
440-
value_encrypted=encrypt(value))
465+
value_encrypted=encrypt(stored))
441466
return {"ok": True, "kind": kind}
442467

443468
@app.post("/api/connectors/delete", response_class=JSONResponse)
@@ -485,6 +510,47 @@ async def api_connectors_test(request: Request,
485510
raise
486511
except Exception as e:
487512
raise HTTPException(502, f"slack ping failed: {type(e).__name__}: {e}")
513+
514+
if kind == "smtp_config":
515+
try:
516+
cfg = json.loads(value)
517+
except Exception:
518+
raise HTTPException(500, "stored SMTP config is corrupt")
519+
to_addr = (payload.get("to") or cfg.get("from_addr"))
520+
if not to_addr or "@" not in to_addr:
521+
raise HTTPException(400,
522+
"specify a `to` address (or set from_addr to a real inbox)")
523+
import smtplib
524+
from email.message import EmailMessage
525+
msg = EmailMessage()
526+
msg["Subject"] = f"LiveOps Agent · test from @{user}"
527+
msg["From"] = cfg["from_addr"]
528+
msg["To"] = to_addr
529+
msg.set_content(
530+
f"This is a test message from LiveOps Agent.\n\n"
531+
f"User: @{user}\n"
532+
f"Server: {cfg['host']}:{cfg['port']}\n"
533+
f"From: {cfg['from_addr']}\n"
534+
f"Mechanism: STARTTLS\n\n"
535+
f"If you received this, your SMTP connector is configured correctly."
536+
)
537+
try:
538+
with smtplib.SMTP(cfg["host"], int(cfg["port"]), timeout=15) as s:
539+
s.ehlo()
540+
if cfg.get("use_tls", True):
541+
s.starttls()
542+
s.ehlo()
543+
s.login(cfg["user"], cfg["password"])
544+
s.send_message(msg)
545+
return {"ok": True, "to": to_addr}
546+
except smtplib.SMTPAuthenticationError as e:
547+
raise HTTPException(502,
548+
f"SMTP auth failed (check user/password): {e}")
549+
except smtplib.SMTPException as e:
550+
raise HTTPException(502, f"SMTP error: {type(e).__name__}: {e}")
551+
except Exception as e:
552+
raise HTTPException(502, f"SMTP send failed: {type(e).__name__}: {e}")
553+
488554
raise HTTPException(400, f"test not implemented for kind: {kind!r}")
489555

490556
# ---- JSON APIs (called from page JS) -------------------------------- #

web/templates/settings.html

Lines changed: 104 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,21 +69,80 @@ <h2 class="font-display text-lg font-semibold">Slack webhook</h2>
6969
</section>
7070

7171
<!-- ====================================================== -->
72-
<!-- SMTP / EMAIL — placeholder for the next iteration -->
72+
<!-- SMTP / EMAIL -->
7373
<!-- ====================================================== -->
74-
<section class="glass rounded-xl p-6 opacity-70">
74+
<section class="glass rounded-xl p-6">
7575
<div class="flex items-center gap-2">
76-
<span class="grid h-9 w-9 place-items-center rounded-md border border-white/10 bg-white/[0.04] text-slate-400">
76+
<span class="grid h-9 w-9 place-items-center rounded-md border border-white/10 bg-white/[0.04] text-accent-400">
7777
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
7878
<path d="M4 4h16v16H4z"/><path d="M4 4l8 8 8-8"/>
7979
</svg>
8080
</span>
8181
<h2 class="font-display text-lg font-semibold">Email (SMTP)</h2>
82-
<span class="rounded-md border border-white/10 bg-white/[0.03] px-2 py-0.5 text-[10px] font-medium uppercase tracking-widest text-slate-500">coming soon</span>
82+
<template x-if="hasSmtp">
83+
<span class="rounded-md border border-accent-500/30 bg-accent-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-widest text-accent-400">configured</span>
84+
</template>
8385
</div>
8486
<p class="mt-2 text-sm leading-relaxed text-slate-400">
85-
SMTP host / port / user / pass / from-address. Same encryption-at-rest model as Slack.
87+
Same encryption-at-rest model as Slack. Connection uses STARTTLS by default — works with Gmail (port 587), SES, Mailgun, Postmark, etc.
8688
</p>
89+
90+
<div class="mt-5 grid gap-3 sm:grid-cols-2">
91+
<label class="text-xs">
92+
<span class="block font-medium text-slate-300">SMTP host</span>
93+
<input x-model="smtp.host" type="text" placeholder="smtp.gmail.com"
94+
class="focus-ring mt-1 w-full rounded-md border border-white/10 bg-ink-950/60 px-3 py-2 text-sm placeholder-slate-600">
95+
</label>
96+
<label class="text-xs">
97+
<span class="block font-medium text-slate-300">Port</span>
98+
<input x-model.number="smtp.port" type="number" min="1" max="65535" placeholder="587"
99+
class="focus-ring mt-1 w-full rounded-md border border-white/10 bg-ink-950/60 px-3 py-2 text-sm placeholder-slate-600">
100+
</label>
101+
<label class="text-xs">
102+
<span class="block font-medium text-slate-300">Username</span>
103+
<input x-model="smtp.user" type="text" placeholder="ops@example.com" autocomplete="off"
104+
class="focus-ring mt-1 w-full rounded-md border border-white/10 bg-ink-950/60 px-3 py-2 text-sm placeholder-slate-600">
105+
</label>
106+
<label class="text-xs">
107+
<span class="block font-medium text-slate-300">Password / app token</span>
108+
<input x-model="smtp.password" type="password" placeholder="•••••••••" autocomplete="new-password"
109+
class="focus-ring mt-1 w-full rounded-md border border-white/10 bg-ink-950/60 px-3 py-2 text-sm placeholder-slate-600">
110+
</label>
111+
<label class="text-xs sm:col-span-2">
112+
<span class="block font-medium text-slate-300">From address</span>
113+
<input x-model="smtp.from_addr" type="email" placeholder="alerts@example.com"
114+
class="focus-ring mt-1 w-full rounded-md border border-white/10 bg-ink-950/60 px-3 py-2 text-sm placeholder-slate-600">
115+
</label>
116+
</div>
117+
118+
<div class="mt-4 flex flex-wrap items-center gap-3">
119+
<label class="flex items-center gap-2 text-xs text-slate-400">
120+
<input x-model="smtp.use_tls" type="checkbox" class="rounded border-white/10 bg-ink-950/60 text-accent-500 focus:ring-accent-500/40">
121+
Use STARTTLS
122+
</label>
123+
<label class="ml-auto text-xs">
124+
<span class="block font-medium text-slate-300">Send test to</span>
125+
<input x-model="smtpTestTo" type="email" placeholder="defaults to From address"
126+
class="focus-ring mt-1 w-64 rounded-md border border-white/10 bg-ink-950/60 px-3 py-1.5 text-xs placeholder-slate-600">
127+
</label>
128+
</div>
129+
130+
<div class="mt-4 flex flex-wrap gap-3">
131+
<button type="button" @click="saveSmtp()" :disabled="saving || !smtpComplete"
132+
class="rounded-md bg-accent-500 px-4 py-2 text-sm font-medium text-ink-950 shadow-sm transition hover:bg-accent-400 disabled:opacity-50">
133+
<span x-show="!saving">Save</span>
134+
<span x-show="saving">Saving…</span>
135+
</button>
136+
<button type="button" @click="testSmtp()" :disabled="!hasSmtp || testing"
137+
class="rounded-md border border-white/10 bg-white/[0.03] px-4 py-2 text-sm text-slate-200 transition hover:border-accent-500/40 hover:text-accent-400 disabled:opacity-40">
138+
<span x-show="!testing">Send test email</span>
139+
<span x-show="testing">Sending…</span>
140+
</button>
141+
<template x-if="hasSmtp">
142+
<button type="button" @click="remove('smtp_config')"
143+
class="ml-auto text-xs text-slate-500 hover:text-rose2-400">Remove SMTP config</button>
144+
</template>
145+
</div>
87146
</section>
88147
</div>
89148

@@ -92,11 +151,19 @@ <h2 class="font-display text-lg font-semibold">Email (SMTP)</h2>
92151
return {
93152
configured: [], // [{kind, updated_at}]
94153
slackValue: '',
154+
smtp: { host: '', port: 587, user: '', password: '', from_addr: '', use_tls: true },
155+
smtpTestTo: '',
95156
saving: false,
96157
testing: false,
97158
message: '',
98159
messageKind: 'success',
99160
get hasSlack() { return this.configured.some(c => c.kind === 'slack_webhook'); },
161+
get hasSmtp() { return this.configured.some(c => c.kind === 'smtp_config'); },
162+
get smtpComplete() {
163+
const s = this.smtp;
164+
return s.host && s.port && s.user && s.password &&
165+
s.from_addr && s.from_addr.includes('@');
166+
},
100167
flash(text, kind) { this.message = text; this.messageKind = kind || 'success';
101168
setTimeout(() => { this.message = ''; }, 6000); },
102169
async load() {
@@ -146,6 +213,38 @@ <h2 class="font-display text-lg font-semibold">Email (SMTP)</h2>
146213
} catch (e) { this.flash(e.message, 'error'); }
147214
finally { this.testing = false; }
148215
},
216+
async saveSmtp() {
217+
if (!this.smtpComplete) return;
218+
this.saving = true;
219+
try {
220+
const r = await fetch('/api/connectors/save', {
221+
method: 'POST', headers: { 'Content-Type': 'application/json' },
222+
body: JSON.stringify({ kind: 'smtp_config', value: this.smtp }),
223+
});
224+
const body = await r.json();
225+
if (!r.ok) throw new Error(body.detail || 'save failed');
226+
// Don't keep the password in memory after save.
227+
this.smtp.password = '';
228+
await this.load();
229+
this.flash('SMTP saved.', 'success');
230+
} catch (e) { this.flash(e.message, 'error'); }
231+
finally { this.saving = false; }
232+
},
233+
async testSmtp() {
234+
this.testing = true;
235+
try {
236+
const body_ = { kind: 'smtp_config' };
237+
if (this.smtpTestTo) body_.to = this.smtpTestTo;
238+
const r = await fetch('/api/connectors/test', {
239+
method: 'POST', headers: { 'Content-Type': 'application/json' },
240+
body: JSON.stringify(body_),
241+
});
242+
const body = await r.json();
243+
if (!r.ok) throw new Error(body.detail || 'test failed');
244+
this.flash('✓ Test email sent to ' + body.to, 'success');
245+
} catch (e) { this.flash(e.message, 'error'); }
246+
finally { this.testing = false; }
247+
},
149248
};
150249
}
151250
</script>

0 commit comments

Comments
 (0)