voicemail: index messages, live event, email with the recording

services/voicemail.ts rescans a mailbox on Asterisk's MessageWaiting event
(and every mailbox on AMI connect) instead of watching files, upserts
voicemail_messages by msg_id, marks vanished ones deleted, sends
voicemail.new to the owner only, and emails the wav. Delete-after-email
uses AMI VoicemailRemove, and only when the audio was attached. Asterisk
24 has VoicemailMove/VoicemailRemove, so 04 §5.6 drops the renumbering
plan. New VOICEMAIL_DIR setting; e2e m4-t2 checks the email arrives
within 10 s with a playable attachment (SMTP sink shared with m3-t4).

Task: M4-T2
This commit is contained in:
iamdoubz committed 2026-10-17 22:33:52 -05:00
1 parent 0d29131d18
commit 8d752ba705
16 files changed
+638 -54

No files matched your search

+1 -1
View File
@@ -1 +1 @@
9468 10280
+2
View File
@@ -15,6 +15,8 @@ const EnvSchema = z.object({
AMI_SECRET: z.string().min(8), AMI_SECRET: z.string().min(8),
/** Where generated Asterisk config goes (03 §3). Dev: ../../data/asterisk-generated */ /** Where generated Asterisk config goes (03 §3). Dev: ../../data/asterisk-generated */
ASTERISK_CONFIG_DIR: z.string().default('/etc/asterisk/selfphoneng'), ASTERISK_CONFIG_DIR: z.string().default('/etc/asterisk/selfphoneng'),
/** Asterisk's voicemail spool, shared with the api (04 §5.6). Dev: ../../data/voicemail */
VOICEMAIL_DIR: z.string().default('/var/spool/asterisk/voicemail'),
API_PORT: z.coerce.number().int().min(1).max(65535).default(8080), API_PORT: z.coerce.number().int().min(1).max(65535).default(8080),
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
TRUST_PROXY: z.stringbool().default(true), TRUST_PROXY: z.stringbool().default(true),
+14
View File
@@ -11,6 +11,7 @@ import { ensureInitialAdmin } from './services/bootstrap.ts';
import { LiveState } from './live/state.ts'; import { LiveState } from './live/state.ts';
import { smtpMailer } from './services/mail.ts'; import { smtpMailer } from './services/mail.ts';
import { watchEmergencies } from './services/emergency.ts'; import { watchEmergencies } from './services/emergency.ts';
import { watchVoicemail } from './services/voicemail.ts';
let config; let config;
try { try {
@@ -50,6 +51,18 @@ app.decorate('ami', ami);
const sendMail = smtpMailer(db); const sendMail = smtpMailer(db);
app.decorate('sendMail', sendMail); app.decorate('sendMail', sendMail);
watchEmergencies({ db, ami, live, send: sendMail, log: app.log }, ami); watchEmergencies({ db, ami, live, send: sendMail, log: app.log }, ami);
const voicemail = watchVoicemail(
{
db,
spoolDir: resolve(config.VOICEMAIL_DIR),
ami,
live,
send: sendMail,
publicHost: config.PUBLIC_HOSTNAME,
log: app.log,
},
ami,
);
app.decorate('live', live); app.decorate('live', live);
await runMigrations(db); await runMigrations(db);
@@ -72,6 +85,7 @@ ami.on('connected', () => {
app.log.info('asterisk restarted: restart no longer required'); app.log.info('asterisk restarted: restart no longer required');
await generator.apply({ trigger: 'startup', force: true }); await generator.apply({ trigger: 'startup', force: true });
await live.loadFromAsterisk(); await live.loadFromAsterisk();
await voicemail.scanAll();
})().catch((err) => app.log.error({ err }, 'startup sync failed')); })().catch((err) => app.log.error({ err }, 'startup sync failed'));
}); });
ami.start(); ami.start();
+5 -1
View File
@@ -34,10 +34,14 @@ export async function liveRoutes(app: FastifyInstance) {
? `trunk:${m.data.trunkId}` ? `trunk:${m.data.trunkId}`
: m.type === 'emergency.call' : m.type === 'emergency.call'
? `emergency:${m.data.at}:${m.data.ext}` // never merge two 911 calls ? `emergency:${m.data.at}:${m.data.ext}` // never merge two 911 calls
: m.type === 'voicemail.new'
? `voicemail:${m.data.id}`
: m.type; : m.type;
const maySee = (c: Client, m: LiveMessage) => const maySee = (c: Client, m: LiveMessage) =>
c.admin || m.type === 'voicemail.new'
? m.data.userId === c.userId // private, even from admins
: c.admin ||
(m.type === 'emergency.call' (m.type === 'emergency.call'
? m.data.audienceUserIds.includes(c.userId) ? m.data.audienceUserIds.includes(c.userId)
: !m.type.startsWith('trunk.')); : !m.type.startsWith('trunk.'));
+8 -3
View File
@@ -5,7 +5,12 @@ import type { Db } from '../db/client.ts';
import { decrypt } from '../crypto/secrets.ts'; import { decrypt } from '../crypto/secrets.ts';
import { getSetting } from './settings.ts'; import { getSetting } from './settings.ts';
export type Mail = { to: string[]; subject: string; text: string }; export type Mail = {
to: string[];
subject: string;
text: string;
attachments?: { filename: string; path: string }[];
};
export type SendMail = (m: Mail) => Promise<void>; export type SendMail = (m: Mail) => Promise<void>;
export class MailNotConfigured extends Error { export class MailNotConfigured extends Error {
@@ -16,7 +21,7 @@ export class MailNotConfigured extends Error {
/** Reads the SMTP settings on every send, so changes apply without a restart. */ /** Reads the SMTP settings on every send, so changes apply without a restart. */
export function smtpMailer(db: Db): SendMail { export function smtpMailer(db: Db): SendMail {
return async ({ to, subject, text }) => { return async ({ to, subject, text, attachments }) => {
const smtp = await getSetting(db, 'smtp'); const smtp = await getSetting(db, 'smtp');
if (!smtp) throw new MailNotConfigured(); if (!smtp) throw new MailNotConfigured();
const transport = nodemailer.createTransport({ const transport = nodemailer.createTransport({
@@ -27,6 +32,6 @@ export function smtpMailer(db: Db): SendMail {
connectionTimeout: 15_000, connectionTimeout: 15_000,
greetingTimeout: 15_000, greetingTimeout: 15_000,
}); });
await transport.sendMail({ from: smtp.from, to, subject, text }); await transport.sendMail({ from: smtp.from, to, subject, text, attachments });
}; };
} }
+234
View File
@@ -0,0 +1,234 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Voicemail index (04 §5.6). Asterisk records into the spool; this keeps voicemail_messages in step
// with it, tells the owner, and emails them. A mailbox is rescanned on Asterisk's MessageWaiting
// event, which fires whenever a message is left, listened to or deleted (from a phone or from us),
// so there is no file watcher (inotify is unreliable on bind mounts).
import type { EventEmitter } from 'node:events';
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { and, eq, isNull, notInArray } from 'drizzle-orm';
import {
formatNumber,
VOICEMAIL_FOLDERS,
type LiveMessage,
type VoicemailDto,
type VoicemailFolder,
} from '@selfphoneng/shared';
import { field, type AmiMessage } from '../ami/events.ts';
import type { Db } from '../db/client.ts';
import { extensions, users, voicemailMessages } from '../db/schema.ts';
import { MailNotConfigured, type SendMail } from './mail.ts';
import { getSetting } from './settings.ts';
export type VoicemailDeps = {
db: Db;
/** /var/spool/asterisk/voicemail */
spoolDir: string;
ami: { action(fields: Record<string, string>): Promise<unknown> };
live: { emit(event: 'message', m: LiveMessage): unknown };
send: SendMail;
publicHost: string;
log: { info(o: object, msg: string): void; error(o: object, msg: string): void };
};
type Row = typeof voicemailMessages.$inferSelect;
/** msgNNNN.txt: an INI file with one [message] section. */
export function parseMessageTxt(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const m = /^([a-z_]+)=(.*)$/.exec(line);
if (m) out[m[1]!] = m[2]!;
}
return out;
}
/** `"Bob" <102>`, `<102>`, `102`, `Bob`, `Unknown`. */
export function parseCallerId(v = ''): { name: string | null; number: string | null } {
const m = /^\s*"?([^"<]*?)"?\s*<([^>]*)>\s*$/.exec(v);
if (m) return { name: m[1]!.trim() || null, number: m[2]!.trim() || null };
const s = v.trim();
if (!s || /^unknown$/i.test(s)) return { name: null, number: null };
return /^[0-9*#+]+$/.test(s) ? { name: null, number: s } : { name: s, number: null };
}
export const toVoicemailDto = (r: Row): VoicemailDto => ({
id: r.id,
folder: r.folder as VoicemailFolder,
callerNumber: r.callerNumber,
callerName: r.callerName,
durationS: r.durationS,
receivedAt: r.receivedAt.toISOString(),
});
/** Everything in one mailbox's INBOX, Old and Urgent folders. filePath is relative to the spool. */
async function readMailbox(spoolDir: string, ext: string) {
const found: (Omit<typeof voicemailMessages.$inferInsert, 'extensionId'> & { msgId: string })[] =
[];
for (const folder of VOICEMAIL_FOLDERS) {
const rel = `default/${ext}/${folder}`;
const names = await readdir(join(spoolDir, rel)).catch(() => [] as string[]);
for (const n of names.filter((n) => /^msg\d{4}\.txt$/.test(n)).sort()) {
const info = parseMessageTxt(await readFile(join(spoolDir, rel, n), 'utf8').catch(() => ''));
if (!info.msg_id) continue; // still being written: the next MessageWaiting picks it up
const cid = parseCallerId(info.callerid);
found.push({
msgId: info.msg_id,
folder,
callerName: cid.name,
callerNumber: cid.number,
durationS: Number(info.duration) || 0,
receivedAt: new Date(Number(info.origtime) * 1000),
filePath: `${rel}/${n.replace(/\.txt$/, '.wav')}`,
});
}
}
return found;
}
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
type Box = {
id: string;
number: string;
userId: string | null;
email: string | null;
voicemailEmail: boolean;
voicemailAttachAudio: boolean;
voicemailDeleteAfterEmail: boolean;
};
async function announce(d: VoicemailDeps, box: Box, row: Row) {
if (box.userId)
d.live.emit('message', {
type: 'voicemail.new',
data: { ...toVoicemailDto(row), userId: box.userId },
});
if (!box.voicemailEmail || !box.email) return;
const tz = await getSetting(d.db, 'general.timezone');
const from = row.callerName ?? (row.callerNumber ? formatNumber(row.callerNumber) : 'unknown');
const at = row.receivedAt.toLocaleString('en-US', { timeZone: tz });
// sv-SE formats as "2026-10-17 15:13:05".
const stamp = row.receivedAt.toLocaleString('sv-SE', { timeZone: tz }).slice(0, 16);
try {
await d.send({
to: [box.email],
subject: `New voicemail from ${from} (${mmss(row.durationS)})`,
text: [
`Caller: ${row.callerName ?? 'unknown'}`,
`Number: ${row.callerNumber ? formatNumber(row.callerNumber) : 'unknown'}`,
`Received: ${at}`,
`Length: ${mmss(row.durationS)}`,
'',
`Listen: https://${d.publicHost}/me/voicemail`,
].join('\n'),
attachments: box.voicemailAttachAudio
? [
{
filename: `voicemail-${stamp.replace(/[ :]/g, '-')}.wav`,
path: join(d.spoolDir, row.filePath),
},
]
: undefined,
});
} catch (err) {
if (!(err instanceof MailNotConfigured))
d.log.error({ err, ext: box.number, msgId: row.msgId }, 'voicemail email failed');
return;
}
await d.db
.update(voicemailMessages)
.set({ emailedAt: new Date() })
.where(eq(voicemailMessages.id, row.id));
// Only when the audio went with the email, so the message can't be lost.
if (box.voicemailDeleteAfterEmail && box.voicemailAttachAudio) {
await d.ami.action({
Action: 'VoicemailRemove',
Context: 'default',
Mailbox: box.number,
Folder: row.folder,
ID: row.msgId,
});
await d.db
.update(voicemailMessages)
.set({ deletedAt: new Date() })
.where(eq(voicemailMessages.id, row.id));
}
}
/** Brings one mailbox's rows in line with its files. New INBOX/Urgent messages are announced. */
export async function scanMailbox(d: VoicemailDeps, number: string) {
const [box] = await d.db
.select({
id: extensions.id,
number: extensions.number,
userId: extensions.userId,
email: users.email,
voicemailEmail: extensions.voicemailEmail,
voicemailAttachAudio: extensions.voicemailAttachAudio,
voicemailDeleteAfterEmail: extensions.voicemailDeleteAfterEmail,
})
.from(extensions)
.leftJoin(users, eq(users.id, extensions.userId))
.where(eq(extensions.number, number));
if (!box) return;
const found = await readMailbox(d.spoolDir, number);
const known = new Set(
(
await d.db
.select({ msgId: voicemailMessages.msgId })
.from(voicemailMessages)
.where(eq(voicemailMessages.extensionId, box.id))
).map((r) => r.msgId),
);
for (const f of found) {
const [row] = (await d.db
.insert(voicemailMessages)
.values({ ...f, extensionId: box.id })
.onConflictDoUpdate({
target: voicemailMessages.msgId,
set: { folder: f.folder, filePath: f.filePath, deletedAt: null },
})
.returning()) as [Row];
if (!known.has(f.msgId) && f.folder !== 'Old') await announce(d, box, row);
}
const seen = found.map((f) => f.msgId);
await d.db
.update(voicemailMessages)
.set({ deletedAt: new Date() })
.where(
and(
eq(voicemailMessages.extensionId, box.id),
isNull(voicemailMessages.deletedAt),
seen.length ? notInArray(voicemailMessages.msgId, seen) : undefined,
),
);
}
/** Rescans a mailbox on every MessageWaiting; one scan at a time per mailbox. */
export function watchVoicemail(d: VoicemailDeps, ami: Pick<EventEmitter, 'on'>) {
const chains = new Map<string, Promise<void>>();
const scan = (number: string) => {
const next = (chains.get(number) ?? Promise.resolve())
.then(() => scanMailbox(d, number))
.catch((err: unknown) => d.log.error({ err, ext: number }, 'voicemail scan failed'));
chains.set(number, next);
return next;
};
ami.on('event', (m: AmiMessage) => {
if (field(m, 'Event') !== 'MessageWaiting') return;
const [box, context] = (field(m, 'Mailbox') ?? '').split('@');
if (context === 'default' && box && /^\d+$/.test(box)) void scan(box);
});
return {
scan,
/** Startup and AMI reconnects: messages may have come and gone while we weren't listening. */
scanAll: async () => {
const rows = await d.db
.select({ number: extensions.number })
.from(extensions)
.where(eq(extensions.voicemailEnabled, true));
await Promise.all(rows.map((r) => scan(r.number)));
},
};
}
+174
View File
@@ -0,0 +1,174 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M4-T2: voicemail index, live event and email (04 §5.6, §10).
import { mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { LiveMessage } from '@selfphoneng/shared';
import { extensions, voicemailMessages } from '../src/db/schema.ts';
import { parseCallerId, watchVoicemail } from '../src/services/voicemail.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe('parseCallerId', () => {
it.each([
['"Bob Smith" <13125550000>', { name: 'Bob Smith', number: '13125550000' }],
['<102>', { name: null, number: '102' }],
['Bob <102>', { name: 'Bob', number: '102' }],
['13125550000', { name: null, number: '13125550000' }],
['Unknown', { name: null, number: null }],
['', { name: null, number: null }],
])('%s', (v, want) => expect(parseCallerId(v)).toEqual(want));
});
const tick = () => new Promise((r) => setTimeout(r, 50));
describe.skipIf(!hasTestDb)('voicemail watcher', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let w: ReturnType<typeof watchVoicemail>;
let spool: string;
let userId: string;
const live: LiveMessage[] = [];
/** Writes a message the way app_voicemail leaves it. */
function leave(folder: string, n: number, msgId: string, callerid: string, duration = 7) {
const dir = join(spool, 'default', '101', folder);
mkdirSync(dir, { recursive: true });
const base = join(dir, `msg${String(n).padStart(4, '0')}`);
writeFileSync(`${base}.wav`, 'RIFF fake audio');
writeFileSync(
`${base}.txt`,
`;\n; Message Information file\n;\n[message]\norigmailbox=101\ncallerid=${callerid}\norigtime=1792300385\nmsg_id=${msgId}\nflag=\nduration=${duration}\n`,
);
}
const rows = () => t.db.select().from(voicemailMessages).orderBy(voicemailMessages.msgId);
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
const cookie = await t.login(ADMIN.email, ADMIN.password);
userId = (await t.addUser('dan@example.com', 'dan-password-123')).id;
await t.app.inject({
method: 'POST',
url: '/api/extensions',
headers: { ...H, cookie },
payload: { number: '101', name: 'Dan Doe', userId },
});
spool = mkdtempSync(join(tmpdir(), 'sp-vm-'));
t.live.on('message', (m) => live.push(m));
w = watchVoicemail(
{
db: t.db,
spoolDir: spool,
ami: t.ami,
live: t.live,
send: t.app.sendMail,
publicHost: 'pbx.test',
log: t.app.log,
},
t.ami.events,
);
});
afterAll(async () => {
await t?.close();
rmSync(spool, { recursive: true, force: true });
});
it('a new message: indexed, owner told, emailed with the audio (on MessageWaiting)', async () => {
leave('INBOX', 0, '1792300385-00000000', '"Bob Smith" <13125550000>', 75);
t.ami.events.emit('event', { Event: 'MessageWaiting', Mailbox: '101@default', New: '1' });
await tick();
await w.scan('101'); // waits for the event's scan too (same mailbox chain)
const [r] = await rows();
expect(r).toMatchObject({
folder: 'INBOX',
callerName: 'Bob Smith',
callerNumber: '13125550000',
durationS: 75,
filePath: 'default/101/INBOX/msg0000.wav',
deletedAt: null,
});
expect(r!.receivedAt.getTime()).toBe(1792300385_000);
expect(r!.emailedAt).not.toBeNull();
expect(live.filter((m) => m.type === 'voicemail.new')).toEqual([
expect.objectContaining({ data: expect.objectContaining({ id: r!.id, userId }) }),
]);
expect(t.mails).toHaveLength(1);
const mail = t.mails[0]!;
expect(mail.to).toEqual(['dan@example.com']);
expect(mail.subject).toBe('New voicemail from Bob Smith (1:15)');
expect(mail.text).toContain('Number: (312) 555-0000');
expect(mail.text).toContain('https://pbx.test/me/voicemail');
expect(mail.attachments).toEqual([
{
filename: expect.stringMatching(/^voicemail-\d{4}-\d\d-\d\d-\d\d-\d\d\.wav$/),
path: join(spool, 'default/101/INBOX/msg0000.wav'),
},
]);
});
it('rescans are idempotent: no second email or event', async () => {
await w.scan('101');
expect(t.mails).toHaveLength(1);
expect(live.filter((m) => m.type === 'voicemail.new')).toHaveLength(1);
});
it('listened to on the phone (INBOX → Old): folder and path follow', async () => {
mkdirSync(join(spool, 'default/101/Old'), { recursive: true });
for (const ext of ['wav', 'txt'])
renameSync(
join(spool, `default/101/INBOX/msg0000.${ext}`),
join(spool, `default/101/Old/msg0000.${ext}`),
);
await w.scan('101');
expect((await rows())[0]).toMatchObject({
folder: 'Old',
filePath: 'default/101/Old/msg0000.wav',
});
});
it('deleted on the phone: the row is marked deleted', async () => {
rmSync(join(spool, 'default/101/Old'), { recursive: true });
await w.scan('101');
expect((await rows())[0]!.deletedAt).not.toBeNull();
});
it('delete-after-email removes it through AMI; no email setting means no email', async () => {
await t.db
.update(extensions)
.set({ voicemailDeleteAfterEmail: true })
.where(eq(extensions.number, '101'));
leave('INBOX', 0, '1792300400-00000001', '<102>');
await w.scan('101');
expect(t.ami.actions.at(-1)).toEqual({
Action: 'VoicemailRemove',
Context: 'default',
Mailbox: '101',
Folder: 'INBOX',
ID: '1792300400-00000001',
});
const r = (await rows()).find((x) => x.msgId === '1792300400-00000001')!;
expect(r.deletedAt).not.toBeNull();
await t.db
.update(extensions)
.set({ voicemailEmail: false, voicemailDeleteAfterEmail: false })
.where(eq(extensions.number, '101'));
leave('INBOX', 1, '1792300500-00000002', 'Unknown');
const before = t.mails.length;
await w.scan('101');
expect(t.mails).toHaveLength(before);
expect(live.filter((m) => m.type === 'voicemail.new')).toHaveLength(3);
});
it('a failed email is logged, the message stays indexed and is not marked emailed', async () => {
await t.db.update(extensions).set({ voicemailEmail: true }).where(eq(extensions.number, '101'));
t.mail.fail = 'smtp down';
leave('INBOX', 2, '1792300600-00000003', '<103>');
await w.scan('101');
t.mail.fail = null;
const r = (await rows()).find((x) => x.msgId === '1792300600-00000003')!;
expect(r).toMatchObject({ emailedAt: null, deletedAt: null });
});
});
+20 -15
View File
@@ -236,21 +236,26 @@ Keep one function per key type (`setDnd(ext, on)`, etc.). Routes call these afte
### 5.6 Voicemail watcher (`services/voicemail.ts`) ### 5.6 Voicemail watcher (`services/voicemail.ts`)
- Watch `/var/spool/asterisk/voicemail/default/*/{INBOX,Old,Urgent}/msg*.txt` with chokidar (`awaitWriteFinish: { stabilityThreshold: 2000 }`). - **When to look:** rescan one mailbox's `INBOX`, `Old` and `Urgent` folders on every AMI
- Parse the `.txt` file: INI-like, section `[message]`, keys `callerid`, `origtime` (epoch), `MessageWaiting` event (`Mailbox: 101@default`), and every mailbox on each AMI (re)connect.
`duration`, `msg_id`, `flag`. `callerid` looks like `"Name" <13125551234>`. Asterisk raises it after a message is saved, listened to, moved or deleted, from a phone or
- **add**: upsert `voicemail_messages` by `msg_id` (update `folder`, `file_path`). from us. No file watcher: inotify is unreliable on bind mounts, and this needs no dependency.
If it's new and in INBOX: fire `voicemail.new` (WS + webhook) and, if the user has Scans of one mailbox run one at a time. (M4-T2; the spec first said chokidar.)
`voicemail_email` and SMTP is configured, send an email (§10) with the `.wav` attached - Parse each `msgNNNN.txt`: INI-like, section `[message]`, keys `callerid`, `origtime` (epoch),
if `voicemail_attach_audio`. Then set `emailed_at`. If `voicemail_delete_after_email`, delete it (below). `duration`, `msg_id`, `flag`. `callerid` looks like `"Name" <13125551234>`. A file without
- **unlink**: if no file with that `msg_id` is left anywhere in the mailbox, set `deleted_at`. `msg_id` is still being written: skip it, the next event picks it up.
- **API operations** (`markRead` = move INBOX→Old, `delete`, `move`): Asterisk expects - Upsert `voicemail_messages` by `msg_id` (update `folder`, `file_path`; `file_path` is relative
files in each folder numbered `msg0000…msgNNNN` with no gaps. So every to the spool, e.g. `default/101/INBOX/msg0000.wav`, because Asterisk renumbers files).
operation must, under a per-mailbox lock: move/delete the `.txt` and `.wav` If it's new and not in `Old`: send `voicemail.new` over the live WebSocket (only to the owner,
together, renumber the remaining files in that folder to close gaps, then not to admins; webhook in M11-T5) and, if the user has `voicemail_email`, an email address and
send AMI `VoicemailRefresh Context: default Mailbox: <ext>` so MWI lights update. SMTP is configured, send an email (§10) with the `.wav` attached if `voicemail_attach_audio`.
First check `manager show commands` on the installed Asterisk for `VoicemailRemove`/`VoicemailMove` Then set `emailed_at`. If `voicemail_delete_after_email` **and** the audio was attached, remove
actions. If they exist, use them instead and delete the renumbering code. it with AMI `VoicemailRemove` and set `deleted_at`. A failed email is logged; the message stays.
- Rows whose `msg_id` is no longer in any folder get `deleted_at`.
- **API operations** (`markRead` = INBOX→Old, `delete`): use the AMI actions `VoicemailMove`
(`Context, Mailbox, Folder, ID, ToFolder`) and `VoicemailRemove` (`Context, Mailbox, Folder, ID`),
where `ID` is the `msg_id`. They exist in Asterisk 24 (checked on 24.0.0-rc2), renumber the
folder and update MWI, so there is no renumbering code.
- Greetings: `PUT /api/me/greetings/:kind` (kind: `unavail` | `busy` | `greet` (name) | `temp`) - Greetings: `PUT /api/me/greetings/:kind` (kind: `unavail` | `busy` | `greet` (name) | `temp`)
converts the upload to `/var/spool/asterisk/voicemail/default/<ext>/<kind>.wav` converts the upload to `/var/spool/asterisk/voicemail/default/<ext>/<kind>.wav`
(8 kHz mono PCM). `DELETE` removes it. (8 kHz mono PCM). `DELETE` removes it.
+1
View File
@@ -10,3 +10,4 @@ export * from './live.ts';
export * from './trunks.ts'; export * from './trunks.ts';
export * from './dids.ts'; export * from './dids.ts';
export * from './outbound.ts'; export * from './outbound.ts';
export * from './voicemail.ts';
+3
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
// Live state shapes sent over the /api/live WebSocket (04 §5.1, §5.2). // Live state shapes sent over the /api/live WebSocket (04 §5.1, §5.2).
import type { VoicemailDto } from './voicemail.ts';
export type ExtensionState = export type ExtensionState =
'idle' | 'inuse' | 'busy' | 'unavailable' | 'ringing' | 'onhold' | 'ringinuse'; 'idle' | 'inuse' | 'busy' | 'unavailable' | 'ringing' | 'onhold' | 'ringinuse';
@@ -59,6 +60,8 @@ export type LiveMessage =
| { type: 'trunk.updated'; data: TrunkLive } | { type: 'trunk.updated'; data: TrunkLive }
| { type: 'trunk.removed'; data: { trunkId: string } } | { type: 'trunk.removed'; data: { trunkId: string } }
| { type: 'emergency.call'; data: EmergencyCall } | { type: 'emergency.call'; data: EmergencyCall }
/** Only to the mailbox owner. */
| { type: 'voicemail.new'; data: VoicemailDto & { userId: string } }
| { type: 'ami.status'; data: { connected: boolean } } | { type: 'ami.status'; data: { connected: boolean } }
| { | {
type: 'system.configApplied'; type: 'system.configApplied';
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Voicemail messages (02 voicemail_messages, 04 §5.6).
/** Asterisk's mailbox folders that we index. */
export const VOICEMAIL_FOLDERS = ['INBOX', 'Old', 'Urgent'] as const;
export type VoicemailFolder = (typeof VOICEMAIL_FOLDERS)[number];
export type VoicemailDto = {
id: string;
folder: VoicemailFolder;
callerNumber: string | null;
callerName: string | null;
durationS: number;
receivedAt: string;
};
+6
View File
@@ -45,3 +45,9 @@ baresip phones, and `trunk-test.ts` with registration against a fake provider) n
`m3-t5.mjs` is the M3-T5 acceptance and needs its own fresh database: it signs in with the `m3-t5.mjs` is the M3-T5 acceptance and needs its own fresh database: it signs in with the
initial password, walks all ten wizard steps against a fake voip.ms, then proves an inbound call initial password, walks all ten wizard steps against a fake voip.ms, then proves an inbound call
to the DID rings the new user's softphone and an outbound call leaves with the DID as caller ID. to the DID rings the new user's softphone and an outbound call leaves with the DID as caller ID.
`m4-t2.mjs` is the M4-T2 acceptance (no browser): start m2-live with a voicemail dir
(`m2-live.mjs up <confDir> <vmDir>`) and the api with `VOICEMAIL_DIR=<vmDir>`. It runs the SMTP
sink (`smtp-sink.mjs`, shared with `m3-t4.mjs`), adds a user and extension 150 with voicemail,
leaves a message from a Local channel, and checks the email arrives within 10 s with a PCM 8 kHz
wav attached. The voicemail itself (MWI on a phone, `*97`) is covered by `pnpm test:asterisk`.
+6 -3
View File
@@ -3,9 +3,10 @@
// AMI published on 127.0.0.1:15038, the api writing its config into that container, two baresip // AMI published on 127.0.0.1:15038, the api writing its config into that container, two baresip
// phones, and the dashboard checked in a headless browser. // phones, and the dashboard checked in a headless browser.
// //
// 1. node scripts/e2e/m2-live.mjs up <confDir> // 1. node scripts/e2e/m2-live.mjs up <confDir> [<voicemailDir>]
// starts Asterisk; then start the api with AMI_PORT=15038 AMI_SECRET=m2-live-secret // starts Asterisk; then start the api with AMI_PORT=15038 AMI_SECRET=m2-live-secret
// ASTERISK_CONFIG_DIR=<confDir> and run m1-t4.mjs to get past the first login // ASTERISK_CONFIG_DIR=<confDir> [VOICEMAIL_DIR=<voicemailDir>] and run m1-t4.mjs to get
// past the first login
// 2. node scripts/e2e/m2-live.mjs check <baseUrl> <adminEmail> <adminPassword> <shotsDir> // 2. node scripts/e2e/m2-live.mjs check <baseUrl> <adminEmail> <adminPassword> <shotsDir>
// 3. node scripts/e2e/m2-live.mjs down // 3. node scripts/e2e/m2-live.mjs down
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -28,8 +29,9 @@ const cli = (cmd) => docker('exec', AST, 'asterisk', '-rx', cmd).out;
const [cmd, ...args] = process.argv.slice(2); const [cmd, ...args] = process.argv.slice(2);
if (cmd === 'up') { if (cmd === 'up') {
const conf = args[0]; const [conf, vm] = args;
mkdirSync(conf, { recursive: true }); mkdirSync(conf, { recursive: true });
if (vm) mkdirSync(vm, { recursive: true });
const dir = mkdtempSync(join(tmpdir(), 'sp-live-')); const dir = mkdtempSync(join(tmpdir(), 'sp-live-'));
writeFileSync( writeFileSync(
join(dir, 'manager.conf.template'), join(dir, 'manager.conf.template'),
@@ -53,6 +55,7 @@ if (cmd === 'up') {
`${conf}:/etc/asterisk/selfphoneng`, `${conf}:/etc/asterisk/selfphoneng`,
'-v', '-v',
`${join(dir, 'manager.conf.template')}:/etc/asterisk/manager.conf.template:ro`, `${join(dir, 'manager.conf.template')}:/etc/asterisk/manager.conf.template:ro`,
...(vm ? ['-v', `${vm}:/var/spool/asterisk/voicemail`] : []),
IMAGE, IMAGE,
); );
assert.ok(r.ok, r.out); assert.ok(r.ok, r.out);
+2 -31
View File
@@ -7,10 +7,10 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { launch } from './cdp.mjs'; import { launch } from './cdp.mjs';
import { smtpSink } from './smtp-sink.mjs';
const NET = 'sp-live'; const NET = 'sp-live';
const AST = 'sp-live-ast'; const AST = 'sp-live-ast';
@@ -23,36 +23,7 @@ const docker = (...a) => {
const cli = (cmd) => docker('exec', AST, 'asterisk', '-rx', cmd).out; const cli = (cmd) => docker('exec', AST, 'asterisk', '-rx', cmd).out;
const [base, email, pw, shotsDir] = process.argv.slice(2); const [base, email, pw, shotsDir] = process.argv.slice(2);
/** Just enough SMTP for nodemailer: no auth, no TLS, keeps every message. */ const { mails, server: smtp } = await smtpSink();
const mails = [];
const smtp = createServer((sock) => {
let data = false;
let buf = '';
let msg = '';
sock.write('220 sink ESMTP\r\n');
sock.on('data', (d) => {
buf += d;
let i;
while ((i = buf.indexOf('\r\n')) >= 0) {
const line = buf.slice(0, i);
buf = buf.slice(i + 2);
if (data) {
if (line === '.') {
data = false;
mails.push(msg);
msg = '';
sock.write('250 ok\r\n');
} else msg += line + '\n';
} else if (/^EHLO/i.test(line)) sock.write('250-sink\r\n250 OK\r\n');
else if (/^DATA/i.test(line)) {
data = true;
sock.write('354 go\r\n');
} else if (/^QUIT/i.test(line)) sock.end('221 bye\r\n');
else sock.write('250 ok\r\n');
}
});
});
await new Promise((r) => smtp.listen(2525, '127.0.0.1', r));
const login = await fetch(`${base}/api/auth/login`, { const login = await fetch(`${base}/api/auth/login`, {
method: 'POST', method: 'POST',
+111
View File
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M4-T2 acceptance: a voicemail left in Asterisk is emailed within 10 s with a playable wav.
// Runs on the m2-live setup started with a voicemail dir (m2-live.mjs up <confDir> <vmDir>) and the
// api started with VOICEMAIL_DIR=<vmDir>, after m1-t4.mjs. Runs its own SMTP sink on 127.0.0.1:2525.
// The caller is a Local channel playing a sound file, so no phones are needed.
// Usage: node scripts/e2e/m4-t2.mjs <baseUrl> <adminEmail> <adminPassword> <vmDir>
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { smtpSink } from './smtp-sink.mjs';
const AST = 'sp-live-ast';
const EXT = '150';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const cli = (cmd) =>
spawnSync('docker', ['exec', AST, 'asterisk', '-rx', cmd], { encoding: 'utf8' }).stdout ?? '';
const [base, email, pw, vmDir] = process.argv.slice(2);
const { mails, server } = await smtpSink();
try {
const login = await fetch(`${base}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng' },
body: JSON.stringify({ email, password: pw }),
});
assert.equal(login.status, 200, 'admin login');
const cookie = login.headers
.getSetCookie()
.map((c) => c.split(';')[0])
.join('; ');
const api = async (method, url, body) => {
const r = await fetch(`${base}${url}`, {
method,
headers: { 'content-type': 'application/json', 'x-requested-with': 'selfphoneng', cookie },
body: body && JSON.stringify(body),
});
const json = await r.json();
assert.ok(r.ok, `${method} ${url}: ${r.status} ${JSON.stringify(json)}`);
return json;
};
await api('PATCH', '/api/settings', {
smtp: {
host: '127.0.0.1',
port: 2525,
secure: false,
username: '',
from: 'PBX <pbx@example.com>',
},
});
const user = await api('POST', '/api/users', {
email: 'val@example.com',
displayName: 'Val Mail',
});
await api('POST', '/api/extensions', {
number: EXT,
name: 'Val Mail',
userId: (user.user ?? user).id,
});
for (
let i = 0;
i < 20 && !new RegExp(`^default\\s+${EXT}\\s`, 'm').test(cli('voicemail show users'));
i++
)
await sleep(500);
assert.match(
cli('voicemail show users'),
new RegExp(`^default\\s+${EXT}\\s`, 'm'),
'mailbox in Asterisk',
);
console.log(`mailbox ${EXT} configured`);
// Straight to voicemail; the "caller" waits out the greeting, then speaks (~6 s).
cli(
`channel originate Local/${EXT}@sp-vm-direct application Playback silence/10&silence/5&tt-weasels&tt-weasels`,
);
const txt = join(vmDir, 'default', EXT, 'INBOX', 'msg0000.txt');
for (let i = 0; i < 240 && !existsSync(txt); i++) await sleep(250);
assert.ok(existsSync(txt), 'Asterisk saved the message');
const t0 = Date.now();
console.log('message saved');
while (!mails.length && Date.now() - t0 < 10_000) await sleep(100);
assert.equal(mails.length, 1, 'one email within 10 s');
console.log(`email arrived after ${((Date.now() - t0) / 1000).toFixed(1)} s`);
const raw = mails[0];
assert.match(raw, /^To: val@example\.com$/m);
assert.match(raw, /^Subject: New voicemail from unknown \(0:\d\d\)$/m);
const boundary = /boundary="?([^";\n]+)"?/.exec(raw)?.[1];
assert.ok(boundary, 'multipart email');
const part = raw.split(`--${boundary}`).find((p) => /filename="?voicemail-[\d-]+\.wav/.test(p));
assert.ok(part, 'wav attachment');
const wav = Buffer.from(part.split(/\n\n/).slice(1).join('').replace(/\s+/g, ''), 'base64');
assert.equal(wav.toString('ascii', 0, 4), 'RIFF');
assert.equal(wav.toString('ascii', 8, 12), 'WAVE');
assert.equal(wav.readUInt16LE(20), 1, 'PCM');
assert.equal(wav.readUInt16LE(22), 1, 'mono');
assert.equal(wav.readUInt32LE(24), 8000, '8 kHz');
const seconds = (wav.length - 44) / 16000;
assert.ok(seconds >= 2, `audio is ${seconds.toFixed(1)} s`);
console.log(`attachment: PCM 8 kHz mono, ${seconds.toFixed(1)} s`);
console.log('M4-T2 (voicemail email): PASS');
} catch (e) {
console.log(`FAIL: ${e.message}`);
console.log(spawnSync('docker', ['logs', '--tail', '40', AST], { encoding: 'utf8' }).stdout);
process.exitCode = 1;
} finally {
server.close();
}
+36
View File
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Just enough SMTP for nodemailer: no auth, no TLS, keeps every message (raw RFC 5322 text).
import { createServer } from 'node:net';
export async function smtpSink(port = 2525) {
const mails = [];
const server = createServer((sock) => {
let data = false;
let buf = '';
let msg = '';
sock.write('220 sink ESMTP\r\n');
sock.on('data', (d) => {
buf += d;
let i;
while ((i = buf.indexOf('\r\n')) >= 0) {
const line = buf.slice(0, i);
buf = buf.slice(i + 2);
if (data) {
if (line === '.') {
data = false;
mails.push(msg);
msg = '';
sock.write('250 ok\r\n');
} else msg += line.replace(/^\.\./, '.') + '\n';
} else if (/^EHLO/i.test(line)) sock.write('250-sink\r\n250 OK\r\n');
else if (/^DATA/i.test(line)) {
data = true;
sock.write('354 go\r\n');
} else if (/^QUIT/i.test(line)) sock.end('221 bye\r\n');
else sock.write('250 ok\r\n');
}
});
});
await new Promise((r) => server.listen(port, '127.0.0.1', r));
return { mails, server };
}