import { createServer, type Server, type Socket } from 'node:net'; import { afterEach, describe, expect, it } from 'vitest'; import type { ChannelDeliveryInput } from './channel-delivery.js'; import { sendEmailNotification } from './smtp-sender.js'; interface FakePeer { readonly port: number; readonly lines: string[]; stop(): Promise; } const peers: FakePeer[] = []; afterEach(async () => { for (const peer of peers.splice(0)) await peer.stop(); }); /** * Scripted SMTP peer. It answers one reply per command and stays quiet while a * message body streams in, which is how a real server behaves after `354`. */ function fakeServer(script: readonly string[]): Promise { return new Promise((resolve) => { const lines: string[] = []; const sockets = new Set(); const server: Server = createServer((socket) => { sockets.add(socket); socket.once('close', () => sockets.delete(socket)); let index = 0; let buffered = ''; let inData = false; socket.write(`${script[index++] ?? '221 bye'}\r\n`); socket.on('data', (chunk) => { buffered += chunk.toString('utf8'); let boundary = buffered.indexOf('\r\n'); while (boundary >= 0) { const line = buffered.slice(0, boundary); buffered = buffered.slice(boundary + 2); boundary = buffered.indexOf('\r\n'); lines.push(line); if (inData) { if (line === '.') inData = false; else continue; } if (line === 'DATA') inData = true; // A silent peer is deliberate: it lets a test assert the client timeout. const reply = script[index++]; if (reply !== undefined) socket.write(`${reply}\r\n`); } }); }); server.listen(0, '127.0.0.1', () => { const address = server.address(); const peer: FakePeer = { port: typeof address === 'object' && address ? address.port : 0, lines, stop: () => new Promise((done) => { for (const socket of sockets) socket.destroy(); server.close(() => done()); }), }; peers.push(peer); resolve(peer); }); }); } function input(config: ChannelDeliveryInput['config']): ChannelDeliveryInput { return { type: 'email', config, title: '设备离线', body: 'SIM 卡所在设备已断开', eventType: 'device', occurredAt: '2026-09-03T08:00:00.000Z', }; } // One reply per command: greeting, EHLO, AUTH LOGIN, user, password, MAIL FROM, // two RCPT TO, DATA, the "." terminator, then QUIT. const AUTH_SCRIPT = [ '220 smtp ready', '250 ehlo', '334 ' + Buffer.from('Username:').toString('base64'), '334 ' + Buffer.from('Password:').toString('base64'), '235 authenticated', '250 sender ok', '250 recipient ok', '250 recipient ok', '354 send data', '250 message accepted', '221 bye', ]; const OPEN_SCRIPT = [ '220 smtp ready', '250 ehlo', '250 sender ok', '250 recipient ok', '354 send data', '250 message accepted', '221 bye', ]; function messageLines(lines: readonly string[]): string[] { const start = lines.indexOf('DATA'); return start < 0 ? [] : lines.slice(start + 1); } describe('sendEmailNotification', () => { it('runs AUTH LOGIN and submits one UTF-8 message per recipient', async () => { const peer = await fakeServer(AUTH_SCRIPT); const result = await sendEmailNotification( input({ smtp_host: '127.0.0.1', smtp_port: peer.port, smtp_security: 'none', username: 'notify@example.test', password: 'p@ss', sender_address: 'notify@example.test', sender_name: 'SimAdmin 控制台', receiver_addresses: 'ops@example.test, admin@example.test', message_format: 'plain', }), 2_000, ); expect(result).toEqual({ ok: true }); expect(peer.lines[0]).toMatch(/^EHLO /u); expect(peer.lines[1]).toBe('AUTH LOGIN'); expect(peer.lines[2]).toBe(Buffer.from('notify@example.test').toString('base64')); expect(peer.lines[3]).toBe(Buffer.from('p@ss').toString('base64')); expect(peer.lines[4]).toBe('MAIL FROM:'); expect(peer.lines.filter((line) => line.startsWith('RCPT TO:<'))).toEqual([ 'RCPT TO:', 'RCPT TO:', ]); const message = messageLines(peer.lines); expect(message.at(-1)).toBe('.'); const headers = message.slice(0, message.indexOf('')); expect(headers.join('\n')).toContain('Subject: =?UTF-8?B?'); expect(headers.join('\n')).toContain('Content-Type: text/plain; charset=UTF-8'); const payload = Buffer.from( message.slice(message.indexOf('') + 1, message.length - 1).join(''), 'base64', ).toString('utf8'); expect(payload).toBe('设备离线\n\nSIM 卡所在设备已断开'); }); it('switches the MIME subtype when the channel asks for HTML', async () => { const peer = await fakeServer(OPEN_SCRIPT); const result = await sendEmailNotification( input({ smtp_host: '127.0.0.1', smtp_port: peer.port, smtp_security: 'none', sender_address: 'a@example.test', receiver_addresses: 'b@example.test', message_format: 'html', }), 2_000, ); expect(result).toEqual({ ok: true }); expect(messageLines(peer.lines).join('\n')).toContain('Content-Type: text/html'); }); it('refuses to dial out when no receiver address is usable', async () => { await expect( sendEmailNotification( input({ smtp_host: '127.0.0.1', smtp_port: 1, sender_address: 'notify@example.test', receiver_addresses: 'not-an-address', }), 500, ), ).resolves.toEqual({ ok: false, detail: '缺少有效的收件地址' }); }); it('reports a rejected handshake instead of throwing', async () => { const peer = await fakeServer(['220 smtp ready', '421 service denied']); await expect( sendEmailNotification( input({ smtp_host: '127.0.0.1', smtp_port: peer.port, smtp_security: 'none', sender_address: 'a@example.test', receiver_addresses: 'b@example.test', }), 2_000, ), ).resolves.toMatchObject({ ok: false, detail: 'SMTP 返回 421:421 service denied' }); }); it('gives up with a readable detail when the server stops answering', async () => { const peer = await fakeServer(['220 smtp ready']); await expect( sendEmailNotification( input({ smtp_host: '127.0.0.1', smtp_port: peer.port, smtp_security: 'none', sender_address: 'a@example.test', receiver_addresses: 'b@example.test', }), 300, ), ).resolves.toMatchObject({ ok: false, detail: 'SMTP 响应超时' }); }); });