# Architecture How the pieces fit and *why* they are shaped the way they are. Read this before changing anything in `vm_mailcmd.py`, `vm_store.py`, `vm_api.py` or `vm_contacts.py`. --- ## Flow ``` Asterisk ──(RFC822 on stdin)──▶ vm_mailcmd.py │ ┌──────────┬───────────────┼───────────────┬───────────────┐ ▼ ▼ ▼ ▼ ▼ transcribe summarise resolve store telegram (whisper) +intents contact (MySQL) (DM) │ │ │ │ │ └────▶ build multipart email ─▶ Postfix :25 (plain + HTML + audio attached) │ └──▶ vm_store.add_message() (best-effort, MySQL) └──▶ vm_telegram.send() (best-effort) ``` **Email is sent before anything else.** The portal and Telegram are conveniences; the email is the contract with the user. If the database is down or Telegram's API throws, the mail has already gone. Each later stage is in its own `try/except` that only logs. The reverse — a transcription error blocking the email — never happens, because the email builder is given whatever text we have, even `""`. At the very top, the whole `main()` body is wrapped so that *any* unexpected exception relays the **original** Asterisk message unchanged. Nothing we add can silently swallow a voicemail. --- ## Storage: MySQL, with a SQLite escape hatch `vm_store.py` is backend-agnostic. It defaults to **MySQL (PyMySQL)** when a credentials source is present (`/opt/vm-transcribe/db_secret`, mode `640 root:root`, or `VM_MYSQL_*` env vars). If neither exists it falls back to the original SQLite file (`VM_DB`). The same `asterisk` MySQL database is shared with Asterisk's own CDR (`cdr_adaptive_odbc` + `res_odbc` + the MariaDB ODBC driver), so voicemail metadata and call records live side by side. This is why the migration kept the `asterisk` DB and a least-privilege `asterisk` user rather than spinning up a separate instance. The connection object returned by `connect()` mimics the `sqlite3` cursor surface callers use (`.execute()`/`.executemany()`, `.commit()`, `.close()`, `.fetchone()`, `.fetchall()`, `lastrowid`, and `row['col']` access), so the rest of the code is written once and works against either backend. --- ## Two backends in front of one store - **`vm_api.py` (FastAPI, :8098)** — the JSON API consumed by the React SPA. Login/logout, message list, mark-read, delete, settings, and full contacts CRUD (`GET/POST /api/contacts`, `GET/PUT/DELETE /api/contacts/{id}`, `/api/contacts/import_vcf`, `/api/contacts/history/{number}`, `/api/add_contact`, `/api/contacts/delete_all`). Apache proxies `/api/` here. - **`vm_web.py` (FastAPI, :8099)** — the original server-rendered HTML portal. It still runs and now primarily serves `/audio/` playback and `/healthz`; Apache proxies `/audio/` here. The interactive UI has moved to the React SPA. Both read and write the same MySQL `messages` / `contacts` tables, so the React UI and the legacy endpoints never disagree. --- ## Frontend: React SPA + Vite The portal UI is a React app (`/home/jp/Work/voicemail-ui`) using `react-router-dom`, TanStack Query, and a Vite build. It is built to `dist/` and rsync'd to `/home/txt3/domains/vm.txt3.net/public_html/`. Apache serves the static build and uses an SPA fallback (any non-`/api/`, non-`/audio/` path returns `index.html`). All data goes through `/api/` → `vm_api.py`. Because the UI is JavaScript, the Apache CSP is **relaxed** to `script-src 'self' 'unsafe-inline'` (see SECURITY.md). The dark "cyber" theme uses neon accents; callers get a dropdown (⋮) on each message/contact row with **Call back** (`tel:` link from sanitised digits) and **Lookup number** (opens a who-called.co.uk search in a new tab, using the 0-prefixed UK number). --- ## Why faster-whisper, CPU, int8, base.en - **faster-whisper** (CTranslate2) is dramatically faster than OpenAI's `whisper` on CPU — ~8 s for 23 s of 8 kHz audio here, versus minutes. - **CPU/int8** because this host has no GPU and the voicemail rate is low. A GPU would be idle 99.9 % of the time. - **base.en** is plenty for clear phone audio; `small.en` only if you see accuracy problems with names/numbers. Set `VM_WHISPER_MODEL`. The model is loaded once per process. Asterisk fires a fresh `mailcmd` per voicemail, so there is a ~1 s model-load cost *per message* — acceptable at PBX volumes, and the model is pre-cached at install so the first call doesn't also pay a download. --- ## Why the summary is extractive, not an LLM The user explicitly declined both OpenAI and Ollama. `summarise()` is: 1. split into sentences, 2. score each by word frequency (stop-words removed) plus a boost for position (first/last sentence) and for sentences containing digits, 3. pick the top sentences, ordered as spoken, capped at ~3 and a length limit, 4. wrap with regex intent tags (`Call back requested`, `Urgent`, `Payment / invoice`, …) and number extraction. It reads well on phone voicemail and needs no network. The single function `summarise()` is a deliberate **swap-in point** — replace it with a local model or an API call without touching the rest of the pipeline. --- ## Why audio is content-addressed Asterisk **renumbers** `msgNNNN` files when a message is deleted from a folder. A stored path like `…/INBOX/msg0042.wav` therefore points at a *different* recording a week later. So: - On store, the audio is copied to `audio//.`. - The spool path is kept only as a *delete hint*. - On delete, the blob is unlinked only if no other row references that hash. This decouples the portal's playback (served by `vm_web.py` :8099 via `/audio/`) from Asterisk's ever-shifting filenames. --- ## Contacts: MySQL only `vm_contacts.py` resolves caller IDs **exclusively** from the MySQL `contacts` table. The older `file` / `google` / `carddav` backends were removed. - Numbers are stored canonical E.164; `normalize_uk()` collapses `+44…` and `0…` (and `0044…`) forms so both match. - Matching uses the full digit string (up to 15 digits), not just last-9, and guards against emoji/placeholder contacts shadowing real names. - Creating/updating a contact backfills `messages.contact_name` / `contact_email` for every callerid that matches (spaces/quotes stripped before the LIKE). - `vm_import_contacts.py` loads a vCard/CSV export into the table. --- ## Auth model Users log in with their **mailbox number + existing voicemail PIN** parsed from `/etc/asterisk/voicemail.conf` (`vm_auth.py`). No password database to keep in sync, no new credentials to provision. Sessions are stored in MySQL (random token, `Secure` cookie, `HttpOnly`, `SameSite=lax`, 12 h) so logout and expiry are enforced server-side, not just by deleting a cookie. Every data query is filtered by the session's mailbox. Cross-mailbox access must 404 — there is a test for this in [TESTING.md](TESTING.md). --- ## Lockout Voicemail PINs are short (often 4 digits) and the portal is internet-facing, so failed logins are throttled in-process: 5 per `(mailbox, IP)` then 15 minutes. State is in memory by design (single worker). A restart clears it — fine, because a restart is an admin action, not an attacker one. --- ## Telegram routing `telegram.conf` is re-read on every voicemail (no reload). Resolution order: `[mailbox:N]` `chat_id` → `default_chat_id` → nothing. A chat id may fan out to several recipients (comma list) or be a group (negative). Per-mailbox sections can override any global (`send_audio`, `send_transcript`). Delivery degrades `sendVoice → sendDocument → sendMessage` so a summary always gets through. --- ## Privacy posture All audio and transcripts stay on the PBX. The only egress is: - **Postfix → your MX** for email (the existing path), - **Telegram API** (only if you enable Telegram), - **who-called.co.uk** (only when a user clicks *Lookup number* in the UI — a manual, user-initiated action that opens a new browser tab). Nothing is sent to OpenAI, Anthropic, or any transcription vendor.