SnowLuma v2.4.2 source snapshot
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
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
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,325 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user