Voicemail
Voicemail is switched on globally, mailboxes are declared per tenant, and two
dialplan steps do the work: voicemail leaves a message, voicemail_check
plays messages back.
configVersion: lyno/v1
kind: System
spec:
dataDir: /var/lib/lyno
voicemail:
enabled: true
maxMessage: 3m
minMessage: 2s
silenceTimeout: 8s
retention: 2160h # 90 days
promptsDir: ../sounds/prompts/en
configVersion: lyno/v1
kind: Mailbox
metadata:
name: "100" # the account extension whose messages it holds
tenant: acme
spec:
displayName: Alice
pin: "4821"
email: alice@example.com
attach: true
deleteAfterEmail: false
greeting: ../sounds/alice-greeting.wav
metadata.name is the account extension whose messages the box holds, and a
name that is not an account extension of the tenant is a validation error.
A mailbox is its own document rather than part of a dialplan because a mailbox is not a dialplan: several plans can leave messages in the same box, and one plan can leave them in a box that is not its own.
Because the name must be an account extension, a box for the main number or for
"nobody answered" needs an Account of its own — say extension 190 — even
though no handset ever registers as it. Give that account a real password anyway:
an account nobody uses is only safe while nobody can guess it.
Voicemail needs a dataDir. Messages land
in <dataDir>/<tenant>/voicemail/<mailbox>/, as a WAV with a JSON sidecar
beside it.
Leaving a message
configVersion: lyno/v1
kind: Extension
metadata:
name: "100"
tenant: acme
spec:
description: Alice
steps:
- {id: ring, type: dial, timeout: 20s, target: {kind: endpoint, endpoint: "100"}}
- {id: box, type: voicemail}
An empty mailbox: means this plan's own extension, which is what makes the
common shape above read well.
In order, the step plays a greeting, plays a beep, and records:
| Greeting, in order of preference | |
|---|---|
| 1 | the step's own greeting: |
| 2 | the mailbox's greeting: |
| 3 | the shipped vm-unavailable + vm-leavemessage prompts |
The greeting can be interrupted by any key; the beep cannot.
Recording stops on the first of:
| Stop | Controlled by |
|---|---|
maxMessage reached | tenant voicemail.maxMessage, overridden per mailbox. Defaults to 3m when neither is set. |
| Silence | voicemail.silenceTimeout. Zero disables it. |
The caller pressed # | Hard-coded. # is the only key that stops a recording. |
| The caller hung up | The message is kept. |
A recording shorter than minMessage is deleted, not stored — that is what
stops a mailbox filling with half-second hang-ups. A zero-length recording is
discarded too.
The audio is written to a .part file and renamed, and the sidecar is written
last. The sidecar is what makes the message exist, so a crash mid-recording
leaves nothing a reader will trip over.
Retrieval
configVersion: lyno/v1
kind: Extension
metadata:
name: "*97"
tenant: acme
spec:
description: Check voicemail
steps:
- {id: check, type: voicemail_check}
With no mailbox: the step opens the mailbox of the authenticated endpoint
that dialled it, which is what makes a *97-style plan work. A call that
arrived over a trunk proved nothing about who placed it — the From header is
whatever the far end wrote — so such a call may only ever reach a mailbox the
plan named itself.
The caller enters the PIN followed by #. Three wrong PINs end the call. The
PIN is compared in constant time.
Then the menu:
| Key | Action |
|---|---|
1 | Replay this message |
2 | Save it, and move on |
3 | Delete it |
4 | Next message |
5 | Previous message |
0 | Finish and hang up |
Three unrecognised keys in a row end the call. A message is only marked as heard once its audio has actually played to the end.
pin is not protectedValidation requires 4 to 8 digits, so this cannot arise from a file that passed
-validate. It is worth knowing that the check is "does the PIN match", not "is
a PIN set".
PINs are stored in the clear
A PIN lives in the configuration next to the SIP passwords, under the same 0600.
Hashing a four-digit PIN is theatre — the whole keyspace is ten thousand — and it
would make resetting one from the editor impossible.
Prompts
promptsDir holds the spoken fragments the retrieval menu assembles: "you
have" + "three" + "new messages". A fragment that is absent is skipped rather
than treated as an error, so a partial set still announces something; a fragment
that is present and cannot be played fails at startup.
The shipped set is generated rather than recorded by hand — see Prompt audio.
The envelope announces "Received … at …" and, for messages from today or yesterday, says which. Anything older announces no date at all: the weekday, month and ordinal fragments are never loaded and the shipped prompt set does not contain them. Number fragments also only cover 0–20 and the tens, so a minute like 47 is dropped from the sentence rather than approximated.
Notification by email
A mailbox with an email: sends a notification when a message arrives. It needs
an smtp: block, globally or on the tenant:
configVersion: lyno/v1
kind: System
spec:
smtp:
host: smtp.example.com
port: 587
security: starttls
username: pbx@example.com
password: "change-me"
from: Lyno PBX <pbx@example.com>
maxAttach: 5m
Validation refuses a mailbox with an email: when no relay is configured for
it.
security | Port default | Behaviour |
|---|---|---|
| (unset) | 587 | Same as starttls. An unset field never sends credentials in the clear. |
starttls | 587 | Refuses to continue when the server does not offer STARTTLS. No plaintext fallback. |
tls | 465 | Implicit TLS, minimum version 1.2. |
none | 25 | No encryption. |
Authentication is attempted only when username is set: PLAIN, falling back to
LOGIN.
The attachment
| Situation | Result |
|---|---|
| Normally | The WAV is attached as voicemail-<mailbox>-<id>.wav. |
attach: false on the mailbox | Not attached. |
Longer than maxAttach | Not attached — some relays refuse large attachments, and the message is in the mailbox either way. |
| The file could not be read | Not attached; the mail is still sent. |
Every case is spelled out for the reader in the {{.Attached}} sentence, so the
mail never leaves somebody wondering where the audio went. Audio is attached
as-is; there is no transcoding.
Templates
subject: and body: are text/template, and are inline strings rather than
file paths so a tenant can override them on a read-only configuration mount.
smtp:
subject: "Voicemail from {{.FromName}} ({{.Duration}})"
body: |
{{.MailboxName}} has a new message.
From: {{.FromName}} <{{.From}}>
Received: {{.Received}}
Length: {{.Duration}}
{{.Attached}}
Dial {{.FeatureCode}} to listen.
| Variable | Is |
|---|---|
{{.From}} | The caller's number, or unknown. |
{{.FromName}} | The caller's name, falling back to the number. |
{{.Mailbox}} | The extension the message was left for. |
{{.MailboxName}} | The mailbox's name, falling back to the extension. |
{{.Received}} | When it arrived, in the tenant's timezone. |
{{.Duration}} | m:ss, or h:mm:ss. |
{{.Attached}} | A sentence saying whether the recording is attached. |
{{.FeatureCode}} | The tenant's featureCodes.voicemail, empty when unset. |
Anything else is an error at send time, not at load time, and the notification is then given up on rather than retried.
{{.FeatureCode}} is the one place featureCodes: is read at all — it is
printed in the mail, not dispatched on.
What happens when mail fails
The set of owed notifications is on disk: a message whose sidecar has no
emailed stamp is one that still needs sending. That survives a restart, and
the first sweep after startup is the crash recovery.
- Retries back off from 30 seconds, doubling to a five-minute ceiling.
- A 5xx reply, or a template that will not execute, is permanent — no retry.
- After one hour from the message's arrival the notification is given up on and logged at error level.
- A message first seen when it is already older than an hour is skipped silently. Filling in an address does not flood the owner with a fortnight of mail.
Giving up drops the notification, never the message. The message stays in the mailbox.
The message-waiting lamp does not work
voicemail.mwi is inertThe key is accepted and the editor offers it, but there is no NOTIFY sender:
nothing ever builds a Message-Summary body, and a phone's SUBSCRIBE is
answered 405 Method Not Allowed.
The lamp on the phone will never light, whatever mwi is set to. Notification by
email and the voicemail_check step are the two ways anybody finds out about a
message.
Retention
voicemail.retention is applied by a janitor that runs every 24 hours, with the
first pass at startup. Zero keeps messages forever.