522 lines
19 KiB
TypeScript
522 lines
19 KiB
TypeScript
|
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||
|
import * as plugins from './plugins.js';
|
||
|
import { createTestServer } from '../../helpers/server.loader.js';
|
||
|
import { createSmtpClient } from '../../helpers/smtp.client.js';
|
||
|
|
||
|
tap.test('CRFC-03: should comply with SMTP command syntax (RFC 5321)', async (tools) => {
|
||
|
const testId = 'CRFC-03-command-syntax';
|
||
|
console.log(`\n${testId}: Testing SMTP command syntax compliance...`);
|
||
|
|
||
|
let scenarioCount = 0;
|
||
|
|
||
|
// Scenario 1: EHLO/HELO command syntax
|
||
|
await (async () => {
|
||
|
scenarioCount++;
|
||
|
console.log(`\nScenario ${scenarioCount}: Testing EHLO/HELO command syntax`);
|
||
|
|
||
|
const testServer = await createTestServer({
|
||
|
onConnection: async (socket) => {
|
||
|
console.log(' [Server] Client connected');
|
||
|
socket.write('220 syntax.example.com ESMTP\r\n');
|
||
|
|
||
|
socket.on('data', (data) => {
|
||
|
const command = data.toString().trim();
|
||
|
console.log(` [Server] Received: ${command}`);
|
||
|
|
||
|
if (command.match(/^EHLO\s+[^\s]+$/i)) {
|
||
|
const domain = command.split(' ')[1];
|
||
|
console.log(` [Server] Valid EHLO with domain: ${domain}`);
|
||
|
|
||
|
// Validate domain format (basic check)
|
||
|
if (domain.includes('.') || domain === 'localhost' || domain.match(/^\[[\d\.]+\]$/)) {
|
||
|
socket.write('250-syntax.example.com\r\n');
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else {
|
||
|
socket.write('501 5.5.4 Invalid domain name\r\n');
|
||
|
}
|
||
|
} else if (command.match(/^HELO\s+[^\s]+$/i)) {
|
||
|
const domain = command.split(' ')[1];
|
||
|
console.log(` [Server] Valid HELO with domain: ${domain}`);
|
||
|
socket.write('250 syntax.example.com\r\n');
|
||
|
} else if (command === 'EHLO' || command === 'HELO') {
|
||
|
console.log(' [Server] Missing domain parameter');
|
||
|
socket.write('501 5.5.4 EHLO/HELO requires domain name\r\n');
|
||
|
} else if (command.startsWith('EHLO ') && command.split(' ').length > 2) {
|
||
|
console.log(' [Server] Too many parameters');
|
||
|
socket.write('501 5.5.4 EHLO syntax error\r\n');
|
||
|
} else if (command.startsWith('MAIL FROM:')) {
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else if (command.startsWith('RCPT TO:')) {
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else if (command === 'DATA') {
|
||
|
socket.write('354 Start mail input\r\n');
|
||
|
} else if (command === '.') {
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else if (command === 'QUIT') {
|
||
|
socket.write('221 Bye\r\n');
|
||
|
socket.end();
|
||
|
} else {
|
||
|
socket.write('500 5.5.1 Command not recognized\r\n');
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
|
||
|
const smtpClient = createSmtpClient({
|
||
|
host: testServer.hostname,
|
||
|
port: testServer.port,
|
||
|
secure: false,
|
||
|
name: 'client.example.com' // Valid domain
|
||
|
});
|
||
|
|
||
|
const email = new plugins.smartmail.Email({
|
||
|
from: 'sender@example.com',
|
||
|
to: ['recipient@example.com'],
|
||
|
subject: 'EHLO syntax test',
|
||
|
text: 'Testing proper EHLO syntax'
|
||
|
});
|
||
|
|
||
|
const result = await smtpClient.sendMail(email);
|
||
|
console.log(' Valid EHLO syntax accepted');
|
||
|
expect(result).toBeDefined();
|
||
|
expect(result.messageId).toBeDefined();
|
||
|
|
||
|
await testServer.server.close();
|
||
|
})();
|
||
|
|
||
|
// Scenario 2: MAIL FROM command syntax
|
||
|
await (async () => {
|
||
|
scenarioCount++;
|
||
|
console.log(`\nScenario ${scenarioCount}: Testing MAIL FROM command syntax`);
|
||
|
|
||
|
const testServer = await createTestServer({
|
||
|
onConnection: async (socket) => {
|
||
|
console.log(' [Server] Client connected');
|
||
|
socket.write('220 syntax.example.com ESMTP\r\n');
|
||
|
|
||
|
socket.on('data', (data) => {
|
||
|
const command = data.toString().trim();
|
||
|
console.log(` [Server] Received: ${command}`);
|
||
|
|
||
|
if (command.startsWith('EHLO')) {
|
||
|
socket.write('250-syntax.example.com\r\n');
|
||
|
socket.write('250-SIZE 10485760\r\n');
|
||
|
socket.write('250-8BITMIME\r\n');
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else if (command.match(/^MAIL FROM:\s*<[^>]*>(\s+[A-Z0-9]+=\S*)*\s*$/i)) {
|
||
|
// Valid MAIL FROM syntax with optional parameters
|
||
|
const address = command.match(/<([^>]*)>/)?.[1] || '';
|
||
|
console.log(` [Server] Valid MAIL FROM: ${address}`);
|
||
|
|
||
|
// Validate email address format
|
||
|
if (address === '' || address.includes('@') || address === 'postmaster') {
|
||
|
// Check for ESMTP parameters
|
||
|
const params = command.substring(command.indexOf('>') + 1).trim();
|
||
|
if (params) {
|
||
|
console.log(` [Server] ESMTP parameters: ${params}`);
|
||
|
|
||
|
// Validate parameter syntax
|
||
|
const validParams = /^(\s+[A-Z0-9]+=\S*)*\s*$/i.test(params);
|
||
|
if (validParams) {
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else {
|
||
|
socket.write('501 5.5.4 Invalid MAIL FROM parameters\r\n');
|
||
|
}
|
||
|
} else {
|
||
|
socket.write('250 OK\r\n');
|
||
|
}
|
||
|
} else {
|
||
|
socket.write('553 5.1.8 Invalid sender address\r\n');
|
||
|
}
|
||
|
} else if (command.startsWith('MAIL FROM:')) {
|
||
|
console.log(' [Server] Invalid MAIL FROM syntax');
|
||
|
if (!command.includes('<') || !command.includes('>')) {
|
||
|
socket.write('501 5.5.4 MAIL FROM requires <address>\r\n');
|
||
|
} else {
|
||
|
socket.write('501 5.5.4 Syntax error in MAIL FROM\r\n');
|
||
|
}
|
||
|
} else if (command.startsWith('RCPT TO:')) {
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else if (command === 'DATA') {
|
||
|
socket.write('354 Start mail input\r\n');
|
||
|
} else if (command === '.') {
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else if (command === 'QUIT') {
|
||
|
socket.write('221 Bye\r\n');
|
||
|
socket.end();
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
|
||
|
const smtpClient = createSmtpClient({
|
||
|
host: testServer.hostname,
|
||
|
port: testServer.port,
|
||
|
secure: false
|
||
|
});
|
||
|
|
||
|
// Test with various sender formats
|
||
|
const testCases = [
|
||
|
{ from: 'sender@example.com', desc: 'normal address' },
|
||
|
{ from: '', desc: 'null sender (bounce)' },
|
||
|
{ from: 'postmaster', desc: 'postmaster without domain' },
|
||
|
{ from: 'user+tag@example.com', desc: 'address with plus extension' }
|
||
|
];
|
||
|
|
||
|
for (const testCase of testCases) {
|
||
|
console.log(` Testing ${testCase.desc}...`);
|
||
|
|
||
|
const email = new plugins.smartmail.Email({
|
||
|
from: testCase.from || 'sender@example.com',
|
||
|
to: ['recipient@example.com'],
|
||
|
subject: `MAIL FROM syntax test: ${testCase.desc}`,
|
||
|
text: `Testing MAIL FROM with ${testCase.desc}`
|
||
|
});
|
||
|
|
||
|
// For null sender, modify the envelope
|
||
|
if (testCase.from === '') {
|
||
|
email.envelope = { from: '', to: ['recipient@example.com'] };
|
||
|
}
|
||
|
|
||
|
const result = await smtpClient.sendMail(email);
|
||
|
expect(result).toBeDefined();
|
||
|
}
|
||
|
|
||
|
await testServer.server.close();
|
||
|
})();
|
||
|
|
||
|
// Scenario 3: RCPT TO command syntax
|
||
|
await (async () => {
|
||
|
scenarioCount++;
|
||
|
console.log(`\nScenario ${scenarioCount}: Testing RCPT TO command syntax`);
|
||
|
|
||
|
const testServer = await createTestServer({
|
||
|
onConnection: async (socket) => {
|
||
|
console.log(' [Server] Client connected');
|
||
|
socket.write('220 syntax.example.com ESMTP\r\n');
|
||
|
|
||
|
socket.on('data', (data) => {
|
||
|
const command = data.toString().trim();
|
||
|
console.log(` [Server] Received: ${command}`);
|
||
|
|
||
|
if (command.startsWith('EHLO')) {
|
||
|
socket.write('250-syntax.example.com\r\n');
|
||
|
socket.write('250-DSN\r\n');
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else if (command.startsWith('MAIL FROM:')) {
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else if (command.match(/^RCPT TO:\s*<[^>]*>(\s+[A-Z0-9]+=\S*)*\s*$/i)) {
|
||
|
// Valid RCPT TO syntax with optional parameters
|
||
|
const address = command.match(/<([^>]*)>/)?.[1] || '';
|
||
|
console.log(` [Server] Valid RCPT TO: ${address}`);
|
||
|
|
||
|
// Validate recipient address
|
||
|
if (address.includes('@') && address.split('@').length === 2) {
|
||
|
// Check for DSN parameters
|
||
|
const params = command.substring(command.indexOf('>') + 1).trim();
|
||
|
if (params) {
|
||
|
console.log(` [Server] DSN parameters: ${params}`);
|
||
|
|
||
|
// Validate NOTIFY and ORCPT parameters
|
||
|
if (params.includes('NOTIFY=') || params.includes('ORCPT=')) {
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else {
|
||
|
socket.write('501 5.5.4 Invalid RCPT TO parameters\r\n');
|
||
|
}
|
||
|
} else {
|
||
|
socket.write('250 OK\r\n');
|
||
|
}
|
||
|
} else {
|
||
|
socket.write('553 5.1.3 Invalid recipient address\r\n');
|
||
|
}
|
||
|
} else if (command.startsWith('RCPT TO:')) {
|
||
|
console.log(' [Server] Invalid RCPT TO syntax');
|
||
|
if (!command.includes('<') || !command.includes('>')) {
|
||
|
socket.write('501 5.5.4 RCPT TO requires <address>\r\n');
|
||
|
} else {
|
||
|
socket.write('501 5.5.4 Syntax error in RCPT TO\r\n');
|
||
|
}
|
||
|
} else if (command === 'DATA') {
|
||
|
socket.write('354 Start mail input\r\n');
|
||
|
} else if (command === '.') {
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else if (command === 'QUIT') {
|
||
|
socket.write('221 Bye\r\n');
|
||
|
socket.end();
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
|
||
|
const smtpClient = createSmtpClient({
|
||
|
host: testServer.hostname,
|
||
|
port: testServer.port,
|
||
|
secure: false
|
||
|
});
|
||
|
|
||
|
// Test with various recipient formats
|
||
|
const recipients = [
|
||
|
'user@example.com',
|
||
|
'user.name@example.com',
|
||
|
'user+tag@example.com',
|
||
|
'user_name@sub.example.com'
|
||
|
];
|
||
|
|
||
|
const email = new plugins.smartmail.Email({
|
||
|
from: 'sender@example.com',
|
||
|
to: recipients,
|
||
|
subject: 'RCPT TO syntax test',
|
||
|
text: 'Testing RCPT TO command syntax'
|
||
|
});
|
||
|
|
||
|
const result = await smtpClient.sendMail(email);
|
||
|
console.log(` Valid RCPT TO syntax for ${recipients.length} recipients`);
|
||
|
expect(result).toBeDefined();
|
||
|
expect(result.accepted?.length).toBe(recipients.length);
|
||
|
|
||
|
await testServer.server.close();
|
||
|
})();
|
||
|
|
||
|
// Scenario 4: DATA command and message termination
|
||
|
await (async () => {
|
||
|
scenarioCount++;
|
||
|
console.log(`\nScenario ${scenarioCount}: Testing DATA command and message termination`);
|
||
|
|
||
|
const testServer = await createTestServer({
|
||
|
onConnection: async (socket) => {
|
||
|
console.log(' [Server] Client connected');
|
||
|
socket.write('220 syntax.example.com ESMTP\r\n');
|
||
|
|
||
|
let inDataMode = false;
|
||
|
let messageData = '';
|
||
|
|
||
|
socket.on('data', (data) => {
|
||
|
if (inDataMode) {
|
||
|
messageData += data.toString();
|
||
|
|
||
|
// Check for proper message termination
|
||
|
if (messageData.includes('\r\n.\r\n')) {
|
||
|
inDataMode = false;
|
||
|
console.log(' [Server] Message terminated with CRLF.CRLF');
|
||
|
|
||
|
// Check for transparency (dot stuffing)
|
||
|
const lines = messageData.split('\r\n');
|
||
|
let hasDotStuffing = false;
|
||
|
lines.forEach(line => {
|
||
|
if (line.startsWith('..')) {
|
||
|
hasDotStuffing = true;
|
||
|
console.log(' [Server] Found dot stuffing in line');
|
||
|
}
|
||
|
});
|
||
|
|
||
|
socket.write('250 OK: Message accepted\r\n');
|
||
|
messageData = '';
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const command = data.toString().trim();
|
||
|
console.log(` [Server] Received: ${command}`);
|
||
|
|
||
|
if (command.startsWith('EHLO')) {
|
||
|
socket.write('250-syntax.example.com\r\n');
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else if (command.startsWith('MAIL FROM:')) {
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else if (command.startsWith('RCPT TO:')) {
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else if (command === 'DATA') {
|
||
|
console.log(' [Server] Entering DATA mode');
|
||
|
socket.write('354 Start mail input; end with <CRLF>.<CRLF>\r\n');
|
||
|
inDataMode = true;
|
||
|
} else if (command === 'QUIT') {
|
||
|
socket.write('221 Bye\r\n');
|
||
|
socket.end();
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
|
||
|
const smtpClient = createSmtpClient({
|
||
|
host: testServer.hostname,
|
||
|
port: testServer.port,
|
||
|
secure: false
|
||
|
});
|
||
|
|
||
|
// Test with message containing dots at line start (transparency test)
|
||
|
const email = new plugins.smartmail.Email({
|
||
|
from: 'sender@example.com',
|
||
|
to: ['recipient@example.com'],
|
||
|
subject: 'DATA transparency test',
|
||
|
text: 'Line 1\n.This line starts with a dot\n..This line starts with two dots\nLine 4'
|
||
|
});
|
||
|
|
||
|
const result = await smtpClient.sendMail(email);
|
||
|
console.log(' DATA command and transparency handled correctly');
|
||
|
expect(result).toBeDefined();
|
||
|
expect(result.messageId).toBeDefined();
|
||
|
|
||
|
await testServer.server.close();
|
||
|
})();
|
||
|
|
||
|
// Scenario 5: RSET command syntax
|
||
|
await (async () => {
|
||
|
scenarioCount++;
|
||
|
console.log(`\nScenario ${scenarioCount}: Testing RSET command syntax`);
|
||
|
|
||
|
const testServer = await createTestServer({
|
||
|
onConnection: async (socket) => {
|
||
|
console.log(' [Server] Client connected');
|
||
|
socket.write('220 syntax.example.com ESMTP\r\n');
|
||
|
|
||
|
let transactionState = 'initial';
|
||
|
|
||
|
socket.on('data', (data) => {
|
||
|
const command = data.toString().trim();
|
||
|
console.log(` [Server] Received: ${command} (state: ${transactionState})`);
|
||
|
|
||
|
if (command.startsWith('EHLO')) {
|
||
|
socket.write('250-syntax.example.com\r\n');
|
||
|
socket.write('250 OK\r\n');
|
||
|
transactionState = 'ready';
|
||
|
} else if (command.startsWith('MAIL FROM:') && transactionState === 'ready') {
|
||
|
socket.write('250 OK\r\n');
|
||
|
transactionState = 'mail';
|
||
|
} else if (command.startsWith('RCPT TO:') && transactionState === 'mail') {
|
||
|
socket.write('250 OK\r\n');
|
||
|
transactionState = 'rcpt';
|
||
|
} else if (command === 'RSET') {
|
||
|
console.log(' [Server] RSET - resetting transaction state');
|
||
|
socket.write('250 OK\r\n');
|
||
|
transactionState = 'ready';
|
||
|
} else if (command.match(/^RSET\s+/)) {
|
||
|
console.log(' [Server] RSET with parameters - syntax error');
|
||
|
socket.write('501 5.5.4 RSET does not accept parameters\r\n');
|
||
|
} else if (command === 'DATA' && transactionState === 'rcpt') {
|
||
|
socket.write('354 Start mail input\r\n');
|
||
|
} else if (command === '.') {
|
||
|
socket.write('250 OK\r\n');
|
||
|
transactionState = 'ready';
|
||
|
} else if (command === 'QUIT') {
|
||
|
socket.write('221 Bye\r\n');
|
||
|
socket.end();
|
||
|
} else {
|
||
|
socket.write('503 5.5.1 Bad sequence of commands\r\n');
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
|
||
|
const smtpClient = createSmtpClient({
|
||
|
host: testServer.hostname,
|
||
|
port: testServer.port,
|
||
|
secure: false
|
||
|
});
|
||
|
|
||
|
// Start a transaction then reset it
|
||
|
const email = new plugins.smartmail.Email({
|
||
|
from: 'sender@example.com',
|
||
|
to: ['recipient@example.com'],
|
||
|
subject: 'RSET test',
|
||
|
text: 'Testing RSET command'
|
||
|
});
|
||
|
|
||
|
const result = await smtpClient.sendMail(email);
|
||
|
console.log(' RSET command syntax validated');
|
||
|
expect(result).toBeDefined();
|
||
|
|
||
|
await testServer.server.close();
|
||
|
})();
|
||
|
|
||
|
// Scenario 6: Command line length limits
|
||
|
await (async () => {
|
||
|
scenarioCount++;
|
||
|
console.log(`\nScenario ${scenarioCount}: Testing command line length limits`);
|
||
|
|
||
|
const maxLineLength = 512; // RFC 5321 limit
|
||
|
|
||
|
const testServer = await createTestServer({
|
||
|
onConnection: async (socket) => {
|
||
|
console.log(' [Server] Client connected');
|
||
|
socket.write('220 syntax.example.com ESMTP\r\n');
|
||
|
|
||
|
let lineBuffer = '';
|
||
|
|
||
|
socket.on('data', (data) => {
|
||
|
lineBuffer += data.toString();
|
||
|
|
||
|
const lines = lineBuffer.split('\r\n');
|
||
|
lineBuffer = lines.pop() || ''; // Keep incomplete line
|
||
|
|
||
|
lines.forEach(line => {
|
||
|
if (line.length === 0) return;
|
||
|
|
||
|
console.log(` [Server] Line length: ${line.length} chars`);
|
||
|
|
||
|
if (line.length > maxLineLength) {
|
||
|
console.log(' [Server] Line too long');
|
||
|
socket.write('500 5.5.1 Line too long\r\n');
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (line.startsWith('EHLO')) {
|
||
|
socket.write('250-syntax.example.com\r\n');
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else if (line.startsWith('MAIL FROM:')) {
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else if (line.startsWith('RCPT TO:')) {
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else if (line === 'DATA') {
|
||
|
socket.write('354 Start mail input\r\n');
|
||
|
} else if (line === '.') {
|
||
|
socket.write('250 OK\r\n');
|
||
|
} else if (line === 'QUIT') {
|
||
|
socket.write('221 Bye\r\n');
|
||
|
socket.end();
|
||
|
}
|
||
|
});
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
|
||
|
const smtpClient = createSmtpClient({
|
||
|
host: testServer.hostname,
|
||
|
port: testServer.port,
|
||
|
secure: false
|
||
|
});
|
||
|
|
||
|
// Test with normal length commands
|
||
|
const email = new plugins.smartmail.Email({
|
||
|
from: 'sender@example.com',
|
||
|
to: ['recipient@example.com'],
|
||
|
subject: 'Line length test',
|
||
|
text: 'Testing command line length limits'
|
||
|
});
|
||
|
|
||
|
const result = await smtpClient.sendMail(email);
|
||
|
console.log(' Normal command lengths accepted');
|
||
|
expect(result).toBeDefined();
|
||
|
|
||
|
// Test with very long recipient address
|
||
|
const longRecipient = 'very-long-username-that-exceeds-normal-limits@' + 'x'.repeat(400) + '.com';
|
||
|
|
||
|
const longEmail = new plugins.smartmail.Email({
|
||
|
from: 'sender@example.com',
|
||
|
to: [longRecipient],
|
||
|
subject: 'Long recipient test',
|
||
|
text: 'Testing very long recipient address'
|
||
|
});
|
||
|
|
||
|
try {
|
||
|
await smtpClient.sendMail(longEmail);
|
||
|
console.log(' Long command handled (possibly folded)');
|
||
|
} catch (error) {
|
||
|
console.log(' Long command rejected as expected');
|
||
|
expect(error.message).toContain('too long');
|
||
|
}
|
||
|
|
||
|
await testServer.server.close();
|
||
|
})();
|
||
|
|
||
|
console.log(`\n${testId}: All ${scenarioCount} command syntax scenarios tested ✓`);
|
||
|
});
|