dcrouter/test/suite/smtpserver_commands/test.cmd-04.data-command.ts
2025-05-25 19:05:43 +00:00

395 lines
12 KiB
TypeScript

import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as net from 'net';
import { startTestServer, stopTestServer } from '../../helpers/server.loader.js';
const TEST_PORT = 2525;
let testServer;
const TEST_TIMEOUT = 15000;
tap.test('prepare server', async () => {
testServer = await startTestServer({ port: TEST_PORT });
await new Promise(resolve => setTimeout(resolve, 100));
});
tap.test('DATA - should accept email data after RCPT TO', async (tools) => {
const done = tools.defer();
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
let receivedData = '';
let currentStep = 'connecting';
socket.on('data', (data) => {
receivedData += data.toString();
if (currentStep === 'connecting' && receivedData.includes('220')) {
currentStep = 'ehlo';
receivedData = '';
socket.write('EHLO test.example.com\r\n');
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
currentStep = 'mail_from';
receivedData = '';
socket.write('MAIL FROM:<sender@example.com>\r\n');
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
currentStep = 'rcpt_to';
receivedData = '';
socket.write('RCPT TO:<recipient@example.com>\r\n');
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
currentStep = 'data_command';
receivedData = '';
socket.write('DATA\r\n');
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
currentStep = 'message_body';
receivedData = '';
// Send email content
socket.write('From: sender@example.com\r\n');
socket.write('To: recipient@example.com\r\n');
socket.write('Subject: Test message\r\n');
socket.write('\r\n'); // Empty line to separate headers from body
socket.write('This is a test message.\r\n');
socket.write('.\r\n'); // End of message
} else if (currentStep === 'message_body' && receivedData.includes('250')) {
expect(receivedData).toInclude('250');
socket.write('QUIT\r\n');
setTimeout(() => {
socket.destroy();
done.resolve();
}, 100);
}
});
socket.on('error', (error) => {
done.reject(error);
});
socket.on('timeout', () => {
socket.destroy();
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
});
await done.promise;
});
tap.test('DATA - should reject without RCPT TO', async (tools) => {
const done = tools.defer();
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
let receivedData = '';
let currentStep = 'connecting';
socket.on('data', (data) => {
receivedData += data.toString();
if (currentStep === 'connecting' && receivedData.includes('220')) {
currentStep = 'ehlo';
receivedData = '';
socket.write('EHLO test.example.com\r\n');
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
currentStep = 'data_without_rcpt';
receivedData = '';
// Try DATA without MAIL FROM or RCPT TO
socket.write('DATA\r\n');
} else if (currentStep === 'data_without_rcpt' && receivedData.includes('503')) {
// Should get 503 (bad sequence)
expect(receivedData).toInclude('503');
socket.write('QUIT\r\n');
setTimeout(() => {
socket.destroy();
done.resolve();
}, 100);
}
});
socket.on('error', (error) => {
done.reject(error);
});
socket.on('timeout', () => {
socket.destroy();
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
});
await done.promise;
});
tap.test('DATA - should accept empty message body', async (tools) => {
const done = tools.defer();
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
let receivedData = '';
let currentStep = 'connecting';
socket.on('data', (data) => {
receivedData += data.toString();
if (currentStep === 'connecting' && receivedData.includes('220')) {
currentStep = 'ehlo';
receivedData = '';
socket.write('EHLO test.example.com\r\n');
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
currentStep = 'mail_from';
receivedData = '';
socket.write('MAIL FROM:<sender@example.com>\r\n');
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
currentStep = 'rcpt_to';
receivedData = '';
socket.write('RCPT TO:<recipient@example.com>\r\n');
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
currentStep = 'data_command';
receivedData = '';
socket.write('DATA\r\n');
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
currentStep = 'empty_message';
receivedData = '';
// Send only the terminator
socket.write('.\r\n');
} else if (currentStep === 'empty_message') {
// Server should accept empty message
expect(receivedData).toMatch(/^(250|5\d\d)/);
socket.write('QUIT\r\n');
setTimeout(() => {
socket.destroy();
done.resolve();
}, 100);
}
});
socket.on('error', (error) => {
done.reject(error);
});
socket.on('timeout', () => {
socket.destroy();
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
});
await done.promise;
});
tap.test('DATA - should handle dot stuffing correctly', async (tools) => {
const done = tools.defer();
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
let receivedData = '';
let currentStep = 'connecting';
socket.on('data', (data) => {
receivedData += data.toString();
if (currentStep === 'connecting' && receivedData.includes('220')) {
currentStep = 'ehlo';
receivedData = '';
socket.write('EHLO test.example.com\r\n');
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
currentStep = 'mail_from';
receivedData = '';
socket.write('MAIL FROM:<sender@example.com>\r\n');
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
currentStep = 'rcpt_to';
receivedData = '';
socket.write('RCPT TO:<recipient@example.com>\r\n');
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
currentStep = 'data_command';
receivedData = '';
socket.write('DATA\r\n');
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
currentStep = 'dot_stuffed_message';
receivedData = '';
// Send message with dots that need stuffing
socket.write('This line is normal.\r\n');
socket.write('..This line starts with two dots (one will be removed).\r\n');
socket.write('.This line starts with a single dot.\r\n');
socket.write('...This line starts with three dots.\r\n');
socket.write('.\r\n'); // End of message
} else if (currentStep === 'dot_stuffed_message' && receivedData.includes('250')) {
expect(receivedData).toInclude('250');
socket.write('QUIT\r\n');
setTimeout(() => {
socket.destroy();
done.resolve();
}, 100);
}
});
socket.on('error', (error) => {
done.reject(error);
});
socket.on('timeout', () => {
socket.destroy();
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
});
await done.promise;
});
tap.test('DATA - should handle large messages', async (tools) => {
const done = tools.defer();
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
let receivedData = '';
let currentStep = 'connecting';
socket.on('data', (data) => {
receivedData += data.toString();
if (currentStep === 'connecting' && receivedData.includes('220')) {
currentStep = 'ehlo';
receivedData = '';
socket.write('EHLO test.example.com\r\n');
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
currentStep = 'mail_from';
receivedData = '';
socket.write('MAIL FROM:<sender@example.com>\r\n');
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
currentStep = 'rcpt_to';
receivedData = '';
socket.write('RCPT TO:<recipient@example.com>\r\n');
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
currentStep = 'data_command';
receivedData = '';
socket.write('DATA\r\n');
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
currentStep = 'large_message';
receivedData = '';
// Send a large message (100KB)
socket.write('From: sender@example.com\r\n');
socket.write('To: recipient@example.com\r\n');
socket.write('Subject: Large test message\r\n');
socket.write('\r\n');
// Generate 100KB of data
const lineContent = 'This is a test line that will be repeated many times. ';
const linesNeeded = Math.ceil(100000 / lineContent.length);
for (let i = 0; i < linesNeeded; i++) {
socket.write(lineContent + '\r\n');
}
socket.write('.\r\n'); // End of message
} else if (currentStep === 'large_message' && receivedData.includes('250')) {
expect(receivedData).toInclude('250');
socket.write('QUIT\r\n');
setTimeout(() => {
socket.destroy();
done.resolve();
}, 100);
}
});
socket.on('error', (error) => {
done.reject(error);
});
socket.on('timeout', () => {
socket.destroy();
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
});
await done.promise;
});
tap.test('DATA - should handle binary data in message', async (tools) => {
const done = tools.defer();
const socket = net.createConnection({
host: 'localhost',
port: TEST_PORT,
timeout: TEST_TIMEOUT
});
let receivedData = '';
let currentStep = 'connecting';
socket.on('data', (data) => {
receivedData += data.toString();
if (currentStep === 'connecting' && receivedData.includes('220')) {
currentStep = 'ehlo';
receivedData = '';
socket.write('EHLO test.example.com\r\n');
} else if (currentStep === 'ehlo' && receivedData.includes('250 ')) {
currentStep = 'mail_from';
receivedData = '';
socket.write('MAIL FROM:<sender@example.com>\r\n');
} else if (currentStep === 'mail_from' && receivedData.includes('250')) {
currentStep = 'rcpt_to';
receivedData = '';
socket.write('RCPT TO:<recipient@example.com>\r\n');
} else if (currentStep === 'rcpt_to' && receivedData.includes('250')) {
currentStep = 'data_command';
receivedData = '';
socket.write('DATA\r\n');
} else if (currentStep === 'data_command' && receivedData.includes('354')) {
currentStep = 'binary_message';
receivedData = '';
// Send message with binary data (base64 encoded attachment)
socket.write('From: sender@example.com\r\n');
socket.write('To: recipient@example.com\r\n');
socket.write('Subject: Binary test message\r\n');
socket.write('MIME-Version: 1.0\r\n');
socket.write('Content-Type: multipart/mixed; boundary="boundary123"\r\n');
socket.write('\r\n');
socket.write('--boundary123\r\n');
socket.write('Content-Type: text/plain\r\n');
socket.write('\r\n');
socket.write('This message contains binary data.\r\n');
socket.write('--boundary123\r\n');
socket.write('Content-Type: application/octet-stream\r\n');
socket.write('Content-Transfer-Encoding: base64\r\n');
socket.write('\r\n');
socket.write('SGVsbG8gV29ybGQhIFRoaXMgaXMgYmluYXJ5IGRhdGEu\r\n');
socket.write('--boundary123--\r\n');
socket.write('.\r\n'); // End of message
} else if (currentStep === 'binary_message' && receivedData.includes('250')) {
expect(receivedData).toInclude('250');
socket.write('QUIT\r\n');
setTimeout(() => {
socket.destroy();
done.resolve();
}, 100);
}
});
socket.on('error', (error) => {
done.reject(error);
});
socket.on('timeout', () => {
socket.destroy();
done.reject(new Error(`Connection timeout at step: ${currentStep}`));
});
await done.promise;
});
tap.test('cleanup server', async () => {
await stopTestServer(testServer);
});
export default tap.start();