Skip to main content

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.

A shared mailbox needs an account to own it

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
1the step's own greeting:
2the mailbox's greeting:
3the shipped vm-unavailable + vm-leavemessage prompts

The greeting can be interrupted by any key; the beep cannot.

Recording stops on the first of:

StopControlled by
maxMessage reachedtenant voicemail.maxMessage, overridden per mailbox. Defaults to 3m when neither is set.
Silencevoicemail.silenceTimeout. Zero disables it.
The caller pressed #Hard-coded. # is the only key that stops a recording.
The caller hung upThe 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:

KeyAction
1Replay this message
2Save it, and move on
3Delete it
4Next message
5Previous message
0Finish 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.

A mailbox with an empty pin is not protected

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

Dates are not announced

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.

securityPort defaultBehaviour
(unset)587Same as starttls. An unset field never sends credentials in the clear.
starttls587Refuses to continue when the server does not offer STARTTLS. No plaintext fallback.
tls465Implicit TLS, minimum version 1.2.
none25No encryption.

Authentication is attempted only when username is set: PLAIN, falling back to LOGIN.

The attachment

SituationResult
NormallyThe WAV is attached as voicemail-<mailbox>-<id>.wav.
attach: false on the mailboxNot attached.
Longer than maxAttachNot attached — some relays refuse large attachments, and the message is in the mailbox either way.
The file could not be readNot 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.
VariableIs
{{.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 inert

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

Reference