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
+642 -58

No files matched your search

+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 });
});
});