Files
SnowLuma/packages/core/tests/notifications-smtp.test.ts
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

326 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, it } from 'vitest';
import net from 'node:net';
import tls from 'node:tls';
import {
buildMimeMessage,
extractAddress,
formatSmtpError,
parseRecipients,
sendSmtpMail,
SmtpError,
} from '../src/notifications/smtp';
// Same throwaway pair as webui-tls.test.ts (CN=snowluma-test).
const CERT = `-----BEGIN CERTIFICATE-----
MIIDETCCAfmgAwIBAgIUNiu66PmcO6Do6cUaB92UKD8j3qYwDQYJKoZIhvcNAQEL
BQAwGDEWMBQGA1UEAwwNc25vd2x1bWEtdGVzdDAeFw0yNjA2MTgwNjIwMTZaFw0z
NjA2MTUwNjIwMTZaMBgxFjAUBgNVBAMMDXNub3dsdW1hLXRlc3QwggEiMA0GCSqG
SIb3DQEBAQUAA4IBDwAwggEKAoIBAQCm0sVJhqlG75gOFJVsUJOfR+oqvb9eSq4t
k5QItuwXr85E2mrzuKFnSzVVbi3VqqyN8t4otL11mGll2ANSjusmWN4hzpaOBBdX
bP4UNX/YA7oHId+QKbVicxJgxDE18aPTwaWullyw23LsMsij8T4nLk4eHiOkeRfi
uLinG1COe3gUGkCK4uyyMT4Vz+y+5Oj9dvoXYL/A/KmcNtq7JrX7F7qYnYsJKLZZ
aZeFyIGSE8ihb0k53iyJ3agWY+rMUD+p5J7pgIZzBd5dcZuI+KwixVKZgapG5n5R
ghrnN2ZAQFz13yTRXYmDYy42m4Ue73hMmmt1xVXyf+WVhvrD6pphAgMBAAGjUzBR
MB0GA1UdDgQWBBRW/HP12nj9fYMYNxa3jyqmnUigvTAfBgNVHSMEGDAWgBRW/HP1
2nj9fYMYNxa3jyqmnUigvTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUA
A4IBAQCijzVQ/jHNoqu6stvkkigUv2lTKrd1EHcTZLfzwQkmNv/hfY2EMobO/Qxs
FhmITreKFALJ/dUwTt0UTO00LV9whEgr2of4x8wwjZ9wRstY6uyRYBP85QC8+8mZ
zWlcf611HugrmpOWjWfEVmmxdI1m26YWTn52nZFPnJqDWg2+RlLJWl55lVotbXEZ
Fvas4Vcf2KOk0QwBQKvpt0BISeTIQhbT4GnducxSxyoXGeBQOjQNYb/vTpMn4F9U
IAxSsfs9WVoHKXOabK8GV89BCxWoRk4UaSahTq/2Vnbgt86tWibt3lA4y49+XhA2
6z7n9HgJAUKqhsDrYvZmM7/VZ2d5
-----END CERTIFICATE-----
`;
const KEY = `-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCm0sVJhqlG75gO
FJVsUJOfR+oqvb9eSq4tk5QItuwXr85E2mrzuKFnSzVVbi3VqqyN8t4otL11mGll
2ANSjusmWN4hzpaOBBdXbP4UNX/YA7oHId+QKbVicxJgxDE18aPTwaWullyw23Ls
Msij8T4nLk4eHiOkeRfiuLinG1COe3gUGkCK4uyyMT4Vz+y+5Oj9dvoXYL/A/Kmc
Ntq7JrX7F7qYnYsJKLZZaZeFyIGSE8ihb0k53iyJ3agWY+rMUD+p5J7pgIZzBd5d
cZuI+KwixVKZgapG5n5RghrnN2ZAQFz13yTRXYmDYy42m4Ue73hMmmt1xVXyf+WV
hvrD6pphAgMBAAECggEAJ13+u4qhIMHCnrQBzPU42PImEt8DLXOvJcc5PFM6ZJ6S
rRHkAk60HAWV+OqOu2jS3o6NGYsJWJpWaPewVQev+zUmelDfm3TgsztfvBIh8K50
dGFsef81tB1WnWo++K1kzUBZ4ljOV9f5hz62tWVlFubo/Vd8bsA6wEB6Jskd2fls
ZOO0hJCb/M6IIwe3cWw4sb/YFTTLMUnVHM0aJfnQ/QH+uraRwecJMv118d0YBYVG
DsNmYqrv9TYNBh4P9mKv6/0x8zaKsJZVLAMwHztILHItOXBfxCbSDivGWVUGiDq2
NxEuBOgxzOelxEYzUzf7B6sjHoEeIL5wIKQbwUbmfwKBgQDZRLhucrMZPw7ous1t
igkO32p1ku7FRxm0/JAskc66oX61KLIYbZFM9N7nTXAEs8ZGhzUkdSgxNcdHWIdK
RI+6pd2FhhcKU1feEohlOHvZTXXEMhm61phWnN391GZXfwMKSpscqsu9kIix2jtw
4YrzKLx82EDDHcLfBh9AtArKywKBgQDEj+7UJp5wuqz9hj+gogXeq4Buv9be1u4u
HCJZMZzLCwVjvoqYw0n1afXP3057v8hQPGsBaxzQpOg7i1lYGGfuZ9T6T8fFrtqw
m/LJLHhMQxeyCEQi1/EoNewvVGSwBJkLOuOs8T2WmMXmOgdHbEYdzkKtT71jst6y
TeJ15hVuAwKBgBMK4t9LTkc4L6ZWOQsQvhp/mmUTq7m+sZIbUMeXP/c7kE9wcauS
btm/3ImJT/gZiZdE4nN/kTY+8GhgafsoZzCEuRWq2vocs+bS2QGGIdS55Uh826R0
ioWM2igVJaMljq6oO1AX6COFN3XfGraaDgOh3mNS0NpJEXtangKdxRRhAoGAP9SF
t/r6hJz6RDHuQ5mZ0l9bC5vciOy+19ZnCRPlWMIxc9ySYV05jSplmqVndSQoRnX4
QbOo3dBPYda0orj6Nx8cuFRkCTvo5GUgCFgakJlQ/o1UowQA2g/4rL35HHfBwzXS
bXzBhUADM+owJu9wLYmneWRlmhSh4MEOAz8+QkUCgYAw0fA1Im2/ftrA2Z2IENTi
U4cLXqeUeGJfXjIBklUmXhc4qrd/9V9eEHjackI/JI7Qhp/QfPWMXLudlSrppnNJ
+s3dVevEKosiZbf4qtTtlmR/IifuTKjK5O94KlBT+evxNsL4Hv0b0oTsVNnEfOOl
OUsvSYNSnfvl87tyLIxYLA==
-----END PRIVATE KEY-----
`;
interface MockOptions {
secure?: boolean;
starttls?: boolean;
mechs?: string;
failAuth?: boolean;
}
interface MockSmtp {
port: number;
commands: string[];
data: string;
close: () => Promise<void>;
}
const closers: Array<() => Promise<void>> = [];
afterEach(async () => {
while (closers.length > 0) {
const close = closers.pop();
if (close) {
await close();
}
}
});
function attachSession(
socket: net.Socket | tls.TLSSocket,
opts: MockOptions,
sink: MockSmtp,
greet = true,
): void {
let buf = '';
let mode: 'cmd' | 'data' = 'cmd';
let loginStep = 0;
const write = (s: string) => socket.write(s);
if (greet) {
write('220 mock ESMTP\r\n');
}
socket.on('data', (chunk) => {
buf += chunk.toString('utf8');
if (mode === 'data') {
const end = buf.indexOf('\r\n.\r\n');
if (end === -1) {
return;
}
sink.data = buf.slice(0, end);
buf = buf.slice(end + 5);
mode = 'cmd';
write('250 OK\r\n');
return;
}
while (true) {
const nl = buf.indexOf('\n');
if (nl === -1) {
break;
}
const line = buf.slice(0, nl).replace(/\r$/, '');
buf = buf.slice(nl + 1);
if (loginStep > 0 && /^[A-Za-z0-9+/=]+$/.test(line)) {
sink.commands.push('(b64)');
} else if (line.startsWith('AUTH PLAIN ')) {
sink.commands.push('AUTH PLAIN');
} else {
sink.commands.push(line);
}
if (line.toUpperCase().startsWith('EHLO')) {
const extra = opts.starttls ? '250-STARTTLS\r\n' : '';
write(`250-mock\r\n${extra}250 AUTH ${opts.mechs ?? 'PLAIN LOGIN'}\r\n`);
} else if (
line.toUpperCase() === 'STARTTLS' &&
opts.starttls &&
socket instanceof net.Socket
) {
write('220 Go ahead\r\n');
socket.removeAllListeners('data');
const tlsSock = new tls.TLSSocket(socket, { isServer: true, cert: CERT, key: KEY });
attachSession(tlsSock, { ...opts, starttls: false }, sink, false);
return;
} else if (line.toUpperCase() === 'AUTH LOGIN') {
loginStep = 1;
write('334 VXNlcm5hbWU6\r\n');
} else if (line.toUpperCase().startsWith('AUTH PLAIN')) {
write(opts.failAuth ? '535 5.7.8 auth failed\r\n' : '235 2.7.0 OK\r\n');
} else if (loginStep === 1 && /^[A-Za-z0-9+/=]+$/.test(line)) {
loginStep = 2;
write('334 UGFzc3dvcmQ6\r\n');
} else if (loginStep === 2 && /^[A-Za-z0-9+/=]+$/.test(line)) {
loginStep = 0;
write(opts.failAuth ? '535 5.7.8 auth failed\r\n' : '235 2.7.0 OK\r\n');
} else if (line.toUpperCase().startsWith('MAIL FROM:')) {
write('250 OK\r\n');
} else if (line.toUpperCase().startsWith('RCPT TO:')) {
write('250 OK\r\n');
} else if (line.toUpperCase() === 'DATA') {
mode = 'data';
write('354 End data with <CR><LF>.<CR><LF>\r\n');
} else if (line.toUpperCase() === 'QUIT') {
write('221 bye\r\n');
socket.end();
} else {
write('250 OK\r\n');
}
}
});
}
function listenMock(opts: MockOptions = {}): Promise<MockSmtp> {
return new Promise((resolve, reject) => {
const sink: MockSmtp = { port: 0, commands: [], data: '', close: async () => undefined };
const onConn = (socket: net.Socket | tls.TLSSocket) => attachSession(socket, opts, sink);
const server = opts.secure
? tls.createServer({ cert: CERT, key: KEY }, onConn)
: net.createServer(onConn);
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
if (!addr || typeof addr === 'string') {
reject(new Error('no port'));
return;
}
sink.port = addr.port;
sink.close = () => new Promise((res) => server.close(() => res()));
closers.push(sink.close);
resolve(sink);
});
});
}
describe('address helpers', () => {
it('extracts a bare or angled address and rejects junk', () => {
expect(extractAddress('bot@qq.com')).toBe('bot@qq.com');
expect(extractAddress('SnowLuma <bot@qq.com>')).toBe('bot@qq.com');
expect(extractAddress('not-an-email')).toBeNull();
expect(extractAddress('')).toBeNull();
});
it('parses unique recipients from mixed separators', () => {
expect(parseRecipients('a@x.com; b@x.com, a@x.com\nc@x.com')).toEqual([
'a@x.com',
'b@x.com',
'c@x.com',
]);
expect(parseRecipients('nope, also nope')).toEqual([]);
});
});
describe('buildMimeMessage', () => {
const base = {
host: 'smtp.example.com',
port: 465,
secure: true,
from: 'SnowLuma <bot@example.com>',
to: ['ops@example.com'],
subject: '账号offline:Alice (1)',
body: 'hello',
};
it('encodes a non-ASCII subject and a plain body as base64', () => {
const raw = buildMimeMessage(base, new Date('2026-09-16T01:00:00Z'));
expect(raw).toContain('Content-Type: text/plain; charset=UTF-8');
expect(raw).toContain('Subject: =?UTF-8?B?');
expect(raw).toContain(Buffer.from('hello', 'utf8').toString('base64'));
expect(raw).toContain('From: SnowLuma <bot@example.com>');
});
it('marks a body that starts with < as HTML', () => {
const raw = buildMimeMessage({ ...base, body: '<p>hi</p>' });
expect(raw).toContain('Content-Type: text/html; charset=UTF-8');
});
});
describe('formatSmtpError', () => {
it('maps connect failures and 535 without leaking the exception class name only', () => {
expect(
formatSmtpError(new SmtpError('x', { code: 'ECONNREFUSED' }), 'smtp.qq.com', 465),
).toBe('无法连接 SMTP:smtp.qq.com:465');
expect(
formatSmtpError(new SmtpError('AUTH failed: 535', { responseCode: 535 }), 'h', 25),
).toBe('SMTP 认证失败');
expect(
formatSmtpError(
new SmtpError('MAIL FROM failed: 550 denied', { responseCode: 550 }),
'h',
25,
),
).toBe('MAIL FROM failed: 550 denied');
});
});
describe('sendSmtpMail — live mock', () => {
const mail = {
host: '127.0.0.1',
from: 'bot@example.com',
to: ['ops@example.com'],
subject: 'offline',
body: '账号下线',
user: 'bot@example.com',
pass: 'secret',
};
it('delivers over AUTH PLAIN on a plaintext socket', async () => {
const mock = await listenMock({ mechs: 'PLAIN' });
await sendSmtpMail({ ...mail, port: mock.port, secure: false });
expect(mock.commands).toContain('AUTH PLAIN');
expect(mock.commands.some((c) => c.startsWith('MAIL FROM:<bot@example.com>'))).toBe(true);
expect(mock.commands.some((c) => c.startsWith('RCPT TO:<ops@example.com>'))).toBe(true);
expect(mock.data).toContain('Subject: offline');
expect(mock.data).toContain(Buffer.from('账号下线', 'utf8').toString('base64'));
expect(mock.commands.join('\n')).not.toContain('secret');
});
it('falls back to AUTH LOGIN when PLAIN is not advertised', async () => {
const mock = await listenMock({ mechs: 'LOGIN' });
await sendSmtpMail({ ...mail, port: mock.port, secure: false });
expect(mock.commands).toContain('AUTH LOGIN');
expect(mock.data).toContain('Subject: offline');
});
it('rejects 535 without sending DATA', async () => {
const mock = await listenMock({ mechs: 'PLAIN', failAuth: true });
await expect(
sendSmtpMail({ ...mail, port: mock.port, secure: false }),
).rejects.toMatchObject({
name: 'SmtpError',
responseCode: 535,
});
expect(mock.data).toBe('');
});
it('upgrades via STARTTLS then authenticates', async () => {
const mock = await listenMock({ starttls: true, mechs: 'PLAIN' });
await sendSmtpMail(
{ ...mail, port: mock.port, secure: false },
{ rejectUnauthorized: false },
);
expect(mock.commands).toContain('STARTTLS');
expect(mock.commands).toContain('AUTH PLAIN');
expect(mock.data).toContain('Subject: offline');
});
it('speaks implicit TLS when secure=true', async () => {
const mock = await listenMock({ secure: true, mechs: 'PLAIN' });
await sendSmtpMail(
{ ...mail, port: mock.port, secure: true },
{ rejectUnauthorized: false },
);
expect(mock.commands).toContain('AUTH PLAIN');
expect(mock.data).toContain('Subject: offline');
});
});