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
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
whisperon 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.enonly if you see accuracy problems with names/numbers. SetVM_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:
- split into sentences,
- score each by word frequency (stop-words removed) plus a boost for position (first/last sentence) and for sentences containing digits,
- pick the top sentences, ordered as spoken, capped at ~3 and a length limit,
- 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…and0…(and0044…) 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_emailfor every callerid that matches (spaces/quotes stripped before the LIKE). vm_import_contacts.pyloads 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_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.