#!/usr/bin/env python3 """ Voicemail API — pure JSON backend for the React portal. Endpoints: POST /api/login POST /api/logout GET /api/messages POST /api/messages/{id}/read POST /api/messages/{id}/delete GET /api/settings POST /api/settings GET /api/contacts POST /api/contacts GET /api/contacts/{id} PUT /api/contacts/{id} DELETE /api/contacts/{id} POST /api/contacts/import_vcf GET /api/contacts/history/{number} GET /api/healthz """ import json import os import secrets import sys import time from fastapi import Cookie, FastAPI, Form, HTTPException, Request, Response from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, EmailStr sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import vm_auth import vm_contacts import vm_store SESSION_HOURS = int(os.environ.get("VM_SESSION_HOURS", "12")) COOKIE = "vm_session" BASE = os.environ.get("VM_BASE_PATH", "") SECURE_COOKIE = os.environ.get("VM_INSECURE_COOKIE", "") not in ("1", "yes", "true") app = FastAPI(title="Voicemail API", docs_url=None, openapi_url=None) # CORS — allow the React dev server and any static frontend host app.add_middleware( CORSMiddleware, allow_origins=os.environ.get("VM_CORS_ORIGINS", "*").split(","), allow_credentials=True, allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], allow_headers=["*"], ) # ------------------------------------------------------------------ sessions def new_session(mailbox): con = vm_store.connect() tok = secrets.token_urlsafe(32) now = int(time.time()) con.execute("INSERT INTO sessions (token, mailbox, created_at, expires_at)" " VALUES (?,?,?,?)", (tok, str(mailbox), now, now + SESSION_HOURS * 3600)) con.execute("DELETE FROM sessions WHERE expires_at < ?", (now,)) con.commit() con.close() return tok def session_mailbox(token): if not token: return None con = vm_store.connect() r = con.execute("SELECT mailbox, expires_at FROM sessions WHERE token=?", (token,)).fetchone() con.close() if not r or r["expires_at"] < time.time(): return None return r["mailbox"] def require(token): mb = session_mailbox(token) if not mb: raise HTTPException(status_code=401, detail="login") return mb # ------------------------------------------------------------ brute-force lockout MAX_FAILS = int(os.environ.get("VM_MAX_FAILS", "5")) LOCK_MINUTES = int(os.environ.get("VM_LOCK_MINUTES", "15")) _fails = {} def _lock_key(mailbox, ip): return (str(mailbox).strip(), ip) def _lock_check(mailbox, ip): e = _fails.get(_lock_key(mailbox, ip)) if not e or e[0] < MAX_FAILS: return 0 elapsed = time.time() - e[1] if elapsed > LOCK_MINUTES * 60: _fails.pop(_lock_key(mailbox, ip), None) return 0 return max(1, int((LOCK_MINUTES * 60 - elapsed) // 60) + 1) def _lock_fail(mailbox, ip): k = _lock_key(mailbox, ip) e = _fails.get(k) now = time.time() if not e or now - e[1] > LOCK_MINUTES * 60: _fails[k] = [1, now] else: e[0] += 1 def _lock_clear(mailbox, ip): _fails.pop(_lock_key(mailbox, ip), None) # ------------------------------------------------------------------ helpers def _fmt_time(ts): if not ts: return "" if hasattr(ts, "strftime"): return ts.strftime("%a %d %b %Y, %H:%M") return time.strftime("%a %d %b %Y, %H:%M", time.localtime(ts)) def _fmt_dur(sec): if not sec: return "" return "%d:%02d" % (sec // 60, sec % 60) # ------------------------------------------------------------------ models class LoginReq(BaseModel): mailbox: str pin: str class ContactIn(BaseModel): name: str number: str = "" email: str | None = None # ------------------------------------------------------------------ routes @app.get("/api/healthz") def healthz(): con = vm_store.connect() n = con.execute("SELECT COUNT(*) c FROM messages").fetchone()["c"] con.close() return {"ok": True, "messages": n} @app.post("/api/login") def login(req: LoginReq, request: Request, response: Response): ip = request.client.host if request.client else "?" locked = _lock_check(req.mailbox, ip) if locked: raise HTTPException(403, "Too many failed attempts. Try again in %d minutes." % locked) info = vm_auth.check_login(req.mailbox, req.pin) if not info: _lock_fail(req.mailbox, ip) raise HTTPException(401, "Incorrect mailbox or PIN") _lock_clear(req.mailbox, ip) tok = new_session(req.mailbox.strip()) response.set_cookie(COOKIE, tok, httponly=True, samesite="lax", secure=SECURE_COOKIE, max_age=SESSION_HOURS * 3600, path=BASE + "/") boxes = vm_auth.parse_mailboxes() return { "mailbox": req.mailbox.strip(), "name": boxes.get(req.mailbox.strip(), {}).get("name", ""), "token": tok, } @app.post("/api/logout") def logout(vm_session: str = Cookie(default=None), response: Response = None): if vm_session: con = vm_store.connect() con.execute("DELETE FROM sessions WHERE token=?", (vm_session,)) con.commit() con.close() response.delete_cookie(COOKIE, path=BASE + "/") return {"ok": True} def _current_user(vm_session: str = Cookie(default=None)): mb = require(vm_session) boxes = vm_auth.parse_mailboxes() return mb, boxes.get(mb, {}).get("name", "") @app.get("/api/messages") def list_messages(vm_session: str = Cookie(default=None), q: str = "", date_from: str = "", date_to: str = "", unread: str = ""): mb, name = _current_user(vm_session) con = vm_store.connect() where = ["mailbox = ?"] params = [mb] if q: like = "%" + q.replace("%", "%%") + "%" where.append("(contact_name LIKE ? OR callerid LIKE ? OR summary LIKE ? OR transcript LIKE ?)") params.extend([like, like, like, like]) if date_from: where.append("origtime >= ?") params.append(int(date_from)) if date_to: where.append("origtime < ?") params.append(int(date_to) + 86400) if unread: where.append("is_read = 0") sql = ("SELECT id, origtime, duration, callerid, contact_name, contact_email, " "summary, is_read, audio_sha, audio_ext FROM messages WHERE " + " AND ".join(where) + " ORDER BY origtime DESC, id DESC") rows = con.execute(sql, params).fetchall() con.close() out = [] for r in rows: out.append({ "id": r["id"], "origtime": r["origtime"], "time": _fmt_time(r["origtime"]), "duration": r["duration"], "duration_fmt": _fmt_dur(r["duration"]), "callerid": r["callerid"], "contact_name": r.get("contact_name"), "contact_email": r.get("contact_email"), "summary": r["summary"], "transcript": r.get("transcript"), "intents": r.get("intents"), "numbers": r.get("numbers"), "tags": json.loads(r["intents"] or "[]"), "is_read": bool(r["is_read"]), "has_audio": bool(r["audio_sha"]), }) return {"mailbox": mb, "name": name, "messages": out} @app.post("/api/messages/{msg_id}/read") def toggle_read(msg_id: int, vm_session: str = Cookie(default=None)): mb = _current_user(vm_session)[0] con = vm_store.connect() con.execute("UPDATE messages SET is_read = 1 - is_read WHERE id=? AND mailbox=?", (msg_id, mb)) con.commit() con.close() return {"ok": True} @app.post("/api/messages/{msg_id}/delete") def delete_message(msg_id: int, vm_session: str = Cookie(default=None)): mb = _current_user(vm_session)[0] con = vm_store.connect() r = con.execute("SELECT * FROM messages WHERE id=? AND mailbox=?", (msg_id, mb)).fetchone() if not r: con.close() raise HTTPException(404, "not found") if r["audio_sha"]: others = con.execute("SELECT COUNT(*) c FROM messages " "WHERE audio_sha=? AND id<>?", (r["audio_sha"], msg_id)).fetchone()["c"] if not others: try: os.unlink(vm_store.audio_path(r["audio_sha"], r["audio_ext"] or "wav")) except OSError: pass if r.get("spool_path"): base = os.path.splitext(r["spool_path"])[0] for ext in (".wav", ".WAV", ".gsm", ".txt", ".wav49"): try: os.unlink(base + ext) except OSError: pass con.execute("DELETE FROM messages WHERE id=? AND mailbox=?", (msg_id, mb)) con.commit() con.close() return {"ok": True} @app.get("/api/settings") def get_settings(vm_session: str = Cookie(default=None)): mb = _current_user(vm_session)[0] con = vm_store.connect() cur = vm_store.get_settings(con, mb) con.close() return cur @app.post("/api/settings") async def save_settings(request: Request, vm_session: str = Cookie(default=None)): mb = _current_user(vm_session)[0] form = await request.form() con = vm_store.connect() for key, (default, _label) in vm_store.USER_SETTINGS.items(): if default in ("yes", "no"): vm_store.set_setting(con, mb, key, "yes" if form.get(key) else "no") else: vm_store.set_setting(con, mb, key, (form.get(key) or "").strip()) con.close() return {"ok": True} @app.get("/api/contacts") def list_contacts(vm_session: str = Cookie(default=None), q: str = ""): _current_user(vm_session) con = vm_store.connect() if q: like = "%" + q.replace("%", "%%") + "%" rows = con.execute( "SELECT id, number_e164, name, email, updated_at FROM contacts " "WHERE name LIKE ? OR number_e164 LIKE ? OR email LIKE ? " "ORDER BY name ASC, id DESC", (like, like, like), ).fetchall() else: rows = con.execute( "SELECT id, number_e164, name, email, updated_at FROM contacts " "ORDER BY name ASC, id DESC" ).fetchall() con.close() return [{"id": r["id"], "number_e164": r.get("number_e164"), "name": r["name"], "email": r.get("email"), "updated_at": _fmt_time(r["updated_at"])} for r in rows] @app.get("/api/contacts/{cid}") def get_contact(cid: int, vm_session: str = Cookie(default=None)): _current_user(vm_session) con = vm_store.connect() c = con.execute("SELECT * FROM contacts WHERE id=?", (cid,)).fetchone() con.close() if not c: raise HTTPException(404, "contact not found") return {"id": c["id"], "number_e164": c.get("number_e164"), "name": c["name"], "email": c.get("email")} @app.post("/api/contacts") async def create_contact(body: ContactIn, vm_session: str = Cookie(default=None)): _current_user(vm_session) normalized = vm_contacts.normalize_uk(vm_contacts.digits_of(body.number)) if body.number else "" con = vm_store.connect() try: con.execute( "INSERT INTO contacts (number_e164, name, email) VALUES (?, ?, ?)", (normalized, body.name, body.email), ) con.commit() cid = con.lastrowid finally: con.close() return {"id": cid, "number_e164": normalized, "name": body.name, "email": body.email} @app.put("/api/contacts/{cid}") async def update_contact(cid: int, body: ContactIn, vm_session: str = Cookie(default=None)): _current_user(vm_session) con = vm_store.connect() try: row = con.execute("SELECT id, number_e164 FROM contacts WHERE id=?", (cid,)).fetchone() if not row: raise HTTPException(404, "contact not found") normalized = vm_contacts.normalize_uk(vm_contacts.digits_of(body.number)) if body.number else "" con.execute( "UPDATE contacts SET name=?, email=?, number_e164=?, updated_at=NOW() WHERE id=?", (body.name, body.email, normalized, cid), ) con.execute( "UPDATE messages SET contact_name=?, contact_email=? " "WHERE callerid LIKE ? AND (contact_name IS NULL OR contact_name = '')", (body.name, body.email, "%%%s%%" % normalized), ) con.commit() finally: con.close() return {"id": cid, "number_e164": normalized, "name": body.name, "email": body.email} @app.delete("/api/contacts/{cid}") def delete_contact(cid: int, vm_session: str = Cookie(default=None)): _current_user(vm_session) con = vm_store.connect() con.execute("DELETE FROM contacts WHERE id=?", (cid,)) con.commit() con.close() return {"ok": True} @app.post("/api/contacts/import_vcf") def import_vcf(vm_session: str = Cookie(default=None)): _current_user(vm_session) path = "/var/lib/vm-transcribe/contacts.vcf" if not os.path.exists(path): raise HTTPException(404, "VCF not found") try: with open(path, encoding="utf-8", errors="replace") as fh: entries = vm_contacts._parse_vcf(fh.read()) except Exception as e: raise HTTPException(400, "VCF parse error: %s" % e) con = vm_store.connect() existing = { r["number_e164"] for r in con.execute("SELECT number_e164 FROM contacts").fetchall() } added = skipped = 0 for name, nums, emails in entries: if vm_contacts._is_junk_name(name): skipped += 1 continue email = emails[0] if emails else None for raw_num in nums: e164 = vm_contacts.normalize_uk(vm_contacts.digits_of(raw_num)) if not e164 or e164 in existing: skipped += 1 continue try: con.execute( "INSERT INTO contacts (number_e164, name, email) VALUES (?, ?, ?)", (e164, name, email), ) existing.add(e164) added += 1 except Exception: skipped += 1 con.commit() con.close() return {"ok": True, "added": added, "skipped": skipped} @app.get("/api/contacts/history/{num}") def contact_history(num: str, vm_session: str = Cookie(default=None)): mb = _current_user(vm_session)[0] normalized = vm_contacts.normalize_uk(vm_contacts.digits_of(num)) or num con = vm_store.connect() rows = con.execute( "SELECT id, origtime, duration, callerid, contact_name, contact_email, " "summary, is_read, audio_sha FROM messages WHERE mailbox=? AND callerid LIKE ? " "ORDER BY origtime DESC, id DESC", (mb, "%%%s%%" % normalized), ).fetchall() c = con.execute( "SELECT name FROM contacts WHERE number_e164 = ? LIMIT 1", (normalized,), ).fetchone() con.close() who = c["name"] if c and c.get("name") else num out = [] for r in rows: out.append({ "id": r["id"], "origtime": r["origtime"], "time": _fmt_time(r["origtime"]), "duration": r["duration"], "duration_fmt": _fmt_dur(r["duration"]), "callerid": r["callerid"], "contact_name": r.get("contact_name"), "contact_email": r.get("contact_email"), "summary": r["summary"], "transcript": r.get("transcript"), "intents": r.get("intents"), "numbers": r.get("numbers"), "tags": json.loads(r["intents"] or "[]"), "is_read": bool(r["is_read"]), "has_audio": bool(r["audio_sha"]), }) return {"contact": who, "number": normalized, "messages": out} @app.get("/api/transcript/{msg_id}") def get_transcript(msg_id: int, vm_session: str = Cookie(default=None)): mb = _current_user(vm_session)[0] con = vm_store.connect() r = con.execute( "SELECT transcript FROM messages WHERE id=? AND mailbox=?", (msg_id, mb), ).fetchone() con.close() if not r: raise HTTPException(404, "not found") return {"id": msg_id, "transcript": r["transcript"] or ""} @app.get("/api/audio/{msg_id}/info") def audio_info(msg_id: int, vm_session: str = Cookie(default=None)): mb = _current_user(vm_session)[0] con = vm_store.connect() r = con.execute("SELECT audio_sha, audio_ext FROM messages WHERE id=? AND mailbox=?", (msg_id, mb)).fetchone() con.close() if not r or not r["audio_sha"]: raise HTTPException(404, "not found") p = vm_store.audio_path(r["audio_sha"], r["audio_ext"] or "wav") return {"id": msg_id, "path": p, "exists": os.path.exists(p)} class AddContactIn(BaseModel): number: str = "" name: str email: str | None = None @app.post("/api/add_contact") async def add_contact(body: AddContactIn, vm_session: str = Cookie(default=None)): mb = _current_user(vm_session)[0] num = (body.number or "").strip() name = (body.name or "").strip() email = (body.email or "").strip() or None if not name: raise HTTPException(400, "Name is required") normalized = vm_contacts.normalize_uk(vm_contacts.digits_of(num)) if num else None con = vm_store.connect() if normalized: row = con.execute( "SELECT id FROM contacts WHERE number_e164 = ?", (normalized,) ).fetchone() if row and row.get("id"): con.execute( "UPDATE contacts SET name=?, email=?, updated_at=NOW() WHERE id=?", (name, email, row["id"]), ) else: con.execute( "INSERT INTO contacts (number_e164, name, email) VALUES (?, ?, ?)", (normalized, name, email), ) con.execute( "UPDATE messages SET contact_name=?, contact_email=? " "WHERE callerid LIKE ? AND (contact_name IS NULL OR contact_name = '')", (name, email, "%%%s%%" % normalized), ) con.commit() con.close() return {"ok": True} @app.post("/api/contacts/delete_all") def delete_all_contacts(vm_session: str = Cookie(default=None)): _current_user(vm_session) con = vm_store.connect() con.execute("DELETE FROM contacts") con.commit() con.close() return {"ok": True} class AddContactIn(BaseModel): number: str = "" name: str email: str | None = None @app.post("/api/add_contact") async def add_contact(body: AddContactIn, vm_session: str = Cookie(default=None)): mb = _current_user(vm_session)[0] num = (body.number or "").strip() name = (body.name or "").strip() email = (body.email or "").strip() or None if not name: raise HTTPException(400, "Name is required") normalized = vm_contacts.normalize_uk(vm_contacts.digits_of(num)) if num else None con = vm_store.connect() if normalized: row = con.execute( "SELECT id FROM contacts WHERE number_e164 = ?", (normalized,) ).fetchone() if row and row.get("id"): con.execute( "UPDATE contacts SET name=?, email=?, updated_at=NOW() WHERE id=?", (name, email, row["id"]), ) else: con.execute( "INSERT INTO contacts (number_e164, name, email) VALUES (?, ?, ?)", (normalized, name, email), ) con.execute( "UPDATE messages SET contact_name=?, contact_email=? " "WHERE callerid LIKE ? AND (contact_name IS NULL OR contact_name = '')", (name, email, "%%%s%%" % normalized), ) con.commit() con.close() return {"ok": True} @app.post("/api/contacts/delete_all") def delete_all_contacts(vm_session: str = Cookie(default=None)): _current_user(vm_session) con = vm_store.connect() con.execute("DELETE FROM contacts") con.commit() con.close() return {"ok": True}