Files
asterisk-voicemail/docs/ARCHITECTURE.md
jp b8c60605fd Update all documentation to current MySQL + React SPA + vm_api.py architecture
README, docs/{ARCHITECTURE,CONFIGURATION,INSTALL,OPERATIONS,SECURITY,TESTING}
and CHANGELOG were stale: they described the original SQLite + CardDAV +
zero-JS vm_web portal. Brought them in line with the actual code:
- MySQL (PyMySQL) as the default store; SQLite fallback only
- vm_contacts resolves from the MySQL contacts table (file/google/carddav removed)
- React SPA frontend + vm_api.py JSON API (:8098); vm_web.py now serves /audio/
- Apache vhost proxies /api/ -> :8098, /audio/ -> :8099, serves the SPA
- CSP relaxed to script-src 'self' 'unsafe-inline' (UI is now JavaScript)
- Added CHANGELOG 1.2.0 entry for the React/vm_api frontend work
2026-08-13 20:20:39 +01:00

8.4 KiB

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/<sha256[:2]>/<sha256>.<ext>.
  • 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.


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_iddefault_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.